---
title: "Creating appointments — Appointments for Laravel | Roundly"
description: "Create appointments with the fluent Appointments::schedule() builder or a typed DTO — every chainable method, the defaults and the terminal calls."
url: https://roundly-consulting.com/open-source/docs/appointments-for-laravel/creating-appointments
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Appointments for Laravel](https://roundly-consulting.com/open-source/docs/appointments-for-laravel.md)

# 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:

```php
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:

```php
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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/appointments-for-laravel.md)
