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

Busy-period providers

Busy time comes from a BusyPeriodProvider, an iterable of BusyPeriod::make($start, $end, weight: 1), or a closure. Five providers ship: ArrayBusyPeriodProvider, ClosureBusyPeriodProvider, CompositeBusyPeriodProvider, NullBusyPeriodProvider and EloquentBusyPeriodProvider.

From an Eloquent query

EloquentBusyPeriodProvider adapts any query to busy periods — map the columns, handle rows without an end, and say which timezone the raw values are stored in:

use RoundlyConsulting\OpeningHours\Availability\Providers\EloquentBusyPeriodProvider;
use RoundlyConsulting\OpeningHours\Facades\OpeningHours;

$busy = EloquentBusyPeriodProvider::for(
        Appointment::query()->where('vet_id', $vet->id)->whereNotIn('status', ['cancelled']),
    )
    ->columns(start: 'starts_at', end: 'ends_at')
    ->durationColumn('duration_minutes')   // rows with a NULL end last this many minutes
    ->defaultDuration(minutes: 30)         // … or this, when the duration is NULL too
    ->maxNullEndMinutes(480)               // optional: the longest possible duration
    ->weightColumn(null)                   // or a column with capacity units
    ->storedIn('UTC');                     // timezone of the raw column values

OpeningHours::for($clinic)->availability()->withBusyPeriods($busy)->isAvailable($start, $end);
  • columns(start:, end:) — the start and end columns.
  • durationColumn() — rows with a NULL end last this many minutes; defaultDuration() when the duration is NULL too (or there is no duration column).
  • weightColumn() — a column holding capacity units per row (null = weight 1).
  • maxNullEndMinutes() — optional: the longest possible duration. A NULL-end row can only overlap a window if it started at most its duration before it; without this bound the provider finds it with one extra MAX(duration) query per read (or uses defaultDuration() when there is no duration column). Set it to skip that query — never lower than your longest duration, or longer bookings are missed and show as free.
  • storedIn() — the timezone of the raw column values (default app.timezone). Storing booking times in UTC is recommended.

Column names are allow-list validated and values are bound. The provider selects only the named columns, caps the read at limits.busy_periods, and resolves wall-clock values around DST changes with the same boundary rule as the engine.

Combining sources

use RoundlyConsulting\OpeningHours\Availability\BusyPeriod;
use RoundlyConsulting\OpeningHours\Availability\Providers\ArrayBusyPeriodProvider;
use RoundlyConsulting\OpeningHours\Availability\Providers\CompositeBusyPeriodProvider;
use RoundlyConsulting\OpeningHours\Facades\OpeningHours;

$maintenance = new ArrayBusyPeriodProvider([
    BusyPeriod::make($start, $end, weight: 2, reference: 'maintenance'),
]);

OpeningHours::for($clinic)->availability()
    ->withBusyPeriods(new CompositeBusyPeriodProvider($busy, $maintenance))   // the union of both
    ->isAvailable($from, $to);

Your own provider

Implement BusyPeriodProvider::busyPeriodsBetween() and return the periods overlapping the window. Returning extra periods is fine — the engine clips:

use Carbon\CarbonImmutable;
use RoundlyConsulting\OpeningHours\Availability\BusyPeriod;
use RoundlyConsulting\OpeningHours\Contracts\BusyPeriodProvider;

final class MaintenanceWindows implements BusyPeriodProvider
{
    public function busyPeriodsBetween(CarbonImmutable $start, CarbonImmutable $end): iterable
    {
        return MaintenanceWindow::query()
            ->where('starts_at', '<', $end)
            ->where('ends_at', '>', $start)
            ->get()
            ->map(fn (MaintenanceWindow $window) => BusyPeriod::make($window->starts_at, $window->ends_at));
    }
}

A BusyPeriod must end after it starts and weigh at least 1 (InvalidBusyPeriodException); more than limits.busy_periods per evaluation throws TooManyBusyPeriodsException.

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.