# Bookings extension examples

These examples use the package's declared Action API. The manifest and Action
classes are the source of truth for the available names and signatures.

## Build available booking slots

`buildAvailableBookingSlots` returns future, display-ready candidates for one
active service. The public contract is:

```php
/**
 * @method static list<array<string, mixed>> run(?int $serviceId = null, ?int $staffMemberId = null, ?int $locationId = null, ?string $timezone = null, ?CarbonImmutable $from = null, int $days = 14, ?int $siteId = null)
 */
```

Here `CarbonImmutable` is `Carbon\CarbonImmutable`, as imported by the Action.
For example, a booking form can select an active persisted service through an
available window and ask for the next 14 days in the visitor's timezone. Try
each eligible window until its resource scope yields future slots, then render
the returned `starts_at`, `ends_at`, and `timezone` values:

<!-- example: action buildAvailableBookingSlots -->

```php
<?php

use Capell\Bookings\Actions\BuildAvailableBookingSlotsAction;
use Capell\Bookings\Models\BookingAvailabilityWindow;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;

$availability = null;
$slots = [];

foreach (BookingAvailabilityWindow::query()
    ->available()
    ->whereNotNull('service_id')
    ->whereHas('service', fn (Builder $query): Builder => $query->active())
    ->where(function (Builder $query): void {
        $query->whereNull('staff_member_id')
            ->orWhereHas('staffMember', fn (Builder $staff): Builder => $staff->active());
    })
    ->where(function (Builder $query): void {
        $query->whereNull('location_id')
            ->orWhereHas('location', fn (Builder $location): Builder => $location->active());
    })
    ->orderBy('id')
    ->get() as $candidate) {
    $slots = BuildAvailableBookingSlotsAction::run(
        serviceId: (int) $candidate->service_id,
        staffMemberId: $candidate->staff_member_id,
        locationId: $candidate->location_id,
        timezone: 'Europe/London',
        from: CarbonImmutable::now('Europe/London'),
        days: 14,
        siteId: $candidate->site_id,
    );

    if ($slots === []) {
        continue;
    }

    $availability = $candidate;
    break;
}

if (! $availability instanceof BookingAvailabilityWindow) {
    throw new LogicException('No future booking slot is currently available.');
}

foreach ($slots as $slot) {
    printf(
        "%s–%s (%s)\n",
        $slot['starts_at'],
        $slot['ends_at'],
        $slot['timezone'],
    );
}
```

The service must be active; a missing, inactive, or invalid service ID returns
an empty list. `from` is normalised to the requested timezone's start of day,
`days` is clamped to at least one day and to the service's configured
`max_future_days`, and the default is 14 days. The action considers persisted
available windows and available-capacity exceptions, removes blocked exceptions
and occupied/buffered time, and applies the optional service, staff, location,
and site scope. It does not reserve a slot or verify that non-null staff and
location IDs are active.

The example selects only windows with an active service and unassigned or active
staff and location resources. It skips windows that produce no future slots and
reports when none qualify. It requires a service-specific available window;
an installation using only general windows or availability exceptions needs to
select its service and resource scope explicitly. Services have no `slug` or
`site_id`: the persisted window supplies all four IDs together. Returned slots
contain display and capacity values, so retain `$availability` alongside them.

If the selected slot will be passed to `createAppointmentRequest`, pass those
same IDs and site scope to that action, as below. Creation revalidates
availability and can fail if the slot or resource status changed; listing slots
alone is not a hold or a booking.

## Create an appointment request

Use the `buildAvailableBookingSlots` Action to select a future slot from a
persisted availability window, then pass the same resource scope to the
`createAppointmentRequest` Action.

<!-- example: action createAppointmentRequest -->

