imranali / smart-timezone
Automatic timezone detection and casting for Laravel - auto-detect user timezone from JS, header, cookie, user profile, or IP, and seamlessly cast all dates to local time.
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- nesbot/carbon: ^2.67|^3.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0
- torann/geoip: ^3.0
Suggests
- torann/geoip: Required only for IP-based timezone detection ('ip' in detect_from).
README
Stop timezone bugs forever. SmartTimezone auto-detects your users' timezone and makes every datetime "just work" in their local time.
No more ->setTimezone('UTC') spaghetti. No more "why does this show 5 hours off?"
What It Does
- Auto-detects timezone — from browser JS → cookie → user profile → IP → fallback
- Auto-casts dates —
$post->created_atshows in user timezone, saves as UTC - Blade ready —
@usertime($datetime)prints "25 Jun 2026, 6:45 PM PKT" - API friendly — Reads
X-Timezoneheader for mobile/SPA apps - Zero config — works for 80% of apps. Supports Laravel 10, 11, 12, 13.
Installation
composer require imranali/smart-timezone php artisan smart-timezone:install php artisan migrate
Make sure your layout <head> has a CSRF token, then add the script before </body>:
<meta name="csrf-token" content="{{ csrf_token() }}"> ... <script src="{{ asset('vendor/smart-timezone/timezone-detect.js') }}"></script>
The script sets a user_timezone cookie and (when the CSRF token is present)
pushes the detected zone to the current session immediately. Without the meta
tag it still works — the server just picks the zone up on the next request from
the cookie.
That's it. All dates now respect user timezone.
Usage
1. Cast any datetime automatically
use ImranAli\SmartTimezone\Casts\TimezoneDateTime; class Post extends Model { protected $casts = [ 'published_at' => TimezoneDateTime::class, 'created_at' => TimezoneDateTime::class, ]; }
$post = Post::find(1); echo $post->published_at; // "2026-06-25 18:45:00" for Lahore user // DB still stores "2026-06-25 13:45:00" UTC $post->published_at = now(); // Saves as UTC automatically // For models loaded BEFORE login, use getDateInTimezone() for fresh conversion $user = User::find(1); // Auth::login($user); // login happens after find echo $user->getDateInTimezone('created_at'); // always uses latest timezone
2. Blade directive
{{-- Returns Carbon instance --}} @usertime($post->published_at) {{-- Returns formatted string --}} @usertime($post->published_at, 'd M Y, g:i A T') {{-- Output: 25 Jun 2026, 6:45 PM PKT --}}
3. Manual conversion
use Carbon\Carbon; $utcDate = Carbon::parse('2026-06-25 10:00:00', 'UTC'); $local = $utcDate->inUserTimezone(); // Macro added by package echo $local->format('g:i A T'); // "3:00 PM PKT"
4. Add to User model
use ImranAli\SmartTimezone\Traits\HasTimezone; class User extends Authenticatable { use HasTimezone; // auto-syncs users.timezone column } auth()->user()->timezone; // "Asia/Karachi" User::inTimezone('Asia/Karachi')->get(); // all Lahore users
5. API / Mobile App Usage
Send the timezone header from your frontend. SmartTimezone picks it up automatically.
Axios / React
axios.defaults.headers.common['X-Timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone;
Flutter / Dart
dio.options.headers['X-Timezone'] = await FlutterNativeTimezone.getLocalTimezone();
Laravel API response:
Route::get('/posts', function () { return Post::all(); // published_at auto-converted to user TZ });
Response for a Lahore user:
{
"id": 1,
"title": "Hello World",
"published_at": "2026-06-25T18:45:00+05:00"
}
How Detection Works
When rendering a date, SmartTimezone resolves the timezone in this order:
| Priority | Source | When it's used |
|---|---|---|
| 1 | X-Timezone header |
API / mobile / SPA requests — overrides everything for that request |
| 2 | users.timezone DB |
Authenticated users — the source of truth once logged in |
| 3 | Session | Guests — populated by the middleware from the cookie or IP lookup |
| 4 | Model's own timezone |
User-owned rows (e.g. a Post belonging to a user) |
| 5 | config('smart-timezone.default') (APP_TIMEZONE) |
Nothing else matched |
The DetectTimezone middleware fills the session once per session for
guests, trying detect_from sources in order (header, cookie, user, ip).
The user_timezone cookie is set client-side by timezone-detect.js.
Key behaviors
- Header wins for API/SPA requests and is never persisted to the session.
- DB column wins for logged-in users — no "one request is wrong after login" race, because resolution reads the user's column directly.
- Every value is validated against
timezone_identifiers_list(). Offset-only values like+05:00are not IANA identifiers and are ignored — sendAsia/Karachi, not an offset. - Eloquent caches cast values. If a model was read before
Auth::login()and you need the post-login zone in the same request, use$model->getDateInTimezone('created_at')for a fresh conversion.
Queues, jobs, commands, and notifications
There is no request, session, or authenticated user in a queued job, an
Artisan command, or a queued notification/mail. In those contexts the cast falls
back to config('smart-timezone.default'). When you need a specific user's zone
outside a request, convert explicitly:
$localCreatedAt = $post->created_at ->clone() ->setTimezone($user->timezone ?? config('smart-timezone.default'));
Configuration
Published to config/smart-timezone.php:
return [ 'default' => env('APP_TIMEZONE', 'UTC'), 'detect_from' => ['header', 'cookie', 'user', 'ip'], 'api_header' => env('SMART_TIMEZONE_HEADER', 'X-Timezone'), 'user_column' => 'timezone', // null = never touch the DB 'auto_save_timezone' => true, // persist a detected zone to the user 'cookie_name' => 'user_timezone', 'cookie_lifetime' => 31536000, 'cookie_same_site' => 'Lax', 'cookie_secure' => null, // null = auto (Secure on HTTPS) 'session_key' => 'user_timezone', 'register_middleware' => true, // false = wire the middleware yourself // (aliases: timezone.detect / timezone.header) 'enable_js_route' => true, 'route_prefix' => '_smart-timezone', 'route_middleware' => ['web'], 'route_throttle' => '10,1', // null to disable rate limiting ];
Opting out of automatic middleware
By default the package pushes DetectTimezone onto the web group and
SetTimezoneFromHeader onto the api group. Set register_middleware to
false and add them where you want:
// bootstrap/app.php (Laravel 11+) ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [\ImranAli\SmartTimezone\Middleware\DetectTimezone::class]); $middleware->api(append: [\ImranAli\SmartTimezone\Middleware\SetTimezoneFromHeader::class]); })
Custom route prefix + JS
If you change route_prefix, tell the detector script where to POST:
<meta name="smart-timezone-endpoint" content="{{ url('my-prefix/set') }}">
Events
ImranAli\SmartTimezone\Events\TimezoneDetected fires whenever a timezone is
resolved and written to the session (web middleware, API header middleware, or
the JS route). It carries $timezone, $source
(header / cookie / user / ip / route / default), and $request.
A common use — copy a guest's detected zone onto their account at registration:
Event::listen(function (TimezoneDetected $event) { if (auth()->check() && blank(auth()->user()->timezone)) { auth()->user()->forceFill(['timezone' => $event->timezone])->saveQuietly(); } });
Commands
php artisan smart-timezone:install # publish config + assets (+ migration prompt) php artisan smart-timezone:install --force # overwrite existing published files
Requirements
- PHP 8.1+
- Laravel 10, 11, 12, or 13
torann/geoip— optional, only needed if you keep'ip'indetect_from. Install it yourself withcomposer require torann/geoip. Without it, IP detection is skipped silently.
Package Comparison
| Package | Auto JS Detect | API Header | Eloquent Cast | DB Saves UTC | SPA Ready |
|---|---|---|---|---|---|
| SmartTimezone | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
jamesmills/laravel-timezone |
❌ No | ❌ No | ✅ Yes | ✅ Yes | ❌ No |
spatie/laravel-timezone |
⚠️ Manual | ❌ No | ❌ No | ❌ No | ❌ No |
Testing
# Run all tests composer test # Run with output vendor/bin/phpunit --no-coverage # Run a specific test file vendor/bin/phpunit tests/TimezoneHelperTest.php # Run a specific test method vendor/bin/phpunit --filter test_converts_utc_date_to_user_timezone # Lint (Laravel Pint) composer lint
Contributing
PRs welcome! Please ensure tests pass before submitting:
composer test
composer lint
Security
- All timezone input (header, cookie, request body) is validated against
timezone_identifiers_list()before use. - The JS detection route (
POST {route_prefix}/set) runs behind thewebmiddleware (CSRF) and athrottlelimiter (route_throttle). - The detection cookie is
SameSite=LaxandSecureon HTTPS. It is notHttpOnlyby design — the browser script needs to read it. - IP detection sends the visitor's IP to whatever
torann/geoipdriver you configure. Drop'ip'fromdetect_fromif that is a concern.
If you discover a security vulnerability, please email imran.wtwm@gmail.com.
License
MIT. Use it in any project, commercial or personal.
Built by Imran Ali in Lahore, PK 🇵🇰
If this saved you from timezone hell, ⭐ star the repo!