---
title: "Schema & custom models — Appointments for Laravel | Roundly"
description: "The appointments and participants tables, UUID/ULID participant keys, and how to swap in your own Appointment and Participant models."
url: https://roundly-consulting.com/open-source/docs/appointments-for-laravel/schema-and-models
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)

# Schema & custom models

Two tables back the package. Their names come from table\_names; column names are fixed.

## appointments

| Column | Type | Notes |
| --- | --- | --- |
| `id` | bigint | Auto-incrementing primary key. |
| `uuid` | uuid, unique | Random identity filled on insert — not the key. The ICS UID is built from it. |
| `name` | string | The appointment title. |
| `description` | text, nullable | Free-form description. |
| `status` | string, default pending | Cast to the Status enum. |
| `meta` | jsonb, nullable | Cast to a Collection. |
| `timezone` | string, nullable | The zone the appointment was booked in; drives local display. |
| `location` | string, nullable | Venue name. |
| `latitude / longitude` | decimal(10,7), nullable | Venue coordinates; indexed together, exposed as coordinates. |
| `starts_at` | timestamp | Always UTC, whatever app.timezone is. Indexed, and together with ends\_at. |
| `ends_at` | timestamp, nullable | Always UTC. Derived from the duration when not given. |
| `duration_minutes` | unsigned integer, nullable | Back-filled from ends\_at when not given. |
| `recurrence_group` | uuid, nullable | Shared by every occurrence of a series; indexed. |
| `created_at / updated_at / deleted_at` | timestamps | Soft deletes. |

## appointment\_participants

| Column | Type | Notes |
| --- | --- | --- |
| `id` | bigint | Auto-incrementing primary key. |
| `appointment_id` | foreignId | The appointment; a plain column without a database-level constraint. |
| `participant_type / participant_id` | morphs | The participating model; participant\_id is typed by key\_type. |
| `role` | string, nullable | Cast to the ParticipantRole enum. |
| `meta` | jsonb, nullable | Cast to a Collection. |
| `created_at / updated_at / deleted_at` | timestamps | Soft deletes. Unique index on (appointment\_id, participant\_type, participant\_id). |

Each model can join an appointment once — the unique index covers (appointment\_id, participant\_type, participant\_id). Soft-deleted participant rows still occupy that index, so adding a removed participant again restores its old row with the new role and meta (ParticipantUpdated) instead of inserting a second one.

## Participant key type

key\_type types the participant\_id morph column: bigint for Laravel’s default auto-incrementing keys, uuid for models using HasUuids, ulid for models using HasUlids. The value is case-insensitive; any other value throws InvalidConfigurationException when the migrations run, so a typo never builds the wrong column. Every model you attach as a participant must share that key type; the package’s own tables always keep auto-incrementing ids. Set it before you publish and run the migrations:

```bash
APPOINTMENTS_KEY_TYPE=uuid
```

```php
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Foundation\Auth\User as Authenticatable;
use RoundlyConsulting\Appointments\Concerns\HasAppointments;

class User extends Authenticatable
{
    use HasAppointments;
    use HasUuids; // pairs with APPOINTMENTS_KEY_TYPE=uuid
}
```

## Custom models

Point appointments.model and appointments.participant at your own subclasses to add relations, accessors or scopes. Extend the packaged models — they are deliberately not final and bring the location, contacts, reviews and approval traits with them:

```php
namespace App\Models;

use RoundlyConsulting\Appointments\Models\Appointment;
use RoundlyConsulting\Appointments\Models\Participant;

class Booking extends Appointment
{
    // Reads its table from appointments.table_names.appointments — nothing to set.

    public function isToday(): bool
    {
        return $this->startsAtLocal()->isToday();
    }
}

class BookingParticipant extends Participant
{
    // Reads its table from appointments.table_names.participants — nothing to set.
}
```

```php
// config/appointments.php
'model' => App\Models\Booking::class,
'participant' => App\Models\BookingParticipant::class,
```

- Both models read their table from table\_names — Appointment from table\_names.appointments, Participant from table\_names.participants — so a subclass with another class name needs no $table.
- An unset key (absent, null or blank) resolves the packaged model. Anything else must be the packaged model or a subclass of it — a class that doesn’t extend it throws InvalidConfigurationException naming the key instead of being silently replaced.
- The participants relation names its foreign key (appointment\_id) explicitly, so a swapped model never looks for a column derived from its own class name.
- The actions, the HasAppointments trait, the availability checks and the participant relation resolve the model from config, so your subclass is what they create and return.

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