Skip to content

feat!: rebuild timezone handling for PHP 8.2 and Symfony 6.4–8.1#21

Draft
lunetics wants to merge 3 commits into
masterfrom
chore/php8-symfony8-modernization
Draft

feat!: rebuild timezone handling for PHP 8.2 and Symfony 6.4–8.1#21
lunetics wants to merge 3 commits into
masterfrom
chore/php8-symfony8-modernization

Conversation

@lunetics

@lunetics lunetics commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

  • rebuild TimezoneBundle as a clean V2 with PHP 8.2+ and Symfony 6.4, 7.4, and 8.x support
  • move all production PHP code into src/ and introduce typed resolver, storage, context, event, and exception contracts
  • add session and signed-cookie persistence plus user, OIDC, MaxMind City, locale, browser, Twig, Form, Messenger, profiler, and console adapters
  • document architecture, contracts, ADRs, configuration, and the complete 1.x migration path

Review hardening

  • preserve Symfony profiler diagnostics across serialization and reload
  • use an application PSR clock internally without replacing its global alias, with a bundle fallback
  • map missing-session browser writes to typed storage failures and HTTP 503
  • reject alias cycles, unknown integration keys, invalid cookie names, and blank MaxMind identifiers
  • sanitize authenticated-user resolver failures and retain strict callable adapter semantics
  • verify automatic AssetMapper discovery and release archive contents

Validation

  • Composer strict validation and security audit
  • PHPStan at max level
  • PHPUnit: 179 tests, 608 assertions
  • Node: 5 tests
  • fresh composer update --no-dev plus package smoke and audit
  • fresh composer update --prefer-lowest plus PHPStan and 179 tests
  • PHP syntax, workflow YAML, Markdown links, whitespace, and real git archive policy checks

Breaking changes

V2 intentionally removes all legacy guesser, provider, event, and validator APIs without a compatibility layer. See UPGRADE-2.0.md.

Greptile Summary

This PR is a full V2 rebuild of the lunetics/timezone-bundle, replacing all legacy guesser/provider/event/validator APIs with a typed resolver chain, signed-cookie and session storage, PSR clock integration, and optional bridges for Twig, Messenger, MaxMind, OIDC, and the Symfony profiler. The new code is in src/ under PHP 8.2+ with strict types throughout.

  • Resolution chain: A priority-ordered TimezoneResolverChain drives pluggable resolvers (request attribute, header, user, OIDC, MaxMind, locale-mapping, locale-country) with per-attempt tracing and configurable continue/throw failure strategies.
  • Storage: Both SessionTimezoneStorage and CookieTimezoneStorage (HMAC-SHA256 signed, base64url encoded) implement a shared TimezonePreferenceStorageInterface; the cookie storage validates __Host-/SameSite=None requirements at construction and run time.
  • Browser endpoint: An opt-in BrowserTimezoneController receives JSON POST with CSRF validation, reads/compares current preference, writes BROWSER-source preference, and dispatches TimezonePreferenceChangedEvent.

Confidence Score: 4/5

Safe to merge; the new architecture is well-structured, security-sensitive paths (HMAC signature verification, CSRF, SameSite/Host prefix guards) are implemented correctly, and the resolver chain handles failures and defaults cleanly.

The only non-trivial issue is that TimezoneStorageException thrown by CookieTimezoneStorage::resolveSecure() gets caught and re-wrapped by the RuntimeException catch in write(), causing the informative root-cause message to be hidden behind a generic one. The hardcoded priority literals in loadExtension() are a maintenance risk given the constants defined in StoredPreferenceTimezoneResolver. Neither affects current correctness; both are easy to fix.

src/Storage/CookieTimezoneStorage.php (exception re-wrapping in write()) and src/LuneticsTimezoneBundle.php (priority literals vs. constants).

Important Files Changed

