NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Events for Laravel

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 passRead 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.
CarbonInterfaceThat 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 one

An 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 calendar

The 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 crypto

By 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.