Laravel Framework

Building an Online Booking System with Laravel: A Step-by-Step Guide

By the Domain India teamPublished 10 min read
Knowledge base article
Contents (9 sections)

An online booking system lets customers pick a service and a time slot and book it themselves: a salon, a clinic, a tutor, a consultant. This guide builds the core of one with Laravel 12 and PHP 8.3: services, available slots, bookings that can never clash, and email reminders, then shows how to deploy it on shared hosting.

Key takeaways

Create a Laravel 12 app with a starter kit for login, add services and appointments tables, and generate the free slots for a day from your opening hours minus existing bookings. Save each booking inside a database transaction with a locking query, so two customers cannot take the same slot at the same moment. Send reminders from the scheduler with Schedule::call(), and on shared hosting build the app on your computer and upload it, because Composer usually cannot run on the server.

1. What you will build

Services
A list of what you offer, with duration and price, managed by you.
Booking
Logged-in customers choose a service, a date and a free slot. Clashing bookings are refused.
Reminders
An email the day before each appointment, sent automatically once.

You need PHP 8.2 or newer (Laravel 12's minimum; this guide uses 8.3), Composer, Node.js for the front-end build, and MySQL. Develop on your own computer.

2. Create the project with login built in

bash
composer global require laravel/installer
laravel new booking

The installer asks which starter kit to use. Choose Livewire (or React or Vue if you prefer). The starter kit gives you registration, login, password reset and email verification, so you do not write authentication yourself. Then set your local database in .env and run:

bash
php artisan migrate
npm install && npm run build

For a business in one place, set 'timezone' => 'Asia/Kolkata' in config/app.php so every time you store and show is Indian time.

3. The database

bash
php artisan make:model Service -mf
php artisan make:model Appointment -m

The services migration:

php
Schema::create('services', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->text('description')->nullable();
    $table->unsignedSmallInteger('duration_minutes');
    $table->unsignedInteger('price_paise');          // store money as whole paise
    $table->boolean('active')->default(true);
    $table->timestamps();
});

The appointments migration:

php
Schema::create('appointments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('service_id')->constrained()->restrictOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->dateTime('starts_at');
    $table->dateTime('ends_at');
    $table->string('status')->default('confirmed');   // confirmed or cancelled
    $table->timestamp('reminder_sent_at')->nullable();
    $table->timestamps();
    $table->index(['starts_at', 'ends_at']);
});

Storing a start and an end time, instead of a date and a "slot" string, lets services of different lengths sit side by side and makes the clash check a single query. Prices in paise avoid rounding errors.

In app/Models/Appointment.php:

php
protected $fillable = ['service_id', 'user_id', 'starts_at', 'ends_at', 'status'];

protected function casts(): array
{
    return ['starts_at' => 'datetime', 'ends_at' => 'datetime', 'reminder_sent_at' => 'datetime'];
}

public function service(): BelongsTo { return $this->belongsTo(Service::class); }
public function user(): BelongsTo    { return $this->belongsTo(User::class); }

Add $fillable = ['name', 'description', 'duration_minutes', 'price_paise', 'active'] to Service. Build the screens for adding and editing services as a normal resource controller (php artisan make:controller Admin/ServiceController --resource) and protect those routes so only you can reach them, for example with a Gate that checks an is_admin column.

4. Finding the free slots

This method, in a small app/Support/Slots.php class or the controller, lists the start times still free on a day, assuming one person or room serves all bookings:

php
use Carbon\CarbonImmutable;

public static function forDay(Service $service, CarbonImmutable $day): array
{
    $open  = $day->setTime(10, 0);            // opening hours
    $close = $day->setTime(18, 0);
    $len   = $service->duration_minutes;

    $booked = Appointment::where('status', 'confirmed')
        ->where('starts_at', '<', $close)
        ->where('ends_at', '>', $open)
        ->get(['starts_at', 'ends_at']);

    $slots = [];
    for ($t = $open; $t->addMinutes($len) <= $close; $t = $t->addMinutes(30)) {
        $end   = $t->addMinutes($len);
        $clash = $booked->contains(fn ($a) => $a->starts_at < $end && $a->ends_at > $t);
        if (! $clash && $t->isFuture()) {
            $slots[] = $t->format('H:i');
        }
    }
    return $slots;
}

