divineomega/dates-timezone-conversion-trait
This release (v3.1.0) is the timezone-aware date layer for Eloquent: string dates coming in are read as the authenticated user's wall-clock time, converted and stored in config('app.timezone'), and Carbon values coming out are handed back in the user's timezone. Everything else (nullable columns, legacy $dates, modern $casts) is now handled for you.
composer why-not / check your composer.json constraint, and if you are on Laravel 11+ make sure the package's service provider is still auto-discovered (older installs relied on config/app.php registration).users.timezone) and whether it is a valid IANA identifier (Europe/Berlin, not CET). Add a validation rule or an accessor/mutator that normalises it before rollout.Appointment, Order, Event — and add the package's trait alongside the existing $casts. Do not roll it across the whole domain in one PR.America/New_York, actingAs them, Model::create(['starts_at' => '2024-03-10 02:30:00']), then assert the raw DB value is the UTC equivalent and $model->fresh()->starts_at renders back as the New York wall-clock time.First real use case: a user in Asia/Tokyo submits a form with 2024-07-01 09:00. You want the DB (and every downstream job/report) in UTC, but the edit screen, the confirmation email and the JSON payload must all read back as 09:00 Tokyo time without per-controller ->setTimezone() calls. That is the whole point of the package — one cast, consistent behaviour at the boundaries.
Model-level casting, not controller-level conversion. Keep conversion at the Eloquent boundary and let controllers/services deal in plain strings and Carbon:
class Appointment extends Model
{
use TimezoneAwareDates; // confirm exact trait name in the README for your version
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'cancelled_at' => 'datetime', // nullable — now passes through as null, see below
];
}
Legacy and modern casts coexist. v3.1.0 recognises both protected $dates = [...] (Laravel ≤6 style) and datetime / date casts. This means you can adopt the package before migrating to $casts, which is the sane order for a large legacy app: enable the trait, keep $dates, ship it, then migrate the arrays model by model. Do not mix a field into both $dates and $casts.
Treat the request boundary as "user timezone, no exceptions". Use a FormRequest to validate the shape of the date (date_format:Y-m-d H:i or date), then assign the raw string to the model. Resist Carbon::parse($request->input('starts_at')) in the controller — that parses in the app timezone and silently shifts the value by the user's offset before the cast ever sees it.
Query filters need explicit conversion. The package governs hydration and assignment; it does not rewrite query builder calls. A "my appointments today" filter must convert the user-tz day boundaries into app-tz values first:
$start = Carbon::parse($day, $user->timezone)->startOfDay()->setTimezone(config('app.timezone'));
$end = Carbon::parse($day, $user->timezone)->endOfDay()->setTimezone(config('app.timezone'));
Appointment::whereBetween('starts_at', [$start, $end])->get();
Wrap that in a query scope (scopeOnUserDay) so the conversion lives in one place.
Serialisation is now user-dependent. Anything that toArray()s or toJson()s a model will emit the user's wall-clock time. For APIs, be explicit in the Resource ($this->starts_at->toIso8601String() includes the offset, which is what clients want) rather than relying on the default Y-m-d H:i:s string, which drops the offset and re-introduces ambiguity.
Non-HTTP contexts need a deliberate timezone source. Console commands, queued jobs and scheduled tasks have no authenticated user. For those, either resolve a user explicitly (dispatch with the user id and re-authenticate, Auth::loginUsingId / Auth::setUser) or override the package's timezone resolver for that request with a fixed zone. If you skip this, dates are interpreted in the app timezone — which is usually the behaviour you want for batch jobs, but it must be a decision, not an accident.
Testing pattern. Combine actingAs($userInTokyo) with Carbon::setTestNow() (or $this->travelTo() on newer Laravel), and assert on both sides: assertDatabaseHas('appointments', ['starts_at' => '2024-07-01 00:00:00']) and assertSame('09:00', $appointment->starts_at->format('H:i')). Test DST boundaries explicitly — a March/November transition date in a zone that observes DST, and one that does not (Asia/Tokyo) as the control.
Extend via a custom cast, not by overriding the trait. If one model needs different rules (e.g. a field always stored in the user's tz), subclass the package's cast or write your own CastsAttributes that delegates to it. Keeping the trait untouched makes future minor upgrades cheap.
$model->created_at->addDay() now affects only the returned Carbon instance — the clone means $model->save() is a no-op. Older code that relied on the implicit mutation will silently stop persisting. Audit for $model->some_date->modify(...) / ->addDays() followed by ->save(), and rewrite as $model->some_date = $model->some_date->addDay(). This is the most likely source of "it worked before the upgrade" tickets.$model->cancelled_at->isPast()) will now fatal on null. Grep for unguarded date property access on nullable columns; use ->isPast() only behind if ($model->cancelled_at) or an accessor returning null.->setTimezone($user->timezone), you now convert twice and get a wrong offset. Remove manual conversions when you adopt the cast, and delete any timezone handling from Blade templates/formatters.01:30 maps to two instants; the package must pick one (typically the earlier). During spring-forward, 02:30 does not exist and Carbon shifts it. For anything schedulable, prefer storing an explicit offset or UTC from the client, and never round-trip user-entered local times through a "date only" column.$model->getAttributes()['starts_at'], getOriginal() and SQL logs will look "wrong" versus the user's expectation. That is correct behaviour; check $model->starts_at for the user-facing value. Similarly, $model->getDirty() after a read is empty — reads do not dirty the model.$a->eq($b) / ->isSameDay() compare absolute instants and are unaffected by the timezone swap; ->format(), ->toDateString() and ->diffForHumans() are not. Assert on formatted output in tests only when the user timezone is deliberately set.??, no ::class on the package internals you might want to type against. If you subclass a cast, keep your signatures untyped or you will fatally break on any environment still pinned to the old floor — and you will not find out until your CI matrix reaches it.Timezone::resolveUsing(fn () => session('preview_timezone') ?? auth()->user()?->timezone)) rather than threading a timezone parameter through services.$model->getRawOriginal('field') (app tz storage) and $model->field->toIso8601String() (user tz read). Nearly every reported "off by N hours" bug resolves the moment those three are on one line.How can I help you explore Laravel packages today?