Skip to content

This is part of Build log, a running series on the WordPress plugins I maintain for venue and community sites.

This plugin kind of came about as I was building the SC Events Manager.

Venues don’t only put things on. They also hire things out — halls, meeting rooms, the odd sports court — and that’s a different problem from listing events, with a different plugin behind it. SC Room Bookings is the newer plugin, and it started by copying the ideas from the events plugin that had made sense… Although it quickly diverged.

Room Bookings plugin admin page screenshot
Room Bookings plugin admin page screenshot

So there’s no “Room” post type in the code, for the same reasons there’s no “Gig” in the other one. You name your own bookable type — Room, Court, Desk, Studio, Pitch — and the key and slug are locked at creation so relabelling later can’t orphan anything.

It also shipped with the type-deletion confirmation on day one, which the events plugin didn’t get until much later. Deleting a room type makes you type its plural label out in full, then moves every room of that type to Trash — a normal thirty-day recovery window, nothing hard-deleted — and cancels any bookings made against them.

That last word is deliberate. Bookings are cancelled, never deleted. A booking isn’t content the site owns and can tidy away; it’s a record that a real person asked for something on a real date. Even once the room it referred to is gone, the fact that the request happened is still true, and deleting it would be editing history to make an admin screen look neater.

What “is this slot free?” actually means

Availability sounds like a solved problem right up until you write it down. Whether a room can take a given start and end depends on rather a lot, and the order the checks run in is the order that produces the most useful error message:

// 1. is the end after the start?
// 2. is it at least the room's minimum booking length?
// 3. is the room open on that weekday?
// 4. does it fall inside the room's opening hours?
// 5. does it meet the site's minimum notice period?
// 6. does it collide with an existing booking, plus buffer?

Each returns a human-readable reason rather than a boolean, so the person requesting gets “this room needs at least 60 minutes per booking” instead of “unavailable”. The boolean version is a one-line wrapper around it for the callers that only need yes or no.

The decision that matters

By default a booking request isn’t binding until an admin confirms it. So there’s a status — pending — sitting between “someone asked” and “it’s happening”.

The tempting implementation is to count only confirmed bookings when checking whether a slot is free, because those are the real ones. Pending is just a request. It might be declined.

That’s wrong, and it’s wrong in a way you’d never catch in testing, because reproducing it needs two people and about ninety seconds. Person A requests Saturday afternoon. Nobody has looked at it yet. Person B checks the same slot, is told it’s free, and requests it too. An admin opens the queue on Monday to find two requests for one room from two people who were both told they could have it, and now has to disappoint one of them for a reason that is entirely the software’s fault.

So pending occupies a slot exactly as confirmed does:

private const BLOCKING_STATUSES = [
    BookingPostType::STATUS_PENDING,
    BookingPostType::STATUS_CONFIRMED,
];

Approval decides whether a booking is binding. It was never meant to decide whether somebody else can claim the same gap while it waits. Those are two different questions and the settings screen only ever asked the first one.

Buffers, from both directions

Rooms can require a gap either side of a booking — twenty minutes to reset a hall, an hour to clear a function room. The subtlety is which range you apply it to. It would be easy to buffer only the existing bookings and compare the new request against those, and that quietly gets it wrong in one direction.

So the requested range is buffered and the overlap test runs against that:

$buffer = new DateInterval(sprintf('PT%dM', $room['buffer_minutes']));
$bufferedStart = $start->sub($buffer);
$bufferedEnd = $end->add($buffer);

// two ranges overlap unless one ends before the other starts
if ($existingStart < $bufferedEnd && $existingEnd > $bufferedStart) {
    return __('That slot is no longer available — please choose another time.');
}

Classic interval overlap, but measured against the buffered request, so the room’s gap is honoured on both sides rather than only the one the code happened to check first.

Reusing these plugins across multiple sites

Having two plugins rather than one forced a distribution decision I’d been putting off. Both now live in a single version-controlled directory outside any individual site, and each site symlinks to them instead of keeping its own copy.

Before that, the honest state of things was several slightly divergent copies, each subtly ahead of or behind the others, with no reliable way to tell which. Now a fix lands once and every site picks it up on its next deploy. Five copies of a plugin is five plugins to maintain; one copy with five symlinks is one.

What this doesn’t fix

There is still that problem with multiple simultaneous bookings … Two requests can both read “free” before either writes, because the check and the write aren’t a single atomic operation — the classic time-of-check to time-of-use gap.

For a village hall taking a handful of enquiries a week, that’s a theoretical problem and the pending-status fix is the one that matters, because the real-world window is minutes or days rather than milliseconds. For anything with actual contention it would need a lock at the database level.

But that’s for a future version to fix!

An unapproved request is still a claim on the room. Anything else means the approval queue itself manufactures the conflicts it exists to prevent.