Skip to main content
The bookings system allows tenants to accept appointments for time-based services. Customers book from the public microsite; providers and business owners manage appointments from the Filament panel.

Core models

Service

Defines what can be booked: name, duration in minutes, buffer times, price.

ServiceProvider

A staff member or resource that performs services, with their own schedule config.

Appointment

A booking record linking a customer, service, provider, and time slot.

OpeningHour

Defines the tenant’s weekly hours, used as the base for slot availability.

Service model

// app/Models/Service.php
protected $fillable = [
    'name',
    'description',
    'duration_minutes',  // Stored in minutes (no TZ issues)
    'buffer_before',     // Pre-appointment buffer (minutes)
    'buffer_after',      // Post-appointment buffer (minutes)
    'price',
    'is_active',
];
The total slot duration includes buffers:
// app/Models/Service.php
public function getTotalDurationAttribute(): int
{
    return $this->duration_minutes + $this->buffer_before + $this->buffer_after;
}

ServiceProvider model

Each provider can have their own weekly schedule stored as schedule_config (JSON). When no provider-level config exists, the system falls back to the tenant’s opening hours.
// app/Models/ServiceProvider.php
protected $fillable = [
    'name', 'email', 'phone',
    'schedule_config', // JSON: Spatie/opening-hours format
    'is_active',
];

// Schedule hierarchy: provider config > tenant hours > default 9-5 M-F
public function getOpeningHours(): OpeningHours
{
    if (!empty($this->schedule_config)) {
        return OpeningHours::create($this->schedule_config);
    }
    return $this->tenant->getOpeningHours();
}

Availability calculation

Availability checks use the Spatie\OpeningHours library and collision detection against existing appointments.
// app/Models/ServiceProvider.php
public function isAvailableAt(
    DateTimeInterface $start,
    DateTimeInterface $end,
    int $bufferBefore = 0,
    int $bufferAfter = 0
): bool {
    $paddedStart = CarbonImmutable::instance($start)->subMinutes($bufferBefore);
    $paddedEnd   = CarbonImmutable::instance($end)->addMinutes($bufferAfter);

    return !$this->appointments()
        ->where('status', '!=', 'cancelled')
        ->where('scheduled_at', '<', $paddedEnd)
        ->where('end_at',       '>', $paddedStart)
        ->exists();
}
The tenant opening hours are built from OpeningHour model records:
// app/Models/Tenant.php
public function getOpeningHours(): OpeningHours
{
    $hours = [];
    $dayMap = ['sunday', 'monday', 'tuesday', 'wednesday',
               'thursday', 'friday', 'saturday'];

    foreach ($this->openingHours as $openingHour) {
        $dayName = $dayMap[$openingHour->day_of_week] ?? null;
        if ($dayName && $open = $openingHour->getRawOpeningTime()) {
            $hours[$dayName][] = substr($open, 0, 5) . '-' . substr($close, 0, 5);
        }
    }
    // Fallback: Mon-Fri 09:00-17:00 if no hours configured
    return OpeningHours::create($hours ?: [...]);
}

Appointment lifecycle

// app/Models/Appointment.php
public const STATUS_PENDING   = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
public const STATUS_NO_SHOW   = 'no_show';
Transitions:
pending → confirmed → completed
       ↘ cancelled
       ↘ no_show
Each transition is enforced by the model’s business methods:
// app/Models/Appointment.php
public function confirm(): bool {
    if ($this->status !== self::STATUS_PENDING) return false;
    $this->status = self::STATUS_CONFIRMED;
    return $this->save();
}

public function complete(): bool {
    if ($this->status !== self::STATUS_CONFIRMED) return false;
    $this->status = self::STATUS_COMPLETED;
    return $this->save();
}

public function cancel(): bool {
    if (in_array($this->status, [STATUS_CANCELLED, STATUS_COMPLETED])) return false;
    $this->status = self::STATUS_CANCELLED;
    return $this->save();
}

Event dispatch on creation

Appointment creation automatically dispatches the AppointmentCreated event:
// app/Models/Appointment.php
protected $dispatchesEvents = [
    'created' => \App\Events\Appointment\AppointmentCreated::class,
];
This triggers the appointment confirmation email to the customer.

Public booking page

Customers book at /t/{tenant}/book (path-based local, or {tenant}.domain.com/book in production). The BookingController serves the Inertia page with the tenant’s active services, active providers, and their availability configuration. The frontend renders available time slots based on the provider’s schedule.

Appointment management portal

Customers receive a signed URL after booking:
GET /appointments/{appointment}/manage
This is a publicly accessible page (no login required) protected by Laravel’s signed URL middleware. Customers can cancel or reschedule their appointment without creating an account.

Reminder jobs

The scheduler dispatches appointment reminder jobs every hour:
// routes/console.php (scheduler)
// Appointment reminders: every hour
The reminder job queries upcoming appointments with reminder_sent_at = null and scheduled_at within the configured reminder window, sends the reminder email, and updates reminder_sent_at.

Email notifications

Mail classTriggerRecipient
Appointment confirmedAppointmentCreated eventCustomer email
Appointment reminderReminder job (scheduled hourly)Customer email
Both mails are sent to the customer_email field on the Appointment record. Guest bookings (no user account) are fully supported.

Panel management

The Filament tenant panel (/app) includes:
  • AppointmentResource — view, filter, and manage all appointments
  • ServiceResource — create and manage bookable services
  • ServiceProviderResource — manage staff/resource schedules

Build docs developers (and LLMs) love