Two bookings overlap when one starts before the other ends and ends after the other starts. That one rule handles every case, including a long service next to a short one. Return the list as JSON from a route such as GET /services/{service}/slots?date=2026-10-05 and fill a drop-down with it using fetch(). A calendar library such as FullCalendar can display the same data; install it with npm, not Composer, because it is a JavaScript package.

5. Booking without double-booking

First a Form Request (php artisan make:request StoreAppointmentRequest):

php
public function authorize(): bool
{
    return $this->user() !== null;
}

public function rules(): array
{
    return [
        'service_id' => ['required', 'integer', Rule::exists('services', 'id')->where('active', true)],
        'starts_at'  => ['required', 'date_format:Y-m-d H:i', 'after:now'],
    ];
}

Then the controller:

php
public function store(StoreAppointmentRequest $request)
{
    $service = Service::findOrFail($request->validated('service_id'));
    $start   = CarbonImmutable::createFromFormat('Y-m-d H:i', $request->validated('starts_at'));
    $end     = $start->addMinutes($service->duration_minutes);

    DB::transaction(function () use ($service, $start, $end, $request) {
        $taken = Appointment::where('status', 'confirmed')
            ->where('starts_at', '<', $end)
            ->where('ends_at', '>', $start)
            ->lockForUpdate()
            ->exists();

        if ($taken) {
            throw ValidationException::withMessages([
                'starts_at' => 'Sorry, that time has just been booked. Please pick another slot.',
            ]);
        }

        Appointment::create([
            'service_id' => $service->id,
            'user_id'    => $request->user()->id,
            'starts_at'  => $start,
            'ends_at'    => $end,
        ]);
    }, 3);

    return redirect()->route('dashboard')->with('status', 'Your appointment is booked.');
}

Why this matters: checking for a clash and then saving are two steps. Without a lock, two customers who click at the same second both pass the check and both get the slot. lockForUpdate() inside the transaction makes MySQL hold that range of the index until the booking is saved, so the second request waits and then sees the first booking. The 3 retries the transaction if MySQL reports a deadlock. Also, only the fields you list are saved, so a visitor cannot inject a user_id or status.

In the Blade form, always include @csrf. Laravel rejects a POST without it. Blade's {{ }} escapes output, so service names and descriptions are safe to print.

6. Reminder emails

bash
php artisan make:mail AppointmentReminder --markdown=mail.appointment-reminder
php
class AppointmentReminder extends Mailable
{
    public function __construct(public Appointment $appointment) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'Reminder: your appointment tomorrow');
    }

    public function content(): Content
    {
        return new Content(markdown: 'mail.appointment-reminder');
    }
}

Schedule it in routes/console.php (Laravel 11 and later have no Console/Kernel.php):

php
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    Appointment::with(['user', 'service'])
        ->where('status', 'confirmed')
        ->whereNull('reminder_sent_at')
        ->whereBetween('starts_at', [now()->addHours(23), now()->addHours(25)])
        ->each(function (Appointment $a) {
            Mail::to($a->user)->send(new AppointmentReminder($a));
            $a->forceFill(['reminder_sent_at' => now()])->save();
        });
})->hourly()->name('appointment-reminders');

reminder_sent_at guarantees each customer gets one reminder even though the job runs every hour. A closure (Schedule::call) is used deliberately: it runs inside the scheduler's own process, which matters on shared hosting (section 8).

7. Test the rule that matters most

php
public function test_a_slot_cannot_be_booked_twice(): void
{
    $service = Service::factory()->create(['duration_minutes' => 60, 'active' => true]);
    $slot    = now()->addDay()->setTime(11, 0)->format('Y-m-d H:i');

    $this->actingAs(User::factory()->create())
        ->post('/appointments', ['service_id' => $service->id, 'starts_at' => $slot])
        ->assertRedirect();

    $this->actingAs(User::factory()->create())
        ->post('/appointments', ['service_id' => $service->id, 'starts_at' => $slot])
        ->assertSessionHasErrors('starts_at');
}

