NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Appointments for Laravel

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

MethodWhat 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 crypto

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