Time and timezones
Every instant is stored in UTC — starts_at, ends_at, doors_open_at, the lifecycle stamps, RSVP times, created_at — whatever app.timezone says, and every event carries its own IANA timezone. The UtcDateTime cast and StoresUtcTimestamps keep it that way even on an app running in America/New_York:
$event = Events::make('Jazz Night')->organizedBy($user)
->at($venue) // the venue's timezone: Europe/Bratislava
->startsAt('2026-11-14 20:00') // wall clock there, stored as 19:00 UTC
->duration('PT3H')
->create();
$event->starts_at; // CarbonImmutable in UTC
$event->startsAtLocal(); // CarbonImmutable in the event's own timezone
$event->starts_on; // '2026-11-14' — the local calendar day
Events::make('Webinar')->organizedBy($user)->timezone('America/New_York')
->startsAt('2026-11-14T20:00:00+01:00') // an offset (or Z, or a Carbon) is that instant
->create();Input
| You pass | Read as |
|---|---|
'2026-11-14 20:00' | Wall clock in the event’s timezone (no offset). |
'2026-11-14T20:00:00+01:00', '…Z' | That instant. |
| CarbonInterface | That instant. |
- The event’s timezone is, in order: an explicit ->timezone(), the venue’s, events.defaults.timezone, app.timezone.
- Setters can come in any order — ->startsAt()->timezone() and ->timezone()->startsAt() give the same instant — because the schedule is resolved on create().
- A wall-clock time inside a DST gap (02:30 on the spring-forward night) is refused with InvalidEventScheduleException, never silently moved. A time in a DST overlap (02:30 on the fall-back night) takes the earlier offset.
- starts_on and ends_on hold the local calendar days — ends_on inclusive — derived on save, so “on this date” queries need no database timezone tables.
- API responses render times in ISO-8601 with the event’s own offset; notices show them in the event’s timezone with its abbreviation.
End, duration and doors
->endsAt(), or ->duration('3 hours'), 'PT3H' or a CarbonInterval; neither means events.defaults.duration. An event lasts at most events.schedule.max_duration (P31D) and must end after it starts. ->doorsOpenAt() must fall within the 12 hours before the start; an all-day event has no doors time — giving it one is InvalidEventScheduleException, never silently dropped. A reschedule clears the doors time, because the old one could fall outside the new window — set it again with update().
All-day and multi-day events
Events::make('Festival')->organizedBy($user)->timezone('Europe/Bratislava')
->allDay('2026-07-17', through: '2026-07-19')
->create();
// starts and ends at local midnights; ends_on is 2026-07-19 (inclusive)
// calendar files use the exclusive end day (20260720), JSON-LD the inclusive oneAn all-day event starts and ends at local midnights — the start of the first day and the start of the day after the last — correct even where a DST switch happens at midnight. It has no doors time and is reminded at events.reminders.all_day_at (09:00 local).
Changing times
- On a draft, update() and edit() change anything. On an announced event a new start goes through reschedule(): attendees are told and the originally announced start is kept as previous_starts_at.
- A timezone change keeps a timed event’s instants — its local wall clock moves — and an all-day event’s days: its local midnights are re-derived in the new zone, so calendar files and JSON-LD keep the same dates.
- allDayThrough alone moves an all-day event’s last day (null makes it a one-day event); on a timed event it is InvalidEventScheduleException.
- A start, end or duration given to an all-day draft turns it into a timed event.
Time scopes
use RoundlyConsulting\Events\Models\Event;
Event::query()->upcoming(); // not started yet
Event::query()->ongoing(); // started and not ended
Event::query()->past(); // ended
Event::query()->between('2026-11-01', '2026-11-30'); // overlapping the range
Event::query()->startingBetween($from, $to); // starting inside it (from inclusive, to exclusive)
Event::query()->onLocalDate('2026-11-14'); // taking place that day, each on its own calendarThe scopes compare in UTC. Naked strings in between() and startingBetween() are read in events.defaults.timezone (else the app’s); a Carbon is an instant. onLocalDate() reads each event on its own calendar, and a string must be a real Y-m-d date — 2027-02-30 is InvalidEventScheduleException, never read as another day.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.