Creating appointments
Appointments::schedule($name) starts a fluent builder — the recommended API. Every method is chainable; create() persists the appointment, attaches its participants and contacts, opens any approval request and returns it with participants loaded:
use RoundlyConsulting\Appointments\Enums\ParticipantRole;
use RoundlyConsulting\Appointments\Facades\Appointments;
$appointment = Appointments::schedule('Project kickoff')
->startingAt('2026-07-01 17:30', timezone: 'Europe/Bratislava')
->lasting(90) // minutes; or ->until('2026-07-01 19:00')
->describedAs('Scope, milestones and owners for the new client portal.')
->withMeta(['agenda_url' => 'https://example.com/agenda'])
->withParticipant($host, ParticipantRole::Organiser, ['is_host' => true])
->withParticipant($guest)
->preventConflicts() // opt-in double-booking guard for this call
->create();Builder methods
| Method | What it sets |
|---|---|
startingAt($at, ?$timezone) | Start — a Carbon keeps its instant; a string without an offset is read in $timezone, else appointments.timezone, else app.timezone. That zone is stored on the appointment. |
lasting(int $minutes) | Duration in minutes — at least one, else InvalidScheduleException. Replaces an earlier until(). |
until($at) | End time, measured against the start whether you call it before or after startingAt(); it must come after the start. Replaces an earlier lasting(). |
describedAs(string $description) | Description. |
withMeta(array $meta) | Arbitrary meta data (stored as JSON, read as a Collection). |
withStatus(Status $status) | Initial status — Status::Pending by default. |
withParticipant($model, ?$role, array $meta) | Adds a participant of any Eloquent model, with an optional ParticipantRole and meta. |
located($lat, $lng, ?$venue) | Venue coordinates and optional venue name. |
at(Coordinates $coordinates) | Venue coordinates from a value object. |
venue(string $location) | Venue name only. |
withContactEmail($email, ?$label, $primary) | Booking email contact — primary by default. |
withContactPhone($phone, ?$label, $primary) | Booking phone contact — primary by default. |
requireApprovalFrom($approvers, $rule, ?$quorum) | Opens a booking-approval request — see Booking approvals. |
approvalRule() / approvalQuorum() | Standalone setters for the approval rule and quorum. |
approvalStages() / rejectOnStageRejection() | A staged approval pipeline and whether a rejected stage declines the booking. |
approvalWorkflow() / approvalStageApprovers() | A named approval preset and its per-stage approver groups. |
recurring(RecurrenceData $rule) | A recurrence rule for createRecurring(). |
preventConflicts(bool $prevent = true) | Double-booking guard for this call. |
create() | Terminal — persists and returns one Appointment. |
createRecurring() | Terminal — returns a Collection: one appointment per occurrence, or a single-item collection without a rule. |
Defaults
- No startingAt() — the appointment starts now.
- No lasting() or until() — default_duration_minutes applies (60 unless configured).
- No withStatus() — the appointment is created pending.
- No timezone — appointments.timezone, else app.timezone, is used to read the start and stored on the appointment.
- until() is measured against the start whether you call it before or after startingAt(). Whichever of lasting() and until() runs last wins.
- withParticipant() without a role stores null — pass ParticipantRole::default() for an explicit Attendee.
Starting in another status
Walk-ins and imported bookings can skip the pending stage. A status set at creation is written as-is — it isn’t a transition, so AppointmentStatusChanged doesn’t fire:
use RoundlyConsulting\Appointments\Enums\Status;
use RoundlyConsulting\Appointments\Facades\Appointments;
$appointment = Appointments::schedule('Walk-in consultation')
->startingAt(now())
->lasting(20)
->withStatus(Status::Confirmed) // created confirmed — no transition event fires
->withParticipant($patient)
->create();One or many
createRecurring() always returns a Collection: one appointment per occurrence when recurring() set a rule, otherwise a single-item collection — so one code path handles both. See Recurring appointments.
From a typed DTO
Programmatic callers — imports, API endpoints — can hand Appointments::create() an AppointmentData instead; its fields are listed in Typed DTOs:
use Carbon\CarbonImmutable;
use RoundlyConsulting\Appointments\DataTransferObjects\AppointmentData;
use RoundlyConsulting\Appointments\DataTransferObjects\ParticipantData;
use RoundlyConsulting\Appointments\Enums\ParticipantRole;
use RoundlyConsulting\Appointments\Facades\Appointments;
$appointment = Appointments::create(new AppointmentData(
name: 'Project kickoff',
startsAt: CarbonImmutable::parse('2026-07-01 17:30'),
durationMinutes: 90,
participants: [
new ParticipantData($host, ParticipantRole::Organiser),
new ParticipantData($guest),
],
));Guards
A booking is written in one transaction, and its events fire only once that commits. So a refused or failed booking — a conflict, a participant listed twice, an invalid contact, an unknown approval workflow — leaves no row and fires no event. Listing the same model twice throws DuplicateParticipantException; an overlap under preventConflicts() throws SchedulingConflictException (see Conflict detection); a duration under one minute or an end that doesn’t come after the start throws InvalidScheduleException (see Durations & time zones).
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.