```php
<?php

use Capell\Bookings\Actions\BuildAvailableBookingSlotsAction;
use Capell\Bookings\Actions\CreateAppointmentRequestAction;
use Capell\Bookings\Data\AppointmentRequestData;
use Capell\Bookings\Models\BookingAvailabilityWindow;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;

$availability = null;
$slot = null;

foreach (BookingAvailabilityWindow::query()
    ->available()
    ->whereNotNull('service_id')
    ->whereHas('service', fn (Builder $query): Builder => $query->active())
    ->where(function (Builder $query): void {
        $query->whereNull('staff_member_id')
            ->orWhereHas('staffMember', fn (Builder $staff): Builder => $staff->active());
    })
    ->where(function (Builder $query): void {
        $query->whereNull('location_id')
            ->orWhereHas('location', fn (Builder $location): Builder => $location->active());
    })
    ->orderBy('id')
    ->get() as $candidate) {
    $slots = BuildAvailableBookingSlotsAction::run(
        serviceId: (int) $candidate->service_id,
        staffMemberId: $candidate->staff_member_id,
        locationId: $candidate->location_id,
        timezone: $candidate->timezone,
        siteId: $candidate->site_id,
    );

    if ($slots === []) {
        continue;
    }

    $availability = $candidate;
    $slot = $slots[0];
    break;
}

if (! $availability instanceof BookingAvailabilityWindow || ! is_array($slot)) {
    throw new LogicException('No future booking slot is currently available.');
}

$appointmentRequest = CreateAppointmentRequestAction::run(new AppointmentRequestData(
    serviceId: (int) $availability->service_id,
    requestedStartsAt: CarbonImmutable::parse((string) $slot['starts_at']),
    requestedEndsAt: CarbonImmutable::parse((string) $slot['ends_at']),
    timezone: (string) $slot['timezone'],
    customerName: 'Jordan Lee',
    customerEmail: 'jordan.lee@example.com',
    siteId: $availability->site_id,
    staffMemberId: $availability->staff_member_id,
    locationId: $availability->location_id,
));
```

A candidate window needs an active service. Its staff member and location must
each be unassigned or active. The slot builder does not check whether staff
members or locations are active, but the create Action rejects inactive IDs, so
these relationship filters are required.

Slot selection does not reserve capacity. The create Action revalidates
availability before returning a `Requested` appointment and its `Created` audit
event; refresh the selection if that validation fails.

## Confirm an appointment request

`confirmAppointmentRequest` confirms one persisted appointment request. Its
public contract is:

```php
/**
 * @method static AppointmentRequest run(AppointmentRequest $appointmentRequest)
 */
```

Pass an authorised request ID from the calling application, load the stored
model, and let the Action apply the confirmation rules under a row lock:

<!-- example: action confirmAppointmentRequest -->

```php
<?php

use Capell\Bookings\Actions\ConfirmAppointmentRequestAction;
use Capell\Bookings\Models\AppointmentRequest;

function confirmPersistedAppointmentRequest(int $appointmentRequestId): AppointmentRequest
{
    /** @var AppointmentRequest $appointmentRequest */
    $appointmentRequest = AppointmentRequest::query()->findOrFail($appointmentRequestId);

    return ConfirmAppointmentRequestAction::run($appointmentRequest);
}
```

The Action accepts a `Requested` appointment or a `Provisional` hold that has
not expired. Other statuses are not confirmable. An expiry at or before the
current time rejects a provisional hold; a provisional hold without an expiry
remains confirmable. A `Payment` confirmation policy with a positive required
amount also requires `payment_confirmed_at` to be present.

On success, the stored request becomes `Confirmed` and receives
`confirmed_at`. The same transaction records a `Confirmed` audit entry, routes
a confirmation mail notification to `customer_email` for dispatch after the
transaction commits, and records a `NotificationQueued` audit entry. Validation
failures leave those success-side writes unapplied.

## Cancel an appointment request

`cancelAppointmentRequest` cancels one persisted appointment request. Its exact
public contract is:

```php
/**
 * @method static AppointmentRequest run(AppointmentRequest $appointmentRequest, ?string $reason = null)
 */
```

Start from service, customer, and request identifiers already authorised by the
calling application. This example resolves the stored service and then the
matching stored customer request before running the Action:

<!-- example: action cancelAppointmentRequest -->

```php
<?php

use Capell\Bookings\Actions\CancelAppointmentRequestAction;
use Capell\Bookings\Models\AppointmentRequest;
use Capell\Bookings\Models\BookingService;

/** @var BookingService $service */
$service = BookingService::query()->findOrFail($serviceId);

/** @var AppointmentRequest $appointmentRequest */
$appointmentRequest = $service->appointmentRequests()
    ->whereKey($appointmentRequestId)
    ->where('customer_email', $customerEmail)
    ->firstOrFail();

$cancelledAppointmentRequest = CancelAppointmentRequestAction::run(
    appointmentRequest: $appointmentRequest,
    reason: $reason,
);
```

