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
Section titled “Build available booking slots”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.
Create an appointment request
Section titled “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.
<?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', 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
Section titled “Confirm an appointment request”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.
Cancel an appointment request
Section titled “Cancel an appointment request”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.
Link a booking to a portal account
Section titled “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:
/** * @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.
Materialise a lesson series
Section titled “Materialise a lesson series”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.