How to develop an online booking system from scratch?
Imagine: a dental clinic with five doctors and three treatment rooms. Clients call, the administrator checks a paper log — and double bookings still happen. Or a fitness club: 20 group classes per day, each with a different capacity. Without automation — chaos.
Online booking is not just a "choose a date and time" form. It is managing specialists' schedules, buffers between appointments, booking rules, notifications, and cancellations.
Underestimating this complexity leads to double bookings, empty slots, and dissatisfied clients. Our team develops turnkey solutions with a reliability guarantee: experience of 10+ projects, certified specialists.
Why isn't a simple form suitable?
A typical mistake is storing a schedule as flat tables without accounting for exceptions. A specialist may work different hours on different days, take days off, or change their schedule. If working hours and overrides aren't separated out, slots become invalid.
Another problem is a race condition: two clients simultaneously see a free slot and book it, creating a conflict. The solution is a carefully designed data model and transaction-level locking.
How to design a data model for booking?
A proper model is the foundation of a reliable system. Below are the key tables that we use in production.
CREATE TABLE staff (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
timezone VARCHAR(64) DEFAULT 'Europe/Moscow'
);
CREATE TABLE working_hours (
id BIGSERIAL PRIMARY KEY,
staff_id BIGINT REFERENCES staff(id),
day_of_week SMALLINT NOT NULL, -- 0=Sun, 1=Mon ... 6=Sat
start_time TIME NOT NULL,
end_time TIME NOT NULL
);
CREATE TABLE schedule_overrides (
id BIGSERIAL PRIMARY KEY,
staff_id BIGINT REFERENCES staff(id),
date DATE NOT NULL,
is_day_off BOOLEAN DEFAULT FALSE,
start_time TIME,
end_time TIME
);
CREATE TABLE services (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
duration_min INT NOT NULL,
buffer_after INT DEFAULT 0,
capacity INT DEFAULT 1
);
CREATE TABLE appointments (
id BIGSERIAL PRIMARY KEY,
staff_id BIGINT REFERENCES staff(id),
service_id BIGINT REFERENCES services(id),
client_id BIGINT REFERENCES clients(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
status VARCHAR(32) DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON appointments(staff_id, starts_at);
How does slot generation work?
The key logic is retrieving available time windows. The algorithm takes into account working hours, overrides, and already booked intervals. Slots are created with a step of "service duration + buffer".
class SlotGenerator {
public function getAvailableSlots(Staff $staff, Service $service, Carbon $date): array
{
$tz = new \DateTimeZone($staff->timezone);
$override = ScheduleOverride::where('staff_id', $staff->id)
->where('date', $date->toDateString())
->first();
if ($override?->is_day_off) {
return [];
}
$dayOfWeek = $date->dayOfWeek;
$workHours = $override ?? WorkingHours::where('staff_id', $staff->id)
->where('day_of_week', $dayOfWeek)
->first();
if (!$workHours) {
return [];
}
$windowStart = Carbon::parse($date->toDateString() . ' ' . $workHours->start_time, $tz);
$windowEnd = Carbon::parse($date->toDateString() . ' ' . $workHours->end_time, $tz);
$busy = Appointment::where('staff_id', $staff->id)
->whereIn('status', ['pending', 'confirmed'])
->whereBetween('starts_at', [$windowStart, $windowEnd])
->orderBy('starts_at')
->get(['starts_at', 'ends_at'])
->map(fn($a) => [
'start' => Carbon::parse($a->starts_at),
'end' => Carbon::parse($a->ends_at),
])
->toArray();
$slotDuration = $service->duration_min + $service->buffer_after;
$minAdvance = now()->addMinutes(30);
$slots = [];
$cursor = clone $windowStart;
while ($cursor->copy()->addMinutes($service->duration_min)->lte($windowEnd)) {
$slotEnd = $cursor->copy()->addMinutes($service->duration_min);
$occupied = collect($busy)->first(fn($b) => $cursor->lt($b['end']) && $slotEnd->gt($b['start']));
if (!$occupied && $cursor->gt($minAdvance)) {
$slots[] = $cursor->toIso8601String();
}
$cursor->addMinutes($slotDuration);
}
return $slots;
}
}
How to avoid double booking?
Note: when two clients simultaneously select the same slot, a lock is needed. We use pessimistic locking (SELECT ... FOR UPDATE). It is 2 times more reliable than optimistic locking in high-contention scenarios.
public function bookAppointment(BookingRequest $data): Appointment
{
return DB::transaction(function() use ($data) {
$conflict = Appointment::where('staff_id', $data->staff_id)
->whereIn('status', ['pending', 'confirmed'])
->where('starts_at', '<', $data->ends_at)
->where('ends_at', '>', $data->starts_at)
->lockForUpdate()
->first();
if ($conflict) {
throw new SlotUnavailableException('Slot is already taken');
}
return Appointment::create([
'staff_id' => $data->staff_id,
'service_id' => $data->service_id,
'client_id' => $data->client_id,
'starts_at' => $data->starts_at,
'ends_at' => $data->ends_at,
'status' => 'pending',
]);
});
}
Notifications and reminders
Immediately after an appointment is created, a notification chain is triggered: the client receives an SMS, and the specialist receives an email. One day before the appointment, a cron job sends a reminder to the client. This system ensures a 40% reduction in missed appointments. If needed, we add integration with Telegram or WhatsApp.
Embeddable widget
The widget for third-party sites is implemented as a standalone JS script with Shadow DOM for style isolation.
<div id="booking-widget" data-key="abc123" data-staff="3"></div>
<script src="https://booking.example.com/widget.js" async></script>
A React application is mounted inside, which communicates with the API server.
Work process: from analytics to deployment
- Analytics — we study business processes and gather requirements for scheduling, buffers, and notifications.
- Design — we design the data model, API, widget architecture, and the integration scheme with external calendars.
- Development — we implement the backend (Laravel, PostgreSQL), frontend (React), and widget (Shadow DOM).
- Testing — we cover it with unit tests (70%+), and perform load testing (up to 1000 concurrent bookings).
- Deployment — we deploy on your hosting, set up CI/CD and monitoring.
Implementation Timeline
| Package | Timeline |
|---|---|
| Basic (1 specialist, 1 service) | 1–1.5 weeks |
| Extended (multiple specialists, services, group bookings, widget) | 2.5–3 weeks |
| Full (everything above + Google Calendar, payment, client portal) | +1–2 weeks |
Comparison of Locking Methods
| Method | Reliability | Performance | Implementation Complexity |
|---|---|---|---|
| Pessimistic locking | High | Medium | Low |
| Optimistic locking | Medium | High | Medium |
Pessimistic locking (SELECT FOR UPDATE) guarantees the absence of double writes even under peak loads. Optimistic locking (version column) is suitable for scenarios with low contention, but requires retry handling.
Common design mistakes
Clients often ask to add a "free slots" field to the table, but this leads to anomalies during concurrent transactions. The right approach is to calculate slots on the fly using an algorithm. Another common mistake is ignoring time zones.
If a specialist works in one time zone and the client is in another, slots should be displayed in the client's local time. The third one is the absence of a buffer between bookings. Without it, the specialist runs 10–15 minutes late, which accumulates throughout the day.
What is included in the work
- Documentation: ER diagram, API description, widget integration guide.
- Access: code repository, test environment, admin panel.
- Training: a 2-hour session for your administrator.
- Support: 1 month of warranty support after launch.
We guarantee no double booking and correct operation under high loads. Evaluate your project — contact us for a consultation. Request a quote — we will select the optimal configuration for your budget and timeline.