The Action starts a database transaction, reloads the request by its primary
key with a row lock, and checks the current stored state after acquiring that
lock. Only `Provisional`, `Requested`, and `Confirmed` requests can be
cancelled. A missing row or any other state is rejected; cancellation is not an
idempotent no-op for an already `Cancelled` request.

On success, the locked request becomes `Cancelled` and receives the current
`cancelled_at` timestamp. The same transaction records a `Cancelled` audit
entry from the previous state to `Cancelled`; the optional reason is stored as
that entry's message. It also routes a cancellation mail to the stored
`customer_email` and records a `NotificationQueued` audit entry. The queued
notification implements after-commit dispatch, and its message does not include
the optional audit reason. An exception before commit rolls back the state and
audit writes; a later queue delivery failure does not undo the cancellation.

This explicit cancellation is separate from scheduled hold expiry. The
five-minute `capell:bookings:expire-workflow-state` command bulk-cancels only
`Provisional` requests whose `hold_expires_at` is due, setting their status and
`cancelled_at` directly without a cancellation audit or customer notification.

## Link a booking to a portal account

`linkBookingToPortalAccount` links one persisted appointment request to a
configured portal account for the authenticated user. Its exact public contract
is:

```php
/**
 * @method static AppointmentRequest run(AppointmentRequest $appointmentRequest, Model $portalAccount, Authenticatable $user)
 */
```

Resolve the request and portal account inside a site scope already authorised by
the calling application. An appointment without a site is included because the
Action can assign the portal account's site during the link:

<!-- example: action linkBookingToPortalAccount -->

```php
<?php

use Capell\Bookings\Actions\LinkBookingToPortalAccountAction;
use Capell\Bookings\Models\AppointmentRequest;
use Capell\Bookings\Support\PortalAccountModel;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;

function linkBookingToPortalAccountForSite(
    int $siteId,
    int $appointmentRequestId,
    int $portalAccountId,
): AppointmentRequest {
    $user = Auth::user();

    if (! $user instanceof Authenticatable) {
        throw new AuthenticationException;
    }

    $portalAccountClass = PortalAccountModel::className();

    /** @var Model $portalAccount */
    $portalAccount = $portalAccountClass::query()
        ->where('site_id', $siteId)
        ->findOrFail($portalAccountId);

    /** @var AppointmentRequest $appointmentRequest */
    $appointmentRequest = AppointmentRequest::query()
        ->whereKey($appointmentRequestId)
        ->where(function (Builder $query) use ($siteId): void {
            $query->whereNull('site_id')->orWhere('site_id', $siteId);
        })
        ->firstOrFail();

    return LinkBookingToPortalAccountAction::run(
        appointmentRequest: $appointmentRequest,
        portalAccount: $portalAccount,
        user: $user,
    );
}
```

`capell-bookings.portal_account_model` supplies the Eloquent model class. That
model must expose `normalizeEmail` and `emailHash`; the Action delegates both
operations to it. The default Customer Portal model trims and lowercases email,
stores email encrypted, and maintains an HMAC-SHA-256 `email_hash` using the
configured Customer Portal hash secret or the application key. Let the model
maintain that hash; do not copy a secret into application code or documentation.

The authenticated user must expose a string email through
`getAttribute('email')`, and that email must be verified. For a user implementing
`MustVerifyEmail`, `hasVerifiedEmail()` must return true. Other authenticatable
implementations must expose `getAttribute('email_verified_at')` with a non-null
value. The normalised verified user email must equal the normalised appointment
`customer_email`, and the portal account's stored `email_hash` must equal the
configured model's hash of that same email.

The portal account must have an integer `site_id`. A request with a different
site is rejected; an unscoped request with a null `site_id` receives the portal
account's site when linked. A request already linked to another portal account
is also rejected.

The Action reloads and locks the request in a transaction before checking these
rules. On the first valid link it stores `site_id` and `portal_account_id`, then
returns the refreshed request. Calling it again for the same portal account is
idempotent and returns the locked request without another save, provided the
verified email, email hash, and site checks still pass.

## Materialise a lesson series