Fill in ServiceFactory with a name, duration and price, then run php artisan test. Add tests for bookings in the past, overlapping services of different lengths, and a cancelled booking freeing its slot.

8. Deploying on shared hosting

The full walkthrough is in Laravel on cPanel and DirectAdmin. The points that matter for this app:

  • Build on your computer, then upload. Run composer install --no-dev --optimize-autoloader and npm run build locally and upload the project with its vendor folder, keeping everything except public/ outside public_html. Composer usually cannot run on our cPanel servers, because it needs proc_open(), which is disabled there; see PHP disabled functions on shared hosting.
  • Production .env: APP_ENV=production, APP_DEBUG=false, CACHE_STORE=file, SESSION_DRIVER=file, QUEUE_CONNECTION=sync.
  • Scheduler: add one cron job in your control panel, running every 4 minutes (the shortest interval on shared hosting): cd /home/youruser/booking && php artisan schedule:run. Closures scheduled with Schedule::call() run; artisan commands scheduled with Schedule::command() may not, because they start a new process.
  • Mail: Laravel's SMTP mailer fails on our cPanel servers because socket functions are disabled. Use a small transport that calls PHP mail() with the -f sender, or an email API over HTTPS; both are explained in PHP sendmail settings.
When to move up

If the business grows into queue workers, SMS reminders sent in bulk, Redis or live calendar updates over websockets, those need long-running processes that shared hosting stops. Move the app to a VPS, where you control the server, at that point.

9. Running this on Domain India

Laravel 12 runs on our cPanel and DirectAdmin shared hosting: cPanel lets you choose the PHP version per domain and starts new accounts on PHP 8.3, and DirectAdmin offers PHP up to 8.3 (measured September 2026). MySQL, cron jobs and free SSL are included. Jailed SSH access is available on every shared hosting plan; it is off by default, so ask support to enable it if you want to run php artisan migrate on the server. SSH login uses a key, not a password.

A VPS is self-managed with full root access and no control panel, for when you need workers or Redis. The App Platform runs a Laravel app from your own Dockerfile, with a PostgreSQL database. Prices on the cards are live, per month, excluding 18% GST.

cPanel Starter
₹125/mo + GST
  • 25 GB NVMe SSD Storage
  • 50 GB Monthly Bandwidth
  • 1 Website
  • 10 Email Accounts
See plan details
VPS Starter
₹552.65/mo + GST
  • 1 vCPU
  • 2 GB DDR4 RAM
  • 64 GB NVMe SSD Storage
  • 2 TB Monthly Bandwidth
See plan details
Which Laravel and PHP versions does this guide use?

Laravel 12 with PHP 8.3. Laravel 12 needs PHP 8.2 or newer. The code uses the Laravel 11 and later structure, where scheduled tasks live in routes/console.php and there is no Console Kernel.

How do I stop two customers booking the same slot?

Check for overlapping bookings and save the new one inside a single database transaction, using lockForUpdate() on the overlap query. MySQL then makes the second request wait until the first booking is saved, and the second customer sees that the slot is taken.

How do I check if two appointments overlap?

Two appointments overlap when the first starts before the second ends and ends after the second starts. In a query that is starts_at less than the new end time and ends_at greater than the new start time.

Does the Laravel scheduler work on shared hosting?

Yes, through one cron job that runs php artisan schedule:run every 4 minutes, the shortest interval on shared hosting. Closures scheduled with Schedule::call() run normally. Artisan commands scheduled with Schedule::command() start a new process and can fail where proc_open is disabled, and tasks more frequent than every 4 minutes need a VPS.

Why does Laravel mail fail on shared hosting?

Laravel's SMTP and sendmail mailers need socket and process functions that are disabled on our cPanel servers. Use a custom transport that calls PHP mail() with the -f envelope sender, or a transactional email API over HTTPS.

Ready to launch your booking site? Read Laravel on cPanel and DirectAdmin before you upload, compare cPanel hosting and DirectAdmin hosting, or look at a VPS if you need queue workers.

Host your Laravel booking system

PHP 8.3 by default, MySQL, cron jobs and free SSL for your booking site.

See cPanel plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app