lunetics/timezone-bundle
Symfony bundle that detects a visitor's timezone from their IP address and exposes it through a simple service, with configurable fallbacks for timezone-aware applications.
lunetics/timezone-bundle is still a Symfony bundle — no service provider, no facade, no auto-discovery, no config publishing. What changed: as of the 4.0.x line it is no longer tied to Symfony 2. The 3.0 line remains Symfony 2.x only (symfony/validator ~2.x), while 4.0.x targets a modern Symfony release — but per the corrected 4.0.1 notes, no released version of this bundle supports Symfony 6.4, and 4.0.0's own documentation wrongly told 6.4 users to pin 3.0, producing an unsatisfiable resolution.
Practical consequence: composer require lunetics/timezone-bundle in a Laravel 10/11 app may now succeed where it previously failed, and install a dependency that does nothing. The old hard failure is gone; do not read a green install as support.
First step is still a compatibility interrogation, in this order:
composer require lunetics/timezone-bundle --dry-run
composer why-not lunetics/timezone-bundle 3.0.0 # confirm what 3.x demands; never install it
composer show lunetics/timezone-bundle --all # read the real 4.x constraints, not the README
Trust composer.json / Packagist constraints over the bundle's own prose — the 4.0.0 docs were demonstrably wrong about version compatibility, and 4.0.1 only fixed the prose.
Where to look first:
composer.json — which Symfony line the 4.x branch actually declares. This decides everything.DependencyInjection/Configuration.php — authoritative config tree (nested keys, defaults, required values); READMEs lag.Resources/config/services.xml — real service IDs and constructor arguments.Timezone/ — small, and still the only part genuinely worth copying.First use case, unchanged in substance: take the client IP, resolve it to an IANA timezone, and apply it as the display timezone for the request — with a guaranteed fallback for private, unknown, or proxied addresses. On Laravel, the realistic first use case is porting the detector into a small TimezoneResolver service behind your own interface, then running it on geoip2/geoip2. If you are genuinely in a legacy Symfony app, register the bundle in Kernel/AppKernel and inject the detector service.
Request-scoped resolution, applied early. The core pattern is middleware/listener that runs before anything formats a date:
// Laravel equivalent of the bundle's request listener
public function handle($request, Closure $next)
{
$tz = $request->user()?->timezone
?? $this->resolver->fromIp($request->ip())
?? config('app.timezone');
config(['app.timezone' => $tz]);
date_default_timezone_set($tz);
Carbon::setTimezone($tz); // if you rely on Carbon's default
return $next($request);
}
Register it near the top of the web group, after TrustProxies.
A fallback hierarchy, not a single lookup. IP detection should be the weakest source in the chain:
Accept-Language → country → representative timezone (small static map).UTC).The bundle gives you step 2 only; the surrounding chain is yours.
Cache IP → timezone aggressively. A GeoIP lookup per request is wasteful, and with file-based databases under php-fpm every worker loads the dataset.
$tz = Cache::remember("tz:{$ip}", now()->addDay(), fn () => $resolver->fromIp($ip));
Key by IP, not by user, and normalize IPv6 — the same visitor can present a rotated address.
Use detection to prefill, not to decide. Detect on first visit, preselect the timezone dropdown, persist what the user confirms. After that the stored value wins and detection is only a fallback. This removes almost every "my meeting showed up at the wrong hour" report.
Keep detection out of CLI and queues. Jobs, scheduled commands, and notifications should receive an explicit timezone (or a user ID to load it from). Never let a queue worker resolve from request()->ip() — there is no request.
Bind behind an interface when porting.
$this->app->singleton(TimezoneResolver::class, fn () => new GeoIpResolver(
databasePath: config('services.geoip.path'),
fallback: config('app.timezone'),
));
That gives you a test double and a one-file swap path to geoip2/geoip2 or a hosted API.
Testing pattern. Assert the fallback, not the geolocation: 127.0.0.1, 10.0.0.1, and ::1 must all return the configured default. Then stub the resolver in feature tests so date assertions are deterministic.
symfony/validator ~2.1 and cannot resolve. Never pin 3.x in anything modern, and verify compatibility from composer.json, not from README prose or release notes claiming a Symfony version.composer require can resolve inside a Laravel app because the Symfony component constraints overlap — the bundle still registers nothing. Check for a service provider before believing it works, and expect config()/container access from bundle classes to fail if you call them directly..dat yourself, and the legacy MaxMind GeoLiteCity format is deprecated — downloads and updates are effectively gone. This is the biggest reason the bundle is hard to run at all today. Plan on GeoIP2/MMDB plus an adapter, or a hosted service.REMOTE_ADDR is frequently wrong. Behind a load balancer, CDN, or Docker ingress it is the proxy's IP. Configure trusted proxies (Laravel's TrustProxies, Symfony's Request::setTrustedProxies) before detection, or every user resolves to the same datacenter timezone.10/8, 172.16/12, 192.168/16, 127/8, and IPv6 ULA addresses have no meaningful geolocation. Return the fallback explicitly rather than letting the database return garbage.date_default_timezone_set() mid-request after dates exist produces subtle off-by-one-hour bugs. Note config(['app.timezone' => ...]) does not retroactively fix already-resolved Carbon instances.%kernel.root_dir%-style parameters, services are XML/YAML without autowiring, constructors assume the old container. Strip these before porting into Laravel — do not try to bridge the container.ip, detected_tz, source, fallback_used) turns "times are wrong for one user" into a grep. Sample in production if volume matters.How can I help you explore Laravel packages today?