`materialiseLessonSeries` creates the due appointment requests for one persisted
lesson series. Its exact public contract is:

```php
/**
 * @method static Collection<int, AppointmentRequest> run(LessonSeries $lessonSeries, CarbonImmutable $through)
 */
```

Here `Collection` is `Illuminate\Support\Collection` and `CarbonImmutable` is
`Carbon\CarbonImmutable`, as imported by the Action. Resolve the stored series
inside a site scope already authorised by the calling application, and pass the
caller-selected inclusive materialisation date:

<!-- example: action materialiseLessonSeries -->

```php
<?php

use Capell\Bookings\Actions\MaterialiseLessonSeriesAction;
use Capell\Bookings\Models\AppointmentRequest;
use Capell\Bookings\Models\LessonSeries;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;

/**
 * @return Collection<int, AppointmentRequest>
 */
function materialiseAuthorisedLessonSeries(
    int $siteId,
    int $lessonSeriesId,
    CarbonImmutable $through,
): Collection {
    /** @var LessonSeries $lessonSeries */
    $lessonSeries = LessonSeries::query()
        ->where('site_id', $siteId)
        ->findOrFail($lessonSeriesId);

    return MaterialiseLessonSeriesAction::run($lessonSeries, $through);
}
```

The Action reloads the series by its primary key under a row lock. An inactive
series returns an empty collection. For an active series, `through` is converted
to the series timezone and its start of day, then capped at `active_until` when
that date is earlier. Processing starts from the latest of the local current
date, `active_from`, and the day after `materialized_until`. If that start is
after the effective end date, the Action returns an empty collection without
moving the marker.

Occurrence dates must match `day_of_week`. `cadence_weeks` is measured in whole
weeks from `active_from`, with values below one treated as one. Each occurrence
uses `starts_at` in the series timezone. A non-null `duration_minutes` sets the
requested end; otherwise `CreateAppointmentRequestAction` uses the service's
duration. The created request also receives the series' site, portal account,
staff member, location, customer fields, reminder preferences, and payload. The
Action sets `source` to `lesson-series` and overwrites any payload value named
`lesson_series_id` with the persisted series ID.

Creation rechecks that the service and any selected staff member and location
are active, that the occurrence is inside the service's booking horizon and a
matching availability window or allowed availability exception, and that
capacity remains. Each successful creation starts as `Requested` and records a
`Created` audit entry. When
`auto_confirm_instances` is true, the same occurrence is then confirmed,
receives `confirmed_at` and a `Confirmed` audit entry, and queues a confirmation
mail plus a `NotificationQueued` audit entry. The mail notification is dispatched
after the transaction commits. With automatic confirmation disabled, the Action
returns the newly created `Requested` instances without that confirmation mail.

An existing request with the same lesson-series ID and occurrence date is
skipped and is not included in the returned collection; the database also has a
unique constraint for that pair. After scanning a valid range, the Action moves
`materialized_until` to the effective end date even when every due occurrence
already existed or no date matched the cadence. Creation, optional confirmation,
audit writes, and the marker update run inside one transaction, so a validation
or persistence failure does not leave a partially advanced series.

## Block a morning across the booking calendar

Use the `createAvailabilityException` Action to stop appointment requests during
a known closure. This exception blocks the three-hour interval across all
services, staff members, and locations because the optional resource IDs are
deliberately omitted.

<!-- example: action createAvailabilityException -->

```php
<?php

use Capell\Bookings\Actions\CreateAvailabilityExceptionAction;
use Capell\Bookings\Data\AvailabilityExceptionData;
use Capell\Bookings\Enums\BookingAvailabilityStatusEnum;
use Carbon\CarbonImmutable;

$exception = CreateAvailabilityExceptionAction::run(new AvailabilityExceptionData(
    date: CarbonImmutable::parse('2026-10-05'),
    status: BookingAvailabilityStatusEnum::Blocked,
    startsAt: '09:00:00',
    endsAt: '12:00:00',
    timezone: 'Europe/London',
    reason: 'Staff training morning',
));
```

`AvailabilityExceptionData` accepts nullable service, staff member, and location
IDs. Omitting all three records a general exception rather than relying on
installation-specific IDs. A blocked exception removes matching availability
for the interval and does not require a capacity.