Simple Booking 2.3: A WordPress Booking Plugin That Cannot Double-Book
I rewrote my booking plugin from scratch and moved double-booking prevention out of PHP and into a database constraint. Here is why the old version lost races, what changed in the data model, and what the free plugin now includes.
Version 1 was written for a single salon, under deadline, and it worked — right up until two people opened the booking form at the same time. Then it did the thing every booking system is supposed to make impossible: it took both.
Simple Booking 2.3 is the rewrite. The business rules were ported; the implementation was not. This post is about the four decisions that changed, because they are the ones that decide whether a booking plugin holds up on a busy Friday.
Why version 1 lost races
The old code did what almost every booking plugin does:
if ( slot_is_free( $staff_id, $start ) ) {
insert_booking( $staff_id, $start, $customer );
}
Read it carefully and the bug is between the two lines. Two requests can both run slot_is_free(), both get true, and both proceed to insert. The window is small — a few milliseconds — and that is exactly why it survives testing and shows up in production, on the one afternoon when a promotion sends thirty people to the same page.
You cannot close that window from PHP. You can shrink it, and people do, with transactions and SELECT ... FOR UPDATE and application locks, but the honest fix is to stop asking the question in PHP at all.
Double booking is a constraint, not a check
In 2.x every booking carries a slot_key: a digest of the staff member and the start instant, under a unique index.
Two concurrent attempts on the same slot compute the same digest. The first insert succeeds. The second one does not fail because the application noticed — it fails because the database refuses it. There is no window, because there is no gap between deciding and writing. It is one operation.
Cancelling a booking nulls the key, and MySQL permits unlimited NULLs in a unique index, so the slot becomes bookable again without any extra bookkeeping.
The test that matters fires five simultaneous requests at one slot over real HTTP:
Concurrency: 5 simultaneous requests for one slot
PASS exactly one of five concurrent requests won (1×201, 4×409)
That runs against a live site, not a mock.
One availability engine, not three
Version 1 answered “is this slot free?” in three places: the customer-facing calendar, the admin calendar, and the check before insert. They started identical and drifted, the way duplicated logic always does. Every double booking it ever produced was a spot where two of them disagreed — usually about a service with padding, or a day with a one-off schedule override.
In 2.x there is one AvailabilityEngine, and all three callers go through it. This sounds like tidiness. It is not: it is the actual fix, and the constraint above is the safety net under it.
Times are stored in UTC
Every instant is a UTC column with the originating timezone recorded next to it.
Wall-clock storage is the tempting alternative — it reads nicely in the database and matches what the customer sees. It also makes every comparison implicitly local, which means it breaks twice a year at the daylight-saving boundary, and again on the day you move the site to a server in another region. Storing UTC and converting at the edges costs one function call and removes a whole category of bug reports that arrive months later and cannot be reproduced.
A booking stores two spans, not one
This is the subtle one, and it began life as an economy.
A booking has:
start_utc/end_utc— the appointment as the customer understands it. Displayed, emailed, exported.blocked_start_utc/blocked_end_utc— the calendar reservation, widened by the service’s setup and cleanup padding. This is what collides.
Version 1 had one pair carrying both meanings, and it caused two bugs at once. Customers were shown the salon’s twenty minutes of cleanup as part of their own appointment. And group-capacity counting silently stopped working on any service with padding, because the thing being counted was no longer the thing being booked.
Splitting them made both problems disappear and made the code easier to read, which is usually how you can tell a merge was wrong in the first place.
409 is not 400
One more distinction the front end depends on. Both of these fail the same availability check:
- 409 Conflict — the slot was real, and somebody else just took it. Refresh the times and the customer picks another. Recoverable.
- 400 Bad Request — the time asked for was never on offer. Something is wrong with the request, not with the timing.
Collapsing them into one error code means showing “that time is no longer available, please try again” to someone whose request will never succeed, however many times they retry.
What is free, and what is not
The free plugin is on tanchev.net/simple-booking and it is the whole booking system:
- Unlimited services, staff members and bookings — no caps of any kind
- Prices per service, settled on the day
- Group bookings for classes and workshops
- Multi-staff scheduling with per-staff weekly hours, breaks and holidays
- Email confirmation, cancellation and reminders, with wording you can edit
- Admin calendar with day, week and month views
- A one-click importer from version 1.x
There is one paid add-on, Simple Booking Pro, and it adds two things: notifications over SMS and WhatsApp through Twilio, and taking deposits online through your WooCommerce checkout.
That split is not a marketing decision, it is a constraint worth knowing about if you ever plan to publish on WordPress.org: the directory rejects plugins containing locked or obfuscated functionality. Paid features cannot sit inside the free plugin behind a licence check. So the free plugin exposes extension points — a feature gate, a channel registry, a gateway registry — and the add-on fills them. The practical consequence is that adding SMS required no change to the free plugin at all.
Deposits are the interesting case, because they show what that rule actually costs you. Taking money online used to be free. Making it a paid capability was not a matter of adding a licence check in front of the code — that check is exactly the locked functionality the directory rejects, and it would have failed review. It meant moving the WooCommerce gateway out of the free plugin and into the add-on, so that the free plugin genuinely does not contain a card gateway any more. The gate in front of it now denies nothing that ships.
You get one of two options with a plugin you want in the directory: charge for a feature by not shipping it, or ship it and do not charge for it. There is no third option that survives review. Everything that stayed free — group bookings, multiple staff, editable notification wording — stayed free because its code is still in the free plugin, and a padlock drawn over working code is the thing that gets you rejected.
One consequence is worth stating plainly, because it is the kind of thing that turns into a support ticket: a site without the add-on keeps its deposit settings and stops acting on them. Bookings confirm immediately rather than waiting for a payment that nothing on the site can take. The configuration is still there, intact, on the day a licence arrives.
Getting it
- Free plugin: download version 2.3.0 or read the product page
- Pro add-on: pricing, from €49/year
Requires WordPress 6.4+ and PHP 8.0+. WooCommerce is only needed if you want to take deposits online, and then you need the add-on as well: the add-on raises the order, WooCommerce processes the card.