Filename Overview
src/LuneticsTimezoneBundle.php Core bundle bootstrap: registers all services, parses/validates config, and wires the resolver chain. Priority literals for stored_manual/stored_browser are hardcoded instead of referencing the constants in StoredPreferenceTimezoneResolver.
src/Storage/CookieTimezoneStorage.php Signed cookie storage with HMAC-SHA256, base64url encoding, and robust validation. TimezoneStorageException from resolveSecure() gets double-wrapped by the RuntimeException catch in write().
src/Controller/BrowserTimezoneController.php Browser-preference POST endpoint with CSRF, size check, JSON validation, MANUAL-source guard, idempotency, and storage+event dispatch. Logic is sound.
src/Resolution/TimezoneResolverChain.php Priority-ordered resolver chain with hrtime-based per-attempt duration tracing, configurable failure strategies, and request-attribute trace attachment.
src/Storage/SessionTimezoneStorage.php Session-backed preference storage; avoids unnecessary session starts by checking for an existing session cookie before accessing the session.
src/DependencyInjection/Compiler/TimezoneCompilerPass.php Compiler pass: resolves clock alias, validates resolver index uniqueness, performs contract checks, and enables optional integrations (Twig, profiler, user, OIDC, CSRF).
src/Resolver/HeaderTimezoneResolver.php Header-based resolver with three trust modes: framework proxy, IP allowlist, and any-source. Correctly rejects multi-value headers and enforces allowlist non-empty invariant.
src/Resolver/StoredPreferenceTimezoneResolver.php Shared read-caching across the two stored resolver instances (MANUAL/BROWSER) via READ_ATTRIBUTE. Defines MANUAL_PRIORITY and BROWSER_PRIORITY constants that are not referenced by the bundle's DI registration.
src/EventListener/ResolveTimezoneListener.php Subscribes to kernel.request at priority 1, safely after the security firewall (priority 8), sets RESOLUTION_ATTRIBUTE, and dispatches TimezoneResolvedEvent.
src/Bridge/WebProfiler/TimezoneDataCollector.php Data collector with runtime type validation of diagnostics data for profiler-serialization safety.
src/Timezone/TimezoneId.php Value object wrapping a validated timezone identifier with static cache for known identifiers, proper __serialize/__unserialize, and readonly safety.
src/Resolver/MaxMindTimezoneResolver.php MaxMind City resolver with private-range/reserved-range filtering using FILTER_VALIDATE_IP and IpUtils. Handles both TimezoneId and string returns from the reader interface.

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant K as Symfony Kernel
    participant RTL as ResolveTimezoneListener
    participant TRC as TimezoneResolverChain
    participant R as Resolvers
    participant S as Storage
    participant BTC as BrowserTimezoneController
    participant IPCL as InvalidPreferenceCleanupListener

    C->>K: Any HTTP Request
    K->>RTL: kernel.request priority 1
    RTL->>TRC: resolve(request)
    TRC->>R: iterate resolvers by priority
    R->>S: read preference
    S-->>R: TimezonePreferenceRead
    R-->>TRC: TimezoneResolution or null
    TRC-->>RTL: TimezoneResolution or default
    RTL->>K: set RESOLUTION_ATTRIBUTE
    RTL->>K: dispatch TimezoneResolvedEvent

    C->>BTC: POST /timezone
    BTC->>S: read preference
    S-->>BTC: TimezonePreferenceRead
    Note over BTC: Skip if MANUAL source
    BTC->>S: write BROWSER preference
    BTC->>K: dispatch TimezonePreferenceChangedEvent

    K->>IPCL: kernel.response
    IPCL->>S: clear if INVALID or EXPIRED
Loading

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/LuneticsTimezoneBundle.php:220-223
The priority values for `stored_manual` (925) and `stored_browser` (800) are hardcoded as literals here, but `StoredPreferenceTimezoneResolver` already defines `MANUAL_PRIORITY = 925` and `BROWSER_PRIORITY = 800` as named constants. If the constants are updated, the DI registration silently stays at the old numbers, letting priorities and documentation drift apart.

```suggestion
        $services->set('lunetics_timezone.resolver.stored_manual', StoredPreferenceTimezoneResolver::class)
            ->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::MANUAL, $persistenceStrategy])
            ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => StoredPreferenceTimezoneResolver::MANUAL_PRIORITY, 'index' => 'stored_manual']);
        $addToCatalog('stored_manual', StoredPreferenceTimezoneResolver::MANUAL_PRIORITY);
```

### Issue 2 of 3
src/LuneticsTimezoneBundle.php:236-239
Same constant-vs-literal divergence for the `stored_browser` resolver.

