---
title: "Availability and slots — Opening Hours for Laravel | Roundly"
description: "Check whether a time can be booked and list free slots — with capacity, buffers, minimum notice and a horizon, from any busy source."
url: https://roundly-consulting.com/open-source/docs/opening-hours-for-laravel/availability-and-slots
language: en
---

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

[Opening Hours for Laravel](https://roundly-consulting.com/open-source/docs/opening-hours-for-laravel.md)

# Availability and slots

Availability knows nothing about bookings — it asks a busy-period source what is occupied. availability() returns an immutable builder; every setter returns a copy:

```php
use Carbon\CarbonImmutable;
use RoundlyConsulting\OpeningHours\Availability\BusyPeriod;
use RoundlyConsulting\OpeningHours\Facades\OpeningHours;

$availability = OpeningHours::for($clinic)->availability()
    ->withBusyPeriods(fn (CarbonImmutable $start, CarbonImmutable $end) => $bookings->between($start, $end))
    ->capacity(2)                           // parallel bookings (a range's own capacity wins)
    ->buffers(before: 5, after: 10)         // must fall inside opening hours by default
    ->minNotice(minutes: 120)
    ->horizon(days: 60);

$result = $availability->check($start, $end);   // AvailabilityResult
$result->available;                             // bool
$result->reason;                                // ?UnavailableReason: invalid_range, in_past, too_soon, too_far, closed, busy
$result->conflict;                              // ?BusyPeriod
$result->message();                             // translated reason
$availability->isAvailable($start, $end, weight: 1);
```

## Settings

| Method | Meaning |
| --- | --- |
| `withBusyPeriods($busy)` | Busy source: a BusyPeriodProvider, an iterable of BusyPeriod, or a closure (start, end) returning them. |
| `capacity(int)` | Default parallel capacity (≥ 1); a range’s own capacity wins; fallback 1. |
| `buffers(before:, after:, withinOpeningHours: true)` | Minutes blocked around each booking; by default they must fall inside opening hours. With withinOpeningHours: false, the closed part of a buffer counts the capacity of the range the booking starts in (before) or ends in (after). |
| `minNotice(minutes:)` | Earliest start = now + n real minutes. |
| `horizon(days:)` | Latest start = now + n calendar days on the local wall clock (inclusive); null = unlimited. |
| `at($instant)` | Evaluate as of this instant instead of now. |

## Why a time is unavailable

check() evaluates in a fixed order and reports the first failing reason; message() returns it translated:

| Reason | When |
| --- | --- |
| `invalid_range` | The end is not after the start. |
| `in_past` | The start is before now. |
| `too_soon` | The start falls inside the minimum notice. |
| `too_far` | The start is beyond the horizon. |
| `closed` | Opening hours don’t cover the booking plus its buffers. |
| `busy` | Capacity is exceeded somewhere in the occupied window; conflict holds the first overlapping busy period. |

## Slots

```php
$slots = $availability->slots('2026-10-01', '2026-10-07')     // whole local days, inclusive
    ->duration(30)->step(15)->alignTo(15)
    ->limit(200)
    ->get();                                                   // SlotCollection
$slots->groupByDate();                                         // ['2026-10-01' => [Slot, …], …]

foreach ($slots as $slot) {
    $slot->start;                                              // CarbonImmutable
    $slot->end;
    $slot->remainingCapacity;
}

$all = $availability->slots('2026-10-01', '2026-10-01')
    ->duration(60)
    ->includeUnavailable()                                     // also slots that failed on capacity
    ->get();
$all->available();                                             // only the bookable ones

$availability->slots('2026-10-01', '2026-10-01')->duration(60)->first();   // ?Slot
$availability->nextAvailableSlot(duration: 30);                // ?Slot
$availability->freePeriods('2026-10-01', '2026-10-01');        // list<Period>
```

- slots($from, $to) takes dates (whole local days, inclusive) or instants (end exclusive). duration() is required; step() defaults to the duration; alignTo() defaults to the step and counts from local midnight.
- weight() books several capacity units at once; includeUnavailable() also returns capacity failures (reason busy); limit() caps the result at limits.slots.
- get() returns a SlotCollection — a regular collection with available() and groupByDate() added, so map(), groupBy(), keyBy() or pluck() work as usual; first() returns the first ?Slot.
- Each Slot has start, end, available, remainingCapacity and reason. It is Arrayable and JsonSerializable: toArray(), json\_encode() and response()->json() give start, end, available, remaining\_capacity and reason, with ISO-8601 times in the output timezone — and a SlotCollection gives every slot in that shape, keys kept.
- nextAvailableSlot() looks search\_days local days ahead and returns the slot that slots() over that window lists first. It searches in chunks, so it stays within max\_query\_days on its own — it throws QueryRangeTooLargeException only when one slot with its buffers is longer.
- The grid aligns on the local wall clock and stays correct across DST gaps, repeated fall-back hours and 30-minute shifts; multi-day slots (rentals) work like any other.

## Capacity model

Open capacity is range.capacity ?? availability capacity ?? 1, and overlapping ranges take the maximum. A booking fits when usage + weight ≤ capacity at every moment of \[start − before, end + after). That window must fit within max\_query\_days: check(), isAvailable() and slot queries throw QueryRangeTooLargeException for a longer one before reading any hours or busy periods. With buffers(withinOpeningHours: false), the closed part of a buffer counts the capacity of the range the booking starts in (before) or ends in (after). Durations, notice and buffers are real elapsed time; the horizon is counted in calendar days on the local wall clock.

## Check again when you book

Availability is a read. When you persist a booking, re-check inside your own lock or unique constraint — another request may book the same slot between your check and your insert:

```php
use Illuminate\Support\Facades\Cache;

Cache::lock("book:{$vet->id}:{$start}", 10)->block(5, function () use ($availability, $vet, $start, $end): void {
    abort_unless($availability->isAvailable($start, $end), 409);

    Appointment::query()->create(['vet_id' => $vet->id, 'starts_at' => $start, 'ends_at' => $end]);
});
```

## 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/opening-hours-for-laravel.md)
