MarzleyTech Learn

Home / Learn / PHP & MySQL / Project: an appointment booking system

Project: an appointment booking system

Salons, clinics, garages and tutors all need the same thing: customers pick a service and a time, the business sees the bookings, and nobody gets double-booked. In this project you'll plan and build one, using everything from the PHP tutorial.

1. Plan the features

Customers can:

  • See services with prices and durations.
  • Pick a date and see only the free time slots.
  • Book with their name and phone number and get a confirmation code.

The owner can:

  • Log in, see today's bookings, mark them done or cancelled.

2. Design the database

SQL
CREATE TABLE services (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(80) NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  minutes INT NOT NULL
);

CREATE TABLE bookings (
  id INT AUTO_INCREMENT PRIMARY KEY,
  code CHAR(6) NOT NULL UNIQUE,
  service_id INT NOT NULL,
  customer VARCHAR(100) NOT NULL,
  phone VARCHAR(15) NOT NULL,
  starts_at DATETIME NOT NULL,
  status ENUM('booked','done','cancelled') NOT NULL DEFAULT 'booked',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (service_id) REFERENCES services(id),
  UNIQUE KEY one_booking_per_slot (starts_at)          -- the database itself prevents double booking
);

INSERT INTO services (name, price, minutes) VALUES
  ('Haircut', 300, 30), ('Braiding', 2500, 120), ('Manicure', 800, 60);

The UNIQUE key on starts_at means even if two people click "Book" at the same second, only one succeeds.

3. The logic: working out free slots

This part is pure PHP, so you can run it here. The shop opens 8:00–17:00 with 30-minute slots:

PHP · runs live in the interactive lesson
<?php
function slots(string $date, string $open = '08:00', string $close = '17:00', int $step = 30): array {
    $out = [];
    $t = strtotime("$date $open");
    $end = strtotime("$date $close");
    while ($t < $end) {
        $out[] = date('H:i', $t);
        $t += $step * 60;
    }
    return $out;
}

function freeSlots(string $date, array $booked): array {
    $all = slots($date);
    return array_values(array_diff($all, $booked));
}

// pretend these came from: SELECT TIME_FORMAT(starts_at, '%H:%i') FROM bookings WHERE DATE(starts_at) = ? AND status = 'booked'
$booked = ['09:00', '09:30', '13:00'];
$free = freeSlots('2026-10-05', $booked);
echo count($free), " free slots on 5 Oct\n";
echo implode(' ', array_slice($free, 0, 6)), " ...\n";

function bookingCode(): string {
    $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';     // no 0/O or 1/I to avoid confusion
    $code = '';
    for ($i = 0; $i < 6; $i++) $code .= $chars[random_int(0, strlen($chars) - 1)];
    return $code;
}
echo "Your booking code: ", bookingCode(), "\n";

function validPhone(string $p): ?string {
    $d = preg_replace('/\D/', '', $p);
    if (preg_match('/^254([17]\d{8})$/', $d, $m)) $d = '0' . $m[1];
    return preg_match('/^0[17]\d{8}$/', $d) ? $d : null;
}
var_dump(validPhone('+254 712 345 678'), validPhone('12345'));

4. Saving a booking safely

PHP
<?php
require __DIR__ . '/db.php';
$phone = validPhone($_POST['phone'] ?? '');
$name = trim($_POST['name'] ?? '');
$serviceId = (int)($_POST['service_id'] ?? 0);
$start = ($_POST['date'] ?? '') . ' ' . ($_POST['time'] ?? '') . ':00';

if (!$phone || $name === '' || !strtotime($start) || strtotime($start) < time()) {
    exit('Please check your details and choose a future time.');
}
try {
    $code = bookingCode();
    $stmt = $pdo->prepare('INSERT INTO bookings (code, service_id, customer, phone, starts_at) VALUES (?, ?, ?, ?, ?)');
    $stmt->execute([$code, $serviceId, $name, $phone, $start]);
    echo "Booked! Your code is $code. We'll see you on " . date('D d M, g:i a', strtotime($start));
} catch (PDOException $e) {
    if ($e->errorInfo[1] === 1062) {            // MySQL "duplicate key": slot just taken
        exit('Sorry, that time was just booked. Please choose another.');
    }
    throw $e;
}

