jord-jd/dates-timezone-conversion-trait
A Laravel trait for Eloquent models that simplifies timezone conversion for date and time attributes, helping keep stored and displayed values consistent without repetitive conversion code in your models.
This package makes Eloquent date/datetime attributes timezone-aware: values are written in the authenticated user's timezone and stored in the application timezone (config('app.timezone')), then converted back on read. v3.1.0 is still a drop-in for very old stacks (Laravel 5.1+, PHP 5.5.9+), so nothing in your composer.json needs to move to adopt it.
Minimal setup:
composer require the package at ^3.1.php artisan vendor:publish) — the only knob that matters day one is the fallback timezone used when there is no authenticated user.$casts, for the attributes that represent user-entered times.DATETIME/TIMESTAMP column that stores a naive value (see Gotchas — this is where most silent breakage lives).Look first at:
src/ — specifically the get/set pair, because that's where the auth-user lookup and the Carbon clone happen. Everything surprising in this package originates there.$dates entries and which $casts keys (datetime, date, immutable_datetime, custom_datetime) are recognised, which is faster than reading the resolver.First use case: a starts_at on something a user schedules. The user types 2025-06-01 09:00 in a form while their profile says America/New_York; the package converts that to UTC (or whatever app.timezone is) before it hits the database, and hands you back a Carbon in New York time when you render it. You stop writing ->setTimezone(...) in controllers and stop storing user-local strings by accident.
Opt in per model, not globally. Apply the trait only to models with genuinely user-facing times (schedules, reminders, deadlines). Leave audit columns (created_at, updated_at, deleted_at) on stock Eloquent behaviour — they're server events, not user events, and converting them makes logs and whereBetween queries confusing.
Both cast styles are supported — use that to migrate gradually. Legacy protected $dates = ['starts_at'] and modern $casts = ['starts_at' => 'datetime'] are both recognised as of 3.1.0. That means you can adopt the package on an old model without rewriting its casts first, then migrate to $casts at your own pace. immutable_datetime is recognised too, and reads come back immutable, so the usual immutable semantics apply ($model->starts_at->addHour() returns a new instance).
Assign strings from forms, assign Carbon from code. The "interpret strings in the user's timezone" rule only fires for strings. When you already have an absolute instant (from an API, a job payload, a Carbon::now()), pass the object through and the package won't re-interpret it. Mixing the two in the same code path is the most common source of off-by-an-offset bugs.
// Form input — interpreted in the user's tz, stored in app tz
$event->starts_at = $request->input('starts_at'); // "2025-06-01 09:00"
// Programmatic — already an absolute instant, passed through
$event->starts_at = Carbon::now()->addWeek();
Nullable fields just work now. 3.1.0 passes null through unchanged instead of throwing or coercing, so optional ranges are safe:
$event->ends_at = $request->input('ends_at') ?: null; // stays null
Pair with nullable|date validation and a nullable column; you no longer need if ($value !== null) guards around the assignment.
Treat reads as copies. The returned Carbon is cloned, so you can mutate it freely without corrupting the model — which also means mutating it does not write back:
$display = $model->starts_at->addHour(); // fine, $model untouched
$model->starts_at->addHour(); $model->save(); // no-op — assign it instead
$model->starts_at = $model->starts_at->addHour();
Keep the timezone resolution explicit in non-HTTP contexts. Queued jobs, console commands, and scheduled tasks have no authenticated user. Decide deliberately whether those code paths should (a) resolve the owner's timezone yourself and set it, or (b) accept the configured fallback. For anything that renders user-facing times later, store the timezone next to the record or resolve it from the owning user, not from auth() at job runtime.
Serialise through the cast. toArray()/toJson() go through the same accessor, so API resources already emit user-timezone values. Normalise the format explicitly in the resource (->toIso8601String()) rather than relying on the default Y-m-d H:i:s, which loses the offset and confuses clients.
Testing. actingAs($user) plus $this->travelTo(...) covers the happy path; add at least one test with a non-UTC user timezone and one asserting the raw column value ($model->getRawOriginal('starts_at')) so you're testing storage, not just round-tripping.
Existing data may silently shift on upgrade. The headline 3.1.0 change — strings are now interpreted in the user's timezone before storage — is a behaviour change, not just a feature. Any historical write path that assigned a string under the old interpretation will now store a different instant. Audit recent records where the user's timezone isn't UTC before you deploy, and check whether a backfill is needed.
auth() failing silently is the #1 production trap. In console and queue contexts there is no user, so the package falls back to the configured timezone rather than erroring. Tests pass because you used actingAs; the job that runs at 3am writes an off-by-N-hours value. Grep for every non-HTTP write path.
Database column type matters more than the package. MySQL TIMESTAMP columns do their own implicit timezone conversion based on the connection/session timezone, which will fight this package's explicit conversion and produce drifting values. Prefer DATETIME for columns managed by this cast, and pin the connection timezone explicitly. Verify with a raw SELECT — not through Eloquent, which will convert it back and hide the problem.
Clones break identity assertions. assertSame($model->starts_at, $model->starts_at) now fails — two accesses yield two objects. Use assertTrue($a->equalTo($b)) or compare formatted strings. Same applies to any === check or spl_object_id keying in your own code.
getOriginal() and dirty checks see app-timezone values. isDirty('starts_at') compares the stored value, so an equality check against a user-tz Carbon will report dirty when nothing meaningful changed. Use wasChanged()/getRawOriginal() deliberately, and be careful with updateOrCreate keyed on datetime columns.
DST is not handled for you — test the boundaries. A string like 2025-03-09 02:30 in America/New_York doesn't exist (spring forward), and 2025-11-02 01:30 is ambiguous (fall back). Carbon will resolve these deterministically but possibly not the way your users expect. Add explicit tests for one spring-forward and one fall-back case per timezone you support, and consider rejecting or warning on nonexistent local times at validation.
Validation runs in PHP's default timezone. Rules like after:now and before:some_date compare against the server clock, not the user's. If the field is user-tz, validate the format and range first, then assign, or convert to an absolute instant before comparing.
Immutable casts need Carbon 2 / Laravel 8+. The release supports immutable_datetime, but that cast doesn't exist on the Laravel 5.1–7 end of the supported matrix. Don't write code that assumes an immutable return value if you still support old versions — the same attribute may return Carbon on one deployment and CarbonImmutable on another, and addHour() mutates in one case and returns in the other.
Don't declare the same field in $dates and $casts. Both are recognised now, and the resolution order between them is an implementation detail you don't want to depend on. Pick one per attribute.
Extension points. The user-timezone lookup and the fallback timezone are the two things you'll want to override (e.g. resolving from a tenant or a User relation rather than auth()). Prefer the package's config/binding over subclassing the cast; if you do subclass, keep an eye on the wide version support — no typed properties, no arrow functions, no ??= in anything you add to the path.
How can I help you explore Laravel packages today?