Skip to content

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.

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

/**
* @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:

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

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

<?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: '[email protected]',
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.

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

/**
* @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:

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

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

/**
* @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:

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

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

/**
* @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:

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

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

/**
* @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:

<?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

Section titled “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.

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