5. The owner's dashboard

  • Protect it with the login from the Sessions and login lesson.
  • Show today's bookings: SELECT b.*, s.name AS service FROM bookings b JOIN services s ON s.id = b.service_id WHERE DATE(starts_at) = CURDATE() ORDER BY starts_at.
  • Buttons (POST forms with a CSRF token) to mark done or cancelled.

6. Going further

FeatureHow
SMS confirmationAn SMS API (e.g. Africa's Talking) after saving
Deposit to secure the slotM-Pesa STK Push (see the M-Pesa lesson), confirm on callback
RemindersA cron job each morning sends SMS for tomorrow's bookings
Multiple staffAdd a staff table and make the unique key (staff_id, starts_at)
Calendar viewFullCalendar (JavaScript) fed by a JSON API

Why booking systems are great projects

Salons, clinics, driving schools, tutors, car washes, event venues, photographers and consultants all need bookings. A working booking system combines the core skills of web development: database design, business logic (free slots), validation, preventing double bookings, notifications, payments and an admin dashboard. Built well, it's both a strong portfolio piece and a product you can sell to local businesses.

Handling business rules

Real businesses have rules that your slot logic must respect:

RuleExampleImplementation idea
Opening hours by dayMon–Fri 8:00–18:00, Sat 9:00–14:00, closed Sundayopening_hours table (weekday, open, close)
Service durationsHaircut 30 min, braiding 3 hoursservices.duration_minutes; check consecutive slots
BreaksLunch 13:00–14:00Blocked times table
Holidays/closuresPublic holidays, staff leaveclosures table with dates
Lead timeBookings at least 2 hours aheadCompare with current time
Booking windowUp to 30 days aheadDate range validation
Several staffEach stylist has their own calendarBookings linked to staff_id

Generating slots that fit a service's duration

PHP · runs live in the interactive lesson
<?php
function slotsForDay(string $open, string $close, int $stepMinutes, int $durationMinutes, array $booked, array $breaks = []): array {
    $slots = [];
    $start = strtotime("2026-10-05 $open");
    $end = strtotime("2026-10-05 $close");
    for ($t = $start; $t + $durationMinutes * 60 <= $end; $t += $stepMinutes * 60) {
        $slotStart = $t;
        $slotEnd = $t + $durationMinutes * 60;
        $clash = false;
        foreach (array_merge($booked, $breaks) as [$bStart, $bEnd]) {
            $bs = strtotime("2026-10-05 $bStart");
            $be = strtotime("2026-10-05 $bEnd");
            if ($slotStart < $be && $slotEnd > $bs) { $clash = true; break; }   // overlap test
        }
        if (!$clash) $slots[] = date('H:i', $slotStart);
    }
    return $slots;
}

$booked = [['09:00', '10:30'], ['14:00', '15:00']];
$breaks = [['13:00', '14:00']];
echo "60-minute service: ", implode(', ', slotsForDay('08:00', '17:00', 30, 60, $booked, $breaks)), "\n";
echo "2-hour service:    ", implode(', ', slotsForDay('08:00', '17:00', 30, 120, $booked, $breaks)), "\n";

The key line is the overlap test: two time ranges overlap when startA < endB and endA > startB. This one rule handles services of any length, breaks and existing bookings.

Preventing double bookings under pressure

Two customers may pick the same slot at the same moment. Protect it at the database level:

SQL
-- One booking per staff member per start time
ALTER TABLE bookings ADD UNIQUE KEY uniq_staff_slot (staff_id, booking_date, start_time);

For variable-length services, check overlaps inside a transaction with a row lock:

PHP
$pdo->beginTransaction();
$stmt = $pdo->prepare(
    'SELECT COUNT(*) FROM bookings
     WHERE staff_id = ? AND booking_date = ? AND status <> "cancelled"
       AND start_time < ? AND end_time > ? FOR UPDATE'
);
$stmt->execute([$staffId, $date, $newEnd, $newStart]);
if ($stmt->fetchColumn() > 0) {
    $pdo->rollBack();
    exit('Sorry, that time was just taken. Please choose another slot.');
}
$pdo->prepare('INSERT INTO bookings (staff_id, booking_date, start_time, end_time, customer_name, phone, status) VALUES (?, ?, ?, ?, ?, ?, "pending")')
    ->execute([$staffId, $date, $newStart, $newEnd, $name, $phone]);
$pdo->commit();

Booking statuses and their lifecycle

pending  →  confirmed (deposit paid or owner approves)  →  completed
    ↘ cancelled (by customer or owner)          ↘ no_show

Store status changes with timestamps; the owner can see no-show patterns and you can automatically release unpaid pending bookings after, say, 30 minutes.

Deposits with M-Pesa (flow)

  1. Customer chooses a slot; booking saved as pending with an expiry time.
  2. Your server starts an M-Pesa payment request for the deposit (through Daraja or a payment provider), using credentials stored in configuration outside public_html.
  3. The callback confirms payment; after verifying it, mark the booking confirmed and store the receipt number (unique).
  4. If no payment arrives before expiry, mark it cancelled and free the slot.
  5. Send confirmation by SMS, WhatsApp or email.

Never mark a booking paid based only on the customer's screenshot or the browser saying "done": rely on the verified callback or a status query.

Reminders and notifications

  • Confirmation immediately after booking.
  • Reminder the day before (a cron job runs every morning to send reminders for tomorrow's bookings).
  • Optional reminder 1 hour before.
  • A link to cancel or reschedule (with a secure random token), which reduces no-shows.
Terminal
# cron: send tomorrow's reminders at 8:15 every morning
15 8 * * * /usr/bin/php /home/username/app/cron/send_reminders.php >> /home/username/logs/reminders.log 2>&1

The owner's dashboard: useful views

ViewShows
TodayTime-ordered list with customer, service, status, phone (tap to call/WhatsApp)
CalendarWeek view per staff member
Pending paymentsBookings awaiting deposits
ReportsBookings per service, revenue, no-show rate, busiest days and hours
CustomersHistory per customer, repeat visit count

Protect the dashboard with secure login (hashed passwords, session regeneration, HTTPS), and record who changed what.

Testing the system

  • Book the same slot from two browsers at once and confirm only one succeeds.
  • Try booking in the past, outside opening hours, during breaks and on closure days.
  • Test services that cross a break (a 2-hour service starting at 12:30 should not be offered if lunch is 13:00–14:00).
  • Test time zone handling (server in UTC, business in EAT): set date_default_timezone_set('Africa/Nairobi') or store UTC consistently.
  • Test on a phone over a slow connection.

Practice

  1. Add an opening_hours table and generate slots using each weekday's hours.
  2. Extend slotsForDay to skip slots that start less than 2 hours from "now".
  3. Add per-staff calendars and show each staff member's free slots.
  4. Implement automatic cancellation of unpaid pending bookings after 30 minutes with a cron job.
  5. Build a "reschedule" link using a random token stored with the booking.
Think about it: A salon offers 30-minute and 3-hour services. A customer books braiding (3 hours) at 10:00, and the system still shows 11:00 as free for a haircut with the same stylist. What's wrong in the logic, and how is it fixed?Show answer

The availability check only compares start times (10:00 vs 11:00) instead of time ranges. Each booking must store an end time (start + duration), and slots should be rejected if they overlap any booking: newStart < existingEnd AND newEnd > existingStart. Then 11:00 clashes with 10:00–13:00 and is hidden.

Check yourself

  1. What database feature prevents two bookings for the same time slot? (one word)

    Show answer

    unique

  2. What MySQL error number means a duplicate key?

    Show answer

    1062

  3. Which PHP function returns the values in one array that are not in another?

    Show answer

    array_diff

  4. Which function gives secure random numbers for codes?

    Show answer

    random_int

  5. Two time ranges overlap when startA < endB and endA > ...?

    Show answer

    startB

  6. Which SQL clause locks the selected rows inside a transaction? (two words)

    Show answer

    FOR UPDATE

  7. Which PHP function sets the default time zone, e.g. Africa/Nairobi?

    Show answer

    date_default_timezone_set

Lesson 14 of 15 in PHP & MySQL · Printable course notes