Insights
A booking engine inside the CMS: slots, guards, clocks

The booking page on this site holds no bookings. There is no availability table, no slots table, and no row anywhere saying that Friday at half past ten is spoken for. What exists is seven strings, four numbers, and one question the site asks a calendar every time somebody opens the page. That choice decides everything else in the module, including what the double-booking guard can possibly be.
Where does availability live if there is no availability table?
In the settings table, as one string per weekday and four integers. That is the whole model. Here are the real values on this install, read out of the local settings table on 4 September 2026, with the connected calendar identifiers left out:
cal_availability {"mon":"9:00-17:00","tue":"9:00-17:00","wed":"9:00-17:00",
"thu":"9:00-17:00","fri":"9:00-17:00","sat":"","sun":""}
cal_slot_minutes 15
cal_buffer_minutes 15
cal_notice_hours 24
cal_window_days 60
Those weekday strings are parsed by one small function whose docblock is the clearest description of the format I could write:
/** "9:00-16:00, 19:00-20:30" -> [[540,960],[1140,1230]] minutes-of-day. */
public static function parseRanges(string $s): array
The four numbers arrive with their own floors and ceilings, which is where the module's opinions are:
'slot' => max(5, (int) (Settings::get('cal_slot_minutes') ?: 15)),
'buffer' => max(0, (int) (Settings::get('cal_buffer_minutes') ?: 15)),
'notice' => max(0, (int) (Settings::get('cal_notice_hours') ?: 24)),
'window' => min(90, max(1, (int) (Settings::get('cal_window_days') ?: 60))),
From that, the slot engine builds a grid on every request: each day from today to the window edge, each availability range on that day, stepping by slot plus buffer, dropping anything sooner than the notice. Then it subtracts real busy time, read live from the connected calendar, and returns what is left as a plain list of Unix timestamps. Nothing about that list is written down. The next visitor gets it computed again.
Sixteen is not a setting. It falls out of the arithmetic: eight hours of availability, a grid step of thirty minutes, sixteen start times from 9:00 to 16:30. When the number on the page matches the number the rules imply, the engine and the calendar agree.
The reason there is no table is simpler than it sounds. Two systems that both believe they know when you are free will eventually disagree, and the one that is wrong is always the website. So the calendar stays the only record of when you are busy, and the site keeps rules about when you are willing.
What stops two people from booking the same minute?
A second look at the calendar, taken after the visitor presses the button. Not a lock, not a unique index, nothing in the database at all.
The confirm handler throws away whatever the page thought was open. It rebuilds the entire open set from a fresh free/busy read, then checks that the submitted time is in it:
$slot = (int) ($_POST['slot'] ?? 0);
$open = Scheduler::openSlots();
if ($open === null) $fail('Scheduling is unreachable at the moment. Please try again shortly, or use the contact form.');
if (!in_array($slot, $open, true)) $fail('That time was just taken. Here are the current openings.');
One comparison covers three failures. A time that has gone since the page loaded, a time somebody typed into the form, and a time from a tab left open since yesterday all fail identically, because none of them are in a set computed a second ago.
What is not there is worth naming. The leads table carries no uniqueness on the slot:
PRIMARY KEY (`id`),
KEY `status` (`status`),
KEY `type` (`type`),
KEY `created_at` (`created_at`)
A unique index on the slot would look reassuring and protect nothing, because the appointment does not live in my database. It lives on somebody's Google or Outlook calendar, and that calendar can be edited from a phone by a person who has never heard of my CMS. On this install the leads even sit in a separate database from the site content, so there is no single transaction that could span the check and the booking in the first place. When the resource you are protecting is in another system, a lock in yours is decoration.
What makes the guard hold instead is one line in the settings reader:
$book = trim((string) Settings::get('cal_booking_calendar'));
if ($book !== '' && !in_array($book, $busy, true)) $busy[] = $book;
The calendar that bookings are written to is always in the set of calendars read for busy time. So a confirmed booking removes its own slot from the next request, without a cache to invalidate or a row to update. The system that is authoritative is also the system being asked.
The honest gap is between that second read and the moment a new event shows up in the provider's own free/busy answer. A round trip sits in it, and however long Google or Microsoft takes to reflect an event it has just accepted. Two visitors would have to press the same time inside that window, and the loser would get a calendar invitation for a time the winner also holds. Both events land on the same calendar its owner opens every morning, so a collision surfaces there rather than silently. I have never seen it happen on a page that takes a handful of bookings a week, and I cannot tell you how wide the window is, because I have not measured it.
I do know what I would build to close it, and both vendors have already left the hook. Google's Calendar API lets the client set the event identifier on insert, and documents a 409 response named duplicate with the message "The requested identifier already exists." An identifier derived from the slot timestamp turns the race into a losing insert instead of a second appointment. Microsoft Graph has a different tool for a neighboring problem, a transactionId on the event, described as:
A custom identifier specified by a client app for the server to avoid redundant POST operations in case of client retries to create the same event.
That one is written for retries rather than for two strangers, so borrowing it for a race would be slightly off label. Google's identifier is the closer fit, and small enough that the only reason it is not written yet is that nothing has gone wrong.
Why does the page fail closed when the calendar cannot be reached?
Because "no times" is a disappointment somebody can recover from, and "a time that is not really free" is not. The contract is stated in the docblock of the function that returns the slots:
/** Open slot start timestamps. Null = free/busy lookup failed (never "all free"). */
public static function openSlots(?array $busyWindows = null): ?array
Null and empty are different answers, and the template treats them as different states. No connected calendar gets "Online booking opens shortly". An empty list gets "No open times in the next 60 days". A null gets "The calendar is not responding right now", with a link to the contact form. Three states, three sentences, none of which offer a time the site cannot keep.
There is one corner where it does not fail closed, and it took writing this post to notice how it reads. Google returns free/busy per calendar, and a single calendar can fail on its own inside an otherwise successful response. The module skips that calendar and keeps the rest:
foreach ($j['calendars'] as $id => $cal) {
if (!empty($cal['errors'])) { $errors[$id] = $cal['errors'][0]['reason'] ?? 'error'; continue; }
foreach ($cal['busy'] ?? [] as $b) $busy[] = [strtotime($b['start']), strtotime($b['end'])];
}
A calendar that cannot be read contributes no busy time, which means its owner looks free. The whole request failing is caught. One calendar quietly dropping out is not, except in the admin booking preview, which prints the reason next to the identifier it could not read. That is a real asymmetry, and the fix is not obvious, because refusing every booking on account of one secondary calendar hands the owner's problem to a stranger. For now it is visible to the person who can act on it and invisible to everyone else.
What is the buffer actually protecting?
Two things, from one number. It pads every busy window on both sides, and it spaces the grid:
$pad = $r['buffer'] * 60;
$busy = array_map(fn($b) => [$b[0] - $pad, $b[1] + $pad], $busyWindows);
$dur = $r['slot'] * 60;
$step = ($r['slot'] + $r['buffer']) * 60;
With fifteen and fifteen, a fifteen-minute call is offered every thirty minutes, and no call can start within fifteen minutes of anything already on the calendar. A calendar event is not the whole appointment. Somebody has to read the note, open the link, and arrive in the right frame of mind, and a scheduler that packs a call against the end of a therapy session has technically done its job and practically ruined an hour.
The minimum notice does a related job at the front. Openings begin at now plus the notice hours, so a person cannot book a call that starts in eleven minutes. And the window is capped in code at ninety days regardless of what the setting says, which I would defend on the grounds that a slot ninety days out is a promise about a calendar that does not exist yet.
What breaks when the clocks change?
Nothing in the arithmetic, and one label. The day loop is the part that would break in most first drafts, and it does not, because it adds a day rather than 86,400 seconds:
for ($day = strtotime('today'); $day <= $to; $day = strtotime('+1 day', $day)) {
The difference shows up twice a year. Daylight saving ends in the United States on the first Sunday in November, so with the site's timezone set to America/Los_Angeles in config.php, here is what those two ways of moving to the next day produce on 1 November 2026:
$ php -r 'date_default_timezone_set("America/Los_Angeles"); $d = strtotime("2026-11-01");
echo date("Y-m-d H:i T", strtotime("+1 day", $d)), "\n", date("Y-m-d H:i T", $d + 86400), "\n";'
2026-11-02 00:00 PST
2026-11-01 23:00 PST
The second one has quietly moved to the wrong day, and every slot generated from it would be an hour out. Inside a day the loop does step by fixed seconds, which is exact for any window that does not straddle the transition hour itself. Business hours never do. An overnight window would: I ran the module's own stepping over a 1:00 to 4:00 window on that Sunday and got 1:00 PDT, 1:30 PDT, 1:00 PST, 1:30 PST, then 2:00 onwards. Both of those one o'clocks are real, distinct, bookable instants, and the page would print them identically. Anyone offering appointments across two in the morning on that one night would want to know before the phone rang twice.
Everywhere else the timestamp is the unit and the wall clock is only presentation. The two providers get the same instant converted at the edge, Google with an offset, Microsoft in UTC:
google 'dateTime' => date('c', $startTs) // 2026-11-05T09:00:00-08:00
outlook 'dateTime' => gmdate('Y-m-d\TH:i:s') // 2026-11-05T17:00:00, timeZone: UTC
The visitor's own timezone is never guessed. The page states the owner's, once, above the grid. On 4 September, before the fix in the next paragraph, that line read "Choose a day · times in PDT" whichever day you clicked, including the days past the change in November. A person booking a call with someone in Oregon is better served by seeing Oregon's clock named than by a browser guess, which goes wrong in exactly the situations where being wrong costs an appointment.
Which leads to the one thing this post found. Writing that last paragraph, I went looking for every place that abbreviation comes from, and found four call sites in two files rather than the one I expected: the booking page's own eyebrow line, the confirmation template, the label the module writes into the lead record, and the same label in the confirmation email. All four called date('T') with no timestamp argument, so each reported the abbreviation for right now rather than for the appointment. Today that reads PDT. A call booked today for 5 November would have been a PST appointment printed with a PDT label. The instant was always correct in every system that matters, the calendar event, the invitation, the stored timestamp: it was three letters of display that would have gone stale for anything booked across the November boundary. I fixed all four the same day I found them, passing the slot's own timestamp into date('T', $startTs) at all four, rather than leave it as a claim in this post I had not shipped yet.
What does the confirmation promise, and what happens when the email does not arrive?
It promises two things, and only one of them is mine to keep.
The invitation is the provider's job, and both of them are explicit about it. The Google insert call carries sendUpdates=all, documented as "Notifications are sent to all guests." Microsoft Graph does not even offer the choice:
When you create an event that includes attendees, the server sends invitations to all attendees. This ensures consistency between the organizer's and attendees' views of the event and can't be configured.
So the calendar invitation rides on the same call that creates the event. The email the CMS sends afterwards is a courtesy on top of it, in the site's voice, with the meeting link repeated.
The order of operations is the answer to the second half of the question. The event is created first. Then a lead, a contact upserted by email, and a logged meeting interaction. Only then does anything get mailed. By the time an email can fail, the appointment exists in four places, one of which is the calendar its owner already looks at every morning.
When SMTP does fail, the mailer catches it rather than letting it surface:
try { return self::smtp($to, $subject, $body, $replyTo); }
catch (Throwable $e) {
error_log('Mailer SMTP failed: ' . $e->getMessage());
AdminLog::log('warn', 'mail', 'An email did not send over SMTP...', [...]);
}
That is a warning in the admin log and a fallback, which is more than nothing and less than proof. There is no delivery receipt anywhere in this path, and I would not trust one if there were: a handoff accepted by a mail server is not a message read by a person. What catches a missed notification is the fact that the notification was never the record in the first place, only a copy of it, and the record sits on a calendar and in a CRM beside every other inquiry the site has ever taken. If you build one of these, decide early which artifact is the booking. Everything else is allowed to fail.
Why keep booking inside the CMS at all?
Because the first contact belongs to the person who earned it. I made the privacy version of that argument in what a licensed practice site needs, where the people inquiring are asking about a controlled substance and the read scope on the calendar is the whole point, and the general version in rent a bot or own a harness. What I would add here is duller and, over a year, probably matters more: the booking lands in the same CRM as the contact form, so one person who inquires twice is one contact with two interactions rather than two lists that never meet. There is no per-seat fee, and because it is engine code rather than a site feature, every fork of the engine inherits it the same week it is fixed. The case study is on the booking module, which was first built for a client practice and generalized afterwards.
The trade is real, so here it is. A rented scheduler will take a deposit, round-robin across a team, send SMS reminders, offer a reschedule link, and detect the visitor's timezone. This module does none of those. It does one flow properly, on one domain, with the phone as the first target, which is why the slot grid is a set of 48-pixel buttons rather than a desktop calendar widget squeezed down, a habit I wrote about in designing for one thumb. If any of those five things are load-bearing for your business, rent the tool and be happy.
The next thing I owe this module is not a feature. It is a number: how many seconds a brand new event takes to show up in the next free/busy answer, measured over real calls rather than guessed at.
Common questions
Can two people book the same time slot?
Almost certainly not, but the guard is a fresh calendar read rather than a database lock. When the form is submitted, the module recomputes the entire open set from a live free/busy call and checks that the submitted timestamp is still in it, so a stale, edited or just-taken time all fail the same way. The remaining window is the gap between that read and the event landing on the calendar, which spans the round trip and the provider's own reflection of the new event.
What happens if the calendar service is unreachable?
The page says so and offers nothing. The slot function returns null for a failed free/busy lookup, which is treated as a separate state from an empty list, so the visitor sees "The calendar is not responding right now" with a link to the contact form rather than a list of times the site cannot keep.
How does the booking handle daylight saving time?
Slots are Unix timestamps end to end, and the day loop advances with a calendar day rather than 86,400 seconds, which is what keeps the two transition days correct. Google receives the instant with its offset, Microsoft Graph receives it in UTC. Times are shown in the site owner's timezone, named on the page, rather than guessed from the browser.
What does a rented scheduling service do that this does not?
Deposits and payments, round-robin assignment across a team, SMS reminders, self-service reschedule links, and per-visitor timezone detection. The module does one booking flow well on the practice's own domain, and puts every booking into the same CRM as the rest of the site. If any of those five features are load-bearing, a subscription is the better answer.
Related