feat!: rebuild timezone handling as 4.0 for PHP 8.3 and Symfony 7.4–8.1
Why: V1 keeps per-request state in shared guesser services and persists every effective timezone — including locale guesses and the configured default — into the session, which freezes a weak first-request guess and is unsafe in long-running workers. V2 is a clean major that rebuilds resolution, persistence and framework integration around immutable, request-local contracts.
Changes:
- A deterministic resolver chain replaces the guesser manager: priority is policy, resolvers are stateless and return complete immutable results, and every externally sourced timezone enters through
TimezoneIdvalidation. - Only direct client or user preferences are persisted (session or signed cookie); GeoIP, locale mapping and the configured default stay request-local, so an inference can never mask a better source later.
- The current timezone lives on the request plus an exception-safe execution scope instead of a process-global
date_default_timezone_set(), keeping Messenger workers and consecutive requests isolated. - Optional bridges (Twig, Form, Messenger, Security/OIDC, MaxMind, profiler, console) are registered only when their dependency exists and fail at container-compile time with an actionable message when enabled without it.
- The opt-in browser endpoint (POST JSON, CSRF on by default, strict shape validation) is the only write path the bundle owns; no route is active until the application imports it.
Hardening from the review rounds (commits 74d8f65..ebb3c9f):
- Preference-write tracking moved into a storage decorator on the configured service id, so neither a custom storage nor a subrequest write can lose a freshly written preference to the invalid-preference cleanup. Three independent review arms reproduced that data loss with executed probes.
- The MaxMind non-public range list is self-sufficient rather than delegating to
IpUtils::PRIVATE_SUBNETS: that constant omits the 6to4 and NAT64 transition prefixes below 6.4.41 / 7.4.13 / 8.0.13 (CVE-2026-48736), which would leak translated private addresses to the reader on the supported floor. - The
__Secure-cookie prefix is now validated like its stricter__Host-sibling, instead of silently emitting a cookie browsers reject. - The distribution gate parses the real
git archiveoutput (PAX and GNU long names, symlinks, asserted exit code) instead of asserting.gitattributesagainst a copy of itself. failOnDeprecationactually binds again:ignoreDirectDeprecationshad filtered exactly the deprecation class this bundle can produce.
Accepted risk: the profiler reports preference_cleared for a forwarded-controller write where the storage turned the cleanup into a no-op — diagnostics only, the preference itself survives. External review coverage is single-model: the grok arm failed four times (cancelled runs, one fabricated finding set), so omission coverage rests on codex alone.
Release number: this ships as 4.0.0, not 2.0. Packagist already serves 2.1.0–2.1.3 and v3.0 (2015) for this package, so a 2.0 tag would be a version regression — composer require would keep resolving the decade-old v3.0 and nobody on ^3.0 would ever see the rebuild. The docs also called the predecessor "1.x", a line that was never published; the upgrade path is stated as 2.1/3.0 now.
BC note: breaking by design for this published package. All 1.x guesser, provider, event and validator APIs are removed without a compatibility layer, and storage, user and OIDC contracts are new. The supported range is now PHP ^8.3 and Symfony ^7.4.13 || ^8.1: unmaintained Symfony 8.0 (support ended July 2026), the older 6.4 LTS (bug fixes end November 2026) and end-of-life PHP 8.2 (December 2026) are out, and the floor excludes the http-foundation versions affected by CVE-2026-48736. Applications on 6.4 stay on the 1.x line. Migration path: UPGRADE-4.0.md.
Verification:
- PHPUnit 196 tests / 823 assertions and PHPStan at max level without baseline: green at
ebb3c9f. --prefer-lowestreproduced in a clean clone: resolvessymfony/http-foundation v6.4.0(12PRIVATE_SUBNETSentries, no2001::/32, no64:ff9b::/96), suite green 196/821 — this leg was deterministically red before the MaxMind fix.git archive HEAD | tar -ttop level equals the test's allowlist, and every tracked entry is either export-ignored or allowed (checked per entry withgit check-attr).- Multi-round review over the rebuild and each fix loop: 3× internal depth (opus), 2× internal breadth (sonnet), 2× external (codex gpt-5.6-sol at xhigh), plus verify-triage and consensus synthesis; every confirmed finding is fixed, with executed probes for both data-loss paths and the cookie/fragment edge.
References: rebuild fe348df, fixes 74d8f65..ebb3c9f · CVE-2026-48736 (symfony/http-foundation) · review artifacts under .review/codex-v2-rebuild-2026-07-27/ (local, not committed).
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
TimezoneResolverChaindrives pluggable resolvers (request attribute, header, user, OIDC, MaxMind, locale-mapping, locale-country) with per-attempt tracing and configurablecontinue/throwfailure strategies. - Storage: Both
SessionTimezoneStorageandCookieTimezoneStorage(HMAC-SHA256 signed, base64url encoded) implement a sharedTimezonePreferenceStorageInterface; the cookie storage validates__Host-/SameSite=Nonerequirements at construction and run time. - Browser endpoint: An opt-in
BrowserTimezoneControllerreceives JSON POST with CSRF validation, reads/compares current preference, writes BROWSER-source preference, and dispatchesTimezonePreferenceChangedEvent.
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
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.
Summary by CodeRabbit
- New Features
- Introduced LuneticsTimezoneBundle 2.0 for modern PHP and Symfony versions.
- Added prioritized timezone resolution from requests, users, OIDC claims, locales, headers, stored preferences, and MaxMind.
- Added session or signed-cookie persistence and browser timezone synchronization with optional CSRF protection.
- Added integrations for forms, Twig, Messenger, console diagnostics, and the Symfony profiler.
- Documentation
- Added comprehensive configuration, scope, resolver, and upgrade guidance.
- Chores
- Modernized automated quality checks, packaging, licensing, and distribution rules.