```suggestion
        $services->set('lunetics_timezone.resolver.stored_browser', StoredPreferenceTimezoneResolver::class)
            ->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::BROWSER, $persistenceStrategy])
            ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => StoredPreferenceTimezoneResolver::BROWSER_PRIORITY, 'index' => 'stored_browser']);
        $addToCatalog('stored_browser', StoredPreferenceTimezoneResolver::BROWSER_PRIORITY);
```

### Issue 3 of 3
src/Storage/CookieTimezoneStorage.php:109-123
**`TimezoneStorageException` from `resolveSecure()` gets double-wrapped**

`TimezoneStorageException` extends `TimezoneException extends \RuntimeException`, so when `resolveSecure()` throws `new TimezoneStorageException('The configured timezone cookie requires a secure request.')` on a non-HTTPS request with `SameSite=None` or `__Host-` naming, that exception is caught by `catch (\RuntimeException $failure)` and re-thrown as `TimezoneStorageException::operationFailed('write', $failure)`. The top-level message becomes the generic "Timezone preference storage write failed.", demoting the specific root cause to the `previous` exception chain. Callers inspecting `$e->getMessage()` without traversing the chain get no actionable detail.

Consider excluding already-`PersistenceFailureExceptionInterface` instances from the re-wrapping: `if ($failure instanceof PersistenceFailureExceptionInterface) { throw $failure; }` at the top of the catch block.

Reviews (1): Last reviewed commit: "fix: keep Symfony 8.1 and PHP 8.5 CI str..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

lunetics added 3 commits July 22, 2026 23:41
Move production code into src/, add typed resolver and storage contracts, ship optional framework adapters, and harden diagnostics, persistence, packaging, documentation, and CI.

BREAKING CHANGE: V2 removes the legacy guesser, event, provider, and validator APIs without a compatibility layer. See UPGRADE-2.0.md.
Comment on lines +220 to +223
$services->set('lunetics_timezone.resolver.stored_manual', StoredPreferenceTimezoneResolver::class)
->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::MANUAL, $persistenceStrategy])
->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 925, 'index' => 'stored_manual']);
$addToCatalog('stored_manual', 925);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The priority values for stored_manual (925) and stored_browser (800) are hardcoded as literals here, but StoredPreferenceTimezoneResolver already defines MANUAL_PRIORITY = 925 and BROWSER_PRIORITY = 800 as named constants. If the constants are updated, the DI registration silently stays at the old numbers, letting priorities and documentation drift apart.

Suggested change
$services->set('lunetics_timezone.resolver.stored_manual', StoredPreferenceTimezoneResolver::class)
->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::MANUAL, $persistenceStrategy])
->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 925, 'index' => 'stored_manual']);
$addToCatalog('stored_manual', 925);
$services->set('lunetics_timezone.resolver.stored_manual', StoredPreferenceTimezoneResolver::class)
->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::MANUAL, $persistenceStrategy])
->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => StoredPreferenceTimezoneResolver::MANUAL_PRIORITY, 'index' => 'stored_manual']);
$addToCatalog('stored_manual', StoredPreferenceTimezoneResolver::MANUAL_PRIORITY);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/LuneticsTimezoneBundle.php
Line: 220-223

Comment:
The priority values for `stored_manual` (925) and `stored_browser` (800) are hardcoded as literals here, but `StoredPreferenceTimezoneResolver` already defines `MANUAL_PRIORITY = 925` and `BROWSER_PRIORITY = 800` as named constants. If the constants are updated, the DI registration silently stays at the old numbers, letting priorities and documentation drift apart.

