Booking approvals
Opt a booking into sign-off with approvals-for-laravel. The appointment is created pending and an approval request is opened; the SyncAppointmentStatusFromApproval listener then moves the appointment as the request resolves:
use RoundlyConsulting\Appointments\Facades\Appointments;
use RoundlyConsulting\Approvals\Enums\ApprovalRule;
use RoundlyConsulting\Approvals\Facades\Approvals;
$appointment = Appointments::schedule('Booking request')
->startingAt('2026-07-01 09:00')
->requireApprovalFrom([$organiser, $clinician], ApprovalRule::Quorum, quorum: 1)
->create(); // created pending, approval request opened
Approvals::for($appointment)->as($organiser)->approve(); // → confirmed
// or: Approvals::for($appointment)->as($organiser)->reject(); → declined| Approval request resolves as | Appointment status becomes |
|---|---|
Approved | confirmed |
Rejected | declined |
Cancelled / Expired | cancelled |
Pending | unchanged |
Rules and quorum
requireApprovalFrom() accepts one approver or many, a rule and an optional quorum. The rule defaults to ApprovalRule::Unanimous; approvals-for-laravel also offers Quorum, Any and Weighted:
use RoundlyConsulting\Appointments\Facades\Appointments;
use RoundlyConsulting\Approvals\Enums\ApprovalRule;
Appointments::schedule('Board review')
->startingAt('2026-07-01 09:00')
->requireApprovalFrom([$chair, $treasurer]) // ApprovalRule::Unanimous by default
->create();
Appointments::schedule('Team booking')
->startingAt('2026-07-01 09:00')
->requireApprovalFrom([$lead, $deputy])
->approvalRule(ApprovalRule::Quorum) // standalone setters
->approvalQuorum(1)
->create();Staged pipelines and presets
Stages open one after another — each only once the previous one clears. Named presets come from config('approvals.workflows'):
use RoundlyConsulting\Approvals\DataTransferObjects\StageDefinition;
Appointments::schedule('Two-desk booking')
->startingAt('2026-07-01 09:00')
->approvalStages([
new StageDefinition([$reception]),
new StageDefinition([$clinician]),
])
->rejectOnStageRejection() // default: a rejected stage declines the booking
->create();
Appointments::schedule('Preset booking')
->startingAt('2026-07-01 09:00')
->approvalWorkflow('clinic') // config('approvals.workflows.clinic')
->approvalStageApprovers([[$reception], [$clinician]])
->create();// config/approvals.php
'workflows' => [
'clinic' => [
'stages' => [
['rule' => 'unanimous', 'required_approvers' => 1, 'name' => 'reception'],
['rule' => 'any', 'required_approvers' => 1, 'name' => 'clinician'],
],
],
],- Precedence: a named workflow preset wins, then explicit stages, then the flat approver set.
- rejectOnStageRejection() is on by default — a rejected stage declines the whole booking.
- approvalStageApprovers() supplies one approver group per stage for a staged preset; a flat preset takes the requireApprovalFrom() approvers.
Approver models
Only the named approvers can decide. Models that decide use the approvals GivesApprovals trait:
use Illuminate\Foundation\Auth\User as Authenticatable;
use RoundlyConsulting\Appointments\Concerns\HasAppointments;
use RoundlyConsulting\Approvals\Traits\GivesApprovals;
class User extends Authenticatable
{
use GivesApprovals;
use HasAppointments;
}What the sync listener does
- Fires AppointmentStatusChanged like any other transition, so your listeners don’t care how the decision arrived.
- Records the deciding actor in meta under approval_decided_by, as a type (morph class) and id.
- Ignores repeated resolutions and requests whose subject isn’t an appointment.
- Never leaves a final status — cancelled, declined, completed or no_show — either way: approving the still-open request of a booking the customer already cancelled changes nothing.
- With approvals.enforce_transitions on, a mapped move the transition matrix forbids is skipped. Off (the default), the mapped status is forced between live statuses — confirmed → declined included.
Approval state on the appointment
$appointment->isPendingApproval(); // bool
$appointment->isApproved(); // bool
$appointment->currentStage()?->name; // staged pipelines: the open stage, e.g. 'reception'
$appointment->approvalRequests()->count(); // the approval requests opened for this booking
$appointment->meta?->get('approval_decided_by'); // ['type' => …, 'id' => …] once decidedExpiring decisions
appointments:expire-approvals runs the approvals engine’s expiry scoped to appointments — the configured appointments.model, by its morph-map alias when you register one. It lapses expired appointment approval decisions and requests only, leaving other subjects’ approvals alone, and the appointments whose requests resolve move to cancelled through the listener:
php artisan appointments:expire-approvals
# Lapsed 3 expired approval decision(s).Run it on a schedule:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('appointments:expire-approvals')->everyFiveMinutes();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 cryptoBy 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.