---
title: "Durations & time zones — Appointments for Laravel | Roundly"
description: "How start, end and duration derive from each other, why instants are stored in UTC, and how to read local times."
url: https://roundly-consulting.com/open-source/docs/appointments-for-laravel/durations-time-zones
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Appointments for Laravel](https://roundly-consulting.com/open-source/docs/appointments-for-laravel.md)

# 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:

```php
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 = 60
```

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

```php
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 minute
```

## Time 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:

```php
$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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/appointments-for-laravel.md)
