A Laravel booking system needs five working parts: a service or resource model, an availability engine that turns working hours into bookable slots, a bookings table with a database-level guard against overlaps, payment and notification handling, and every timestamp stored in UTC. Describe those five parts to a Laravel-native AI builder and you get a production-ready Laravel app back, with migrations, Policies, and Pest tests attached.
Most booking system tutorials build a form that writes a row and stop there. Then two customers hit the same 10:00 slot inside the same second, and the demo falls apart.
You already know the CRUD part is easy. The hard part is the four problems that arrive the week after launch: overlapping bookings, timezones, recurring availability, and cancellation rules. This guide gives you the schema, the locking strategy, the availability logic, and the exact prompts that generate each piece, so your appointment booking Laravel app survives real traffic instead of a happy-path screenshot.
Key Takeaways
What a Laravel booking system actually needs
A Laravel booking system is six moving parts, not one form. Skip any of them and the gap shows up as a support ticket rather than a failing test.
Part | What it does | Laravel artifacts |
|---|---|---|
Services | Defines what can be booked, how long it takes, and what it costs |
|
Availability rules | Working hours, buffers, lead time, blackout dates |
|
Bookings | The reservation itself, with status and audit trail |
|
Booking flow | Validates the request and writes the row safely |
|
Money | Deposits, full payment, refunds on cancellation | Stripe webhook controller, queued jobs |
Comms | Confirmations, reminders, reschedule notices | Notification classes, scheduled command |
Scope the build by answering one question first. Can two customers ever want the same slot? If yes, you need the locking work covered below. If you are booking an unlimited resource such as a webinar seat, drop the overlap logic and save yourself a day.
Most of that table is boilerplate you have written before. If you would rather generate it than type it, try StackMivo now and describe the booking flow in plain English.
The data model behind an appointment booking Laravel app
Four tables carry an appointment booking Laravel app: services, staff_profiles, availabilities, and bookings. Everything else is reporting on top of those.
The bookings migration is where you win or lose the reliability argument.
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained();
$table->foreignId('staff_id')->constrained('users');
$table->foreignId('customer_id')->constrained('users');
$table->dateTime('starts_at');
$table->dateTime('ends_at');
$table->string('status')->default('pending');
$table->unsignedInteger('price_cents');
$table->timestamp('reminded_at')->nullable();
$table->timestamps();
$table->unique(['staff_id', 'starts_at']);
$table->index(['staff_id', 'starts_at', 'ends_at']);
});Two details matter more than the rest. The unique index on staff_id and starts_at stops the identical slot being written twice, which is the most common race in production. It does not stop a 60-minute booking overlapping a 30-minute one that starts 15 minutes later, so the composite index exists to make the range query below fast.
Cast both columns with 'starts_at' => 'immutable_datetime' so Eloquent hands you CarbonImmutable objects instead of strings. Immutable dates remove a whole class of slot-math bug where an addMinutes call quietly rewrites the variable you were comparing against.
Relationships stay ordinary. A service has many bookings, a booking belongs to a service, a staff member, and a customer, and availability hangs off the staff record. The Laravel relationship docs cover the edge cases you hit later when you add group bookings or a waitlist. If you are scaffolding the models by hand, the php artisan make:model cheatsheet lists the flags that create the migration, factory, Policy, and controller in one command.
Availability is the part most booking tutorials skip
Availability is computed, never stored as a fixed list of slots. Keep the rules in the database, generate slots on request, and cache the result for a few minutes.
Four rules cover almost every real schedule:
Working hours per staff member per weekday, in that person's own timezone.
Buffers before and after a service, so back-to-back appointments leave room for travel or cleanup.
Lead time and horizon, such as nothing inside the next two hours and nothing beyond 60 days out.
Blackout dates for holidays, sick days, and one-off closures.
Once the rules are explicit, the generator is small.
public function slotsFor(User $staff, CarbonImmutable $day, Service $service): array
{
$opens = $day->setTimeFromTimeString($staff->opens_at)->utc();
$closes = $day->setTimeFromTimeString($staff->closes_at)->utc();
$step = $service->duration_minutes + $service->buffer_minutes;
$taken = $staff->bookings()
->whereIn('status', ['pending', 'confirmed'])
->where('starts_at', '<', $closes)
->where('ends_at', '>', $opens)
->get(['starts_at', 'ends_at']);
return collect(CarbonPeriod::create($opens, $step . ' minutes', $closes->subMinutes($step)))
->reject(fn (CarbonImmutable $slot) => $taken->contains(
fn (Booking $b) => $slot->lt($b->ends_at)
&& $slot->addMinutes($step)->gt($b->starts_at)
))
->values()
->all();
}Notice that the conversion to UTC happens once, at the top. Every comparison after that is timezone-free, and the display layer converts back for the customer.
A two-person agency shipped a salon booking app for a client with three locations. Slots were generated in each location's local time and stored the same way, which worked fine until the last Sunday in March. The clocks moved forward, every 9:00 appointment for the following week rendered an hour off in the confirmation emails, and the salon spent a morning re-confirming 40 bookings by phone. The fix took two lines: cast to immutable datetimes, store UTC, convert on display. That lesson is much cheaper to learn from somebody else's Monday.
How to prevent double bookings in a Laravel booking system
Wrap the availability check and the insert in one transaction, and lock the rows you check. A check that runs outside a transaction is a suggestion, not a guarantee.
public function store(StoreBookingRequest $request): JsonResponse
{
$booking = DB::transaction(function () use ($request) {
$clash = Booking::query()
->where('staff_id', $request->integer('staff_id'))
->whereIn('status', ['pending', 'confirmed'])
->where('starts_at', '<', $request->date('ends_at'))
->where('ends_at', '>', $request->date('starts_at'))
->lockForUpdate()
->exists();
if ($clash) {
throw new SlotUnavailableException();
}
return Booking::create($request->validated());
});
return (new BookingResource($booking))->response()->setStatusCode(201);
}Here is the part most tutorials leave out. lockForUpdate does not behave identically across databases. On MySQL InnoDB, a ranged select for update takes next-key locks, so a concurrent insert into the same gap waits for your transaction to commit. PostgreSQL does not gap lock, so the same code leaves a small window open.
On Postgres, back the check with the unique index, an exclusion constraint on the time range, or an advisory lock such as DB::select('select pg_advisory_xact_lock(?)', [$staffId]) at the top of the transaction. The pessimistic locking section of the Laravel docs is worth reading before you commit to one approach.
If your app is queue-heavy and you would rather keep the guarantee in application code, Cache::lock('staff:' . $id . ':slots', 5)->block(3) around the same transaction is easier to reason about and behaves the same on either database. Whichever route you take, also catch the unique constraint violation and return 409. For money-bearing writes, belt and braces is the correct amount of paranoia.
Build the Laravel booking system with AI in five prompts
These prompts map one to one onto the parts above. Run them in order, review each output before moving on, and keep the schema prompt tight, because everything downstream inherits its mistakes.
Schema and models.Create Service, StaffProfile, Availability, and Booking models for an appointment booking Laravel app. Bookings need starts_at, ends_at, status, and price_cents, a unique index on staff_id and starts_at, and immutable_datetime casts.
Availability service.Add a SlotGenerator that takes a staff member, a date, and a service, then returns bookable UTC slots from weekday working hours, honouring buffer_minutes, a two-hour lead time, and blackout dates.
Booking endpoint.Generate a StoreBookingRequest, a BookingPolicy, and a controller action that checks overlaps with lockForUpdate inside a transaction and returns 409 when the slot is gone.
Payments and notifications.Add a Stripe deposit on confirmation, queued confirmation and reminder notifications, and a cancellation action that applies a 24-hour refund window.
Tests.Write Pest feature tests for a successful booking, a rejected double booking, a slot list across a daylight saving boundary, and a late cancellation that is refused.
Two things decide whether the output is worth keeping. The generator has to know Laravel conventions rather than generic PHP, which is what AI code generation built for the framework gives you. It also has to read your project before writing to it.
A team lead at a mid-size SaaS needed demo scheduling inside an existing Laravel 11 app. Instead of describing the schema from scratch, they connected the repository first so the generator could read the current User model, the tenant scope, and the notification setup already in place. The generated booking module reused the tenant middleware and matched the existing FormRequest style, so review took an afternoon rather than a sprint.
The same sequence transfers to adjacent builds. Swap the domain nouns and you have the skeleton of a CRM or a marketplace, which is why the booking system use case and this walkthrough on building a CRM with Laravel AI share most of their structure. You can get started free and run the first prompt against your own schema.
Payments, reminders, and shipping to production
Your booking app is not finished when the row saves. Three things generate the support load: money, memory, and change.
Deposits and cancellation rules
Take a deposit at confirmation rather than full payment, and store the payment intent ID on the booking. Put the cancellation policy in one place, ideally a method on the model such as isRefundable(), and call it from the controller, the Policy, and the admin panel. Policy logic duplicated across three files is policy logic that will disagree with itself within a month.
Reminders on the scheduler
No-shows drop when reminders actually send. Query the window, notify, and stamp the row so a retry never double-sends.
// app/Console/Commands/SendBookingReminders.php
Booking::query()
->where('status', 'confirmed')
->whereNull('reminded_at')
->whereBetween('starts_at', [now(), now()->addDay()])
->each(function (Booking $booking) {
$booking->customer->notify(new BookingReminder($booking));
$booking->update(['reminded_at' => now()]);
});
// routes/console.php
Schedule::command('bookings:remind')->everyFifteenMinutes();Run the notifications on a queue so a slow mail provider never blocks the scheduler. The Laravel task scheduling docs cover overlap prevention and single-server mode, both of which you want once more than one worker is running.
Tests that earn their keep
Two tests matter more than full coverage. One proves the same slot cannot be booked twice, the other proves slot lists stay correct across a daylight saving change.
it('rejects a second booking for the same slot', function () {
$staff = User::factory()->create();
$slot = CarbonImmutable::parse('2026-09-01 09:00:00', 'UTC');
postJson('/api/bookings', payloadFor($staff, $slot))->assertCreated();
postJson('/api/bookings', payloadFor($staff, $slot))->assertStatus(409);
expect(Booking::where('staff_id', $staff->id)->count())->toBe(1);
});Deploying the booking app
Deployment is where a booking app differs from a blog. Migrations that alter the bookings table need to run without dropping in-flight requests, and queue workers must restart so they pick up the new notification classes. A one-click deployment to Laravel Cloud, Forge, Ploi, or your own SSH box handles the migration run, cache clear, and worker restart in one step, with a rollback if the release misbehaves.
Where to start with your booking build
A Laravel booking system stands on four decisions. Compute availability from rules instead of storing fixed slots. Store every timestamp in UTC and convert only for display. Guard the insert with a transaction, a lock, and a unique index, then verify the guard with a test that tries to break it. Keep the cancellation policy in one method so refunds and admin actions never disagree.
Start with the migration and the double-booking test. Those two files pin down the behaviour that everything else depends on, and they take an hour. Generate the surrounding CRUD, the FormRequests, the Filament resource, and the notification classes, then spend your review time on the availability rules and pricing logic that actually make the product yours.
Build it once properly and the same skeleton carries your next scheduling feature, whether that is class bookings, resource rentals, or a full multi-location calendar.
Build your Laravel app with AI
Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.
Get started free