```suggestion
        $services->set('lunetics_timezone.resolver.stored_manual', StoredPreferenceTimezoneResolver::class)
            ->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::MANUAL, $persistenceStrategy])
            ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => StoredPreferenceTimezoneResolver::MANUAL_PRIORITY, 'index' => 'stored_manual']);
        $addToCatalog('stored_manual', StoredPreferenceTimezoneResolver::MANUAL_PRIORITY);
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment on lines +236 to +239
$services->set('lunetics_timezone.resolver.stored_browser', StoredPreferenceTimezoneResolver::class)
->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::BROWSER, $persistenceStrategy])
->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 800, 'index' => 'stored_browser']);
$addToCatalog('stored_browser', 800);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same constant-vs-literal divergence for the stored_browser resolver.

Suggested change
$services->set('lunetics_timezone.resolver.stored_browser', StoredPreferenceTimezoneResolver::class)
->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::BROWSER, $persistenceStrategy])
->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 800, 'index' => 'stored_browser']);
$addToCatalog('stored_browser', 800);
$services->set('lunetics_timezone.resolver.stored_browser', StoredPreferenceTimezoneResolver::class)
->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::BROWSER, $persistenceStrategy])
->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => StoredPreferenceTimezoneResolver::BROWSER_PRIORITY, 'index' => 'stored_browser']);
$addToCatalog('stored_browser', StoredPreferenceTimezoneResolver::BROWSER_PRIORITY);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/LuneticsTimezoneBundle.php
Line: 236-239

Comment:
Same constant-vs-literal divergence for the `stored_browser` resolver.

```suggestion
        $services->set('lunetics_timezone.resolver.stored_browser', StoredPreferenceTimezoneResolver::class)
            ->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::BROWSER, $persistenceStrategy])
            ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => StoredPreferenceTimezoneResolver::BROWSER_PRIORITY, 'index' => 'stored_browser']);
        $addToCatalog('stored_browser', StoredPreferenceTimezoneResolver::BROWSER_PRIORITY);
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment on lines +109 to +123
public function write(Request $request, Response $response, TimezonePreference $preference): void
{
try {
$payload = json_encode(['v' => 1, 'timezone' => $preference->timezone->value(), 'source' => $preference->source->value, 'recorded_at' => $preference->recordedAt->format(\DateTimeInterface::ATOM)], JSON_THROW_ON_ERROR);
$payload64 = $this->base64UrlEncode($payload);
$value = $payload64.'.'.$this->base64UrlEncode(hash_hmac('sha256', $payload64, $this->key, true));
if (strlen($value) > $this->maxEncodedSize) {
throw new \LengthException('Encoded timezone cookie exceeds its size limit.');
}
$secure = $this->resolveSecure($request);
$response->headers->setCookie(Cookie::create($this->name, $value, $this->clock->now()->getTimestamp() + $this->maxAge, $this->path, $this->domain, $secure, $this->httpOnly, false, $this->cookieSameSite()));
} catch (\JsonException|\LengthException|\RuntimeException $failure) {
throw TimezoneStorageException::operationFailed('write', $failure);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TimezoneStorageException from resolveSecure() gets double-wrapped

TimezoneStorageException extends TimezoneException extends \RuntimeException, so when resolveSecure() throws new TimezoneStorageException('The configured timezone cookie requires a secure request.') on a non-HTTPS request with SameSite=None or __Host- naming, that exception is caught by catch (\RuntimeException $failure) and re-thrown as TimezoneStorageException::operationFailed('write', $failure). The top-level message becomes the generic "Timezone preference storage write failed.", demoting the specific root cause to the previous exception chain. Callers inspecting $e->getMessage() without traversing the chain get no actionable detail.

Consider excluding already-PersistenceFailureExceptionInterface instances from the re-wrapping: if ($failure instanceof PersistenceFailureExceptionInterface) { throw $failure; } at the top of the catch block.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Storage/CookieTimezoneStorage.php
Line: 109-123

Comment:
**`TimezoneStorageException` from `resolveSecure()` gets double-wrapped**

`TimezoneStorageException` extends `TimezoneException extends \RuntimeException`, so when `resolveSecure()` throws `new TimezoneStorageException('The configured timezone cookie requires a secure request.')` on a non-HTTPS request with `SameSite=None` or `__Host-` naming, that exception is caught by `catch (\RuntimeException $failure)` and re-thrown as `TimezoneStorageException::operationFailed('write', $failure)`. The top-level message becomes the generic "Timezone preference storage write failed.", demoting the specific root cause to the `previous` exception chain. Callers inspecting `$e->getMessage()` without traversing the chain get no actionable detail.

Consider excluding already-`PersistenceFailureExceptionInterface` instances from the re-wrapping: `if ($failure instanceof PersistenceFailureExceptionInterface) { throw $failure; }` at the top of the catch block.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant