Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Dates Timezone Conversion Trait Laravel Package

divineomega/dates-timezone-conversion-trait

View on GitHub
Deep Wiki
Context7

Getting Started

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.

  1. Confirm the target version. v3.1.0 keeps PHP 5.5.9 and Laravel 5.1–13 in scope. That breadth is a feature and a trap: run 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).
  2. Make sure a timezone exists per user. The package resolves the user timezone from the authenticated user; the first thing to check in an unfamiliar codebase is the column name it expects (typically 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.
  3. Apply the trait (or cast) to one model first. Pick the model where the bug reports actually are — usually Appointment, Order, Event — and add the package's trait alongside the existing $casts. Do not roll it across the whole domain in one PR.
  4. Prove the round trip in a test before touching a controller: create a user in 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.

Implementation Patterns

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.

Gotchas and Tips

  • Reading no longer mutates the model (v3.1.0 behaviour change). $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.
  • Nullable dates now pass through unchanged. Good — but code that assumed it always got a Carbon instance ($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.
  • Double conversion is the classic bug. If a service, accessor or API resource already calls ->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.
  • DST ambiguity is unavoidable and worth documenting. During a fall-back hour, a submitted 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.
  • Raw attributes and diagnostics are in app time. $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.
  • Comparisons are offset-safe, formatting is not. $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.
  • PHP 5.5.9 support constrains extension code. No scalar type hints, no ??, 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 source is a config/extension seam. The resolver (authenticated-user lookup) is the correct override point for multi-tenant apps, admin impersonation and "preview as user" features — set it once in a middleware (Timezone::resolveUsing(fn () => session('preview_timezone') ?? auth()->user()?->timezone)) rather than threading a timezone parameter through services.
  • When debugging, print three values, not one: the user's timezone string, $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.
  • Cache and queue payloads capture the timezone at dispatch time. A job serialising the model stores the app-tz attribute, but any pre-formatted string you embedded was rendered in the dispatching user's timezone. Prefer passing IDs/instants over formatted dates across queue boundaries.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator