Durations & time zones
Provide a duration (lasting() or durationMinutes) or an explicit end (until()). When only one is given the other is derived; when neither is given, default_duration_minutes applies:
use RoundlyConsulting\Appointments\Facades\Appointments;
Appointments::schedule('Workshop')
->startingAt('2026-07-01 09:00')
->until('2026-07-01 11:30') // duration_minutes = 150
->create();
Appointments::schedule('Quick sync')
->startingAt('2026-07-01 09:00')
->create(); // no duration, no end: default_duration_minutes (60)
Appointments::schedule('Review')
->until('2026-07-01 16:00') // measured against the start, before or after startingAt()
->startingAt('2026-07-01 15:00')
->create(); // duration_minutes = 60The model’s saving hook keeps the pair consistent even when you write rows directly: an explicit ends_at back-fills duration_minutes, otherwise ends_at is derived from duration_minutes or the configured default.
Invalid schedules
A duration under one minute, or an end that doesn’t come after the start, throws InvalidScheduleException before anything is written. reschedule() applies the same rule to durationMinutes:
use RoundlyConsulting\Appointments\Exceptions\InvalidScheduleException;
use RoundlyConsulting\Appointments\Facades\Appointments;
try {
Appointments::schedule('Back to front')
->startingAt('2026-07-01 11:00')
->until('2026-07-01 10:00') // the end does not come after the start
->create();
} catch (InvalidScheduleException $e) {
$e->getMessage(); // An appointment must end after it starts; …
}
Appointments::schedule('Instant')->lasting(0); // throws: an appointment must last at least one minuteTime zones
starts_at and ends_at always hold UTC, whatever app.timezone is, and read back as UTC CarbonImmutable instances. The appointment’s timezone column drives local display. Pass the zone to startingAt() — the wall-clock time is read in it and the zone is stored on the appointment:
$appointment = Appointments::schedule('Client call')
->startingAt('2026-07-01 17:30', timezone: 'Europe/Bratislava')
->lasting(45)
->create();
$appointment->timezone; // 'Europe/Bratislava'
$appointment->starts_at; // 2026-07-01 15:30 UTC — instants are stored in UTC
$appointment->startsAtLocal(); // CarbonImmutable 2026-07-01 17:30 Europe/Bratislava
$appointment->endsAtLocal(); // CarbonImmutable 2026-07-01 18:15 Europe/Bratislava
$appointment->resolveTimezone(); // appointment timezone, else appointments.timezone, else app.timezone
$appointment->duration(); // CarbonInterval of 45 minutes
$appointment->durationInMinutes(); // 45
// No zone given: the string is read in appointments.timezone, else app.timezone — and that zone is stored
$walkIn = Appointments::schedule('Walk-in')->startingAt('2026-07-01 09:00')->create();
$walkIn->timezone; // e.g. 'Europe/Bratislava' — a later config change does not re-time it- A Carbon you pass keeps its instant. A wall-clock string without an offset is read in the timezone: you give, else appointments.timezone, else app.timezone — and that zone is stored on the appointment.
- Each appointment keeps the zone it was booked in, so changing appointments.timezone later doesn’t re-time existing appointments.
- startsAtLocal() and endsAtLocal() convert the stored instants into the appointment’s zone; endsAtLocal() returns null when there is no end.
- resolveTimezone() returns the appointment’s own timezone; a row stored without one — a factory or a hand insert — falls back to appointments.timezone, then app.timezone.
- duration() returns a CarbonInterval and durationInMinutes() an int — from duration_minutes, else from the start and end, else the default.
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.