Booking and Availability
The core of the product. Five steps, four of them LEARN, because every one of them is a place
where generated code looks right and is wrong.
Step 9 — Working hours, staff schedules, and the availability engine
Section titled “Step 9 — Working hours, staff schedules, and the availability engine”Story: As a customer, I see the times I can actually book with a given member of staff, so that I am not offered a slot that will be refused.
Mode: LEARN — this is an algorithm, and it is the product.
Why now: Everything after this consumes availability: the booking flow, the public page, the reminders. Get the shape wrong and all three inherit it.
Concepts:
- Availability as set arithmetic: opening hours, minus time off, minus existing bookings, minus buffers, intersected with a booking horizon
- Interval algebra — the six ways two intervals can relate, and the two that people always forget when they write the overlap condition by hand
- Slot granularity: fixed grid (every 15 minutes) vs free-start. What each does to the number of candidate slots and to customer expectations.
- Buffers and setup time: Bright Smile needs 15 minutes of cleanup after every appointment, Northside needs none. A 45-minute service in a 60-minute footprint is not the same thing as a 60-minute service.
- Lead time and horizon — no bookings in the next two hours, none more than 60 days out
- Capacity beyond one: Loft Yoga’s class has 12 places, so “is this slot free” becomes “how many places are left”. It is the same engine with a count instead of a boolean — decide now whether you support it, because retrofitting it later touches every rule.
- Where the computation lives: a pure, testable service that takes a date range and returns intervals, with no framework types in its signature
- Property-based thinking: what must be true of any output — no slot outside opening hours, no slot overlapping a booking, no slot shorter than the service
Libraries: nesbot/carbon (bundled), optionally spatie/period — decide whether the dependency earns its place
Expected outcome: A pure AvailabilityService taking (staff, service, date range) and returning available intervals, with working hours, time off, existing bookings, buffers, lead time and horizon all applied. Table-driven tests covering each rule alone and in combination.
It must give the right answer for all three example businesses without knowing which one it is serving: Northside’s 30-minute cuts back to back, Bright Smile’s 45-minute appointment that occupies 60 minutes and therefore cannot start at 17:15 before an 18:00 close, and Loft Yoga’s class that stays bookable until the twelfth place is taken.
app/Scheduling/├── Availability/ the engine — no Eloquent, no facades in the signature├── Rules/ one class per constraint: WorkingHours, TimeOff, Buffer, LeadTime└── ValueObjects/ Interval, SlotSize, DurationVerification
| L1 — Gating test | ACC-09 — a table-driven test where each row states opening hours, existing bookings, time off, buffer and service duration, and asserts the exact list of slots returned. Include: Northside fully booked, Bright Smile’s last slot of the day refused because the cleanup buffer runs past closing, a day fully off, and a booking that starts before opening and ends after it. |
| L2 — Manual checks | (a) Feed the engine a week with 200 existing bookings and time the call. Note the number; step 13 will attack it. (b) Hand the rules list to someone else and ask them to name a case you did not test. There is always one. |
| L4 — Anti-patterns | AP-09-a, AP-09-b, AP-09-c — full text |
| Done when | ACC-09 green, and the engine can be tested without touching the database |
Step 10 — Time correctness: timezones, DST, recurrence
Section titled “Step 10 — Time correctness: timezones, DST, recurrence”Story: As a customer travelling abroad, my 09:00 appointment is still at 09:00 in the shop’s town, so that I arrive when the business expects me.
Mode: LEARN — expect to fail review here. Almost everyone does.
Why now: Step 9 produced intervals. Until they are anchored to real instants they are only numbers, and the bugs are invisible in a single timezone.
Concepts:
- Instant vs local time: a booking happens at an instant; opening hours are a local wall-clock rule. They are different types and storing them the same way is the root of most calendar bugs.
- Store UTC, render tenant-local — and the third timezone, the customer’s, which affects display only
- DST gaps: on the spring-forward day, 02:30 does not exist. What does your engine emit for a business that opens at 02:00?
- DST overlaps: on the autumn day, 01:30 happens twice. Which one did the customer book?
- Recurring weekly rules are local rules — Loft Yoga’s “every Sunday at 09:00” is not “every 168 hours”, and the week the clocks change is where the two answers differ
- Duration vs interval arithmetic: adding one day is not adding 24 hours
- Carbon’s immutable API, and why the mutable one causes action at a distance
- A clock abstraction so tests can control “now” instead of sleeping
- Where the tenant’s timezone is set, and what happens when the business changes it after bookings exist
Libraries: Carbon (immutable), PHP’s IntlDateFormatter for display, a Clock interface of your own
Expected outcome: All timestamps stored as timestamptz in UTC, a Clock bound in the container and frozen in tests, availability computed against the tenant timezone, rendering in the viewer’s timezone, and explicit, tested behaviour on both DST boundaries.
Verification
| L1 — Gating test | ACC-10 — with the tenant timezone set to one that observes DST, assert the slot list on the spring-forward day contains no non-existent local time, and on the fall-back day the duplicated hour resolves to exactly one instant. Assert a weekly 09:00 rule stays 09:00 local on both sides of each transition. |
| L2 — Manual checks | (a) Set Loft Yoga’s timezone to Pacific/Chatham (a 45-minute offset) and read the rendered schedule. (b) Book a Loft Yoga class as a customer whose browser is in another continent. Both of you must agree on which class it is. (c) Change a tenant’s timezone after bookings exist. Decide, and document, whether existing bookings move. |
| L4 — Anti-patterns | AP-10-a, AP-10-b, AP-10-c, AP-10-d — full text |
| Done when | ACC-10 green, and no now() call remains outside the clock |
Step 11 — The booking flow and its state machine
Section titled “Step 11 — The booking flow and its state machine”Story: As a customer, I can hold a slot while I finish entering my details, so that it is not taken from under me mid-form.
Mode: LEARN — the state machine is a design decision with consequences you will live with.
Why now: Step 12 makes this correct under concurrency. You cannot make a flow correct before the flow exists.
Concepts:
- The states: held → pending → confirmed → completed, plus cancelled and no-show. Draw the diagram before writing code.
- Illegal transitions — confirmed → held must be impossible, and “impossible” means the model refuses, not that no screen offers it
- Holds with a TTL: why a hold is a row and not a cache entry, and how it is released — lazily on read, actively by a job, or both
- Where the rules live: an enum for the state, a transition method on the aggregate, and events for the side effects
- Events and listeners:
BookingConfirmedfans out to email, calendar and metrics without the booking service knowing about any of them - Idempotent transitions — a double-submitted form must not create two bookings
- Cancellation policy as data, not as an
if— a window, a fee, and who may cancel - Audit: who changed what, when. A booking dispute is a support ticket you answer from the log.
Libraries: framework events and enums; a state-machine package only if you can say what it adds
Expected outcome: A Booking aggregate with a state enum and guarded transitions, SlotHold with a TTL and a release path, domain events for each transition, an idempotency key on booking creation, and an append-only booking audit trail.
Verification
| L1 — Gating test | ACC-11 — a test over every (state × transition) pair asserting allowed transitions succeed and every illegal one throws and changes nothing. Plus: submitting the same booking request twice with one idempotency key creates exactly one booking. |
| L2 — Manual checks | (a) Create a hold and let it expire. Confirm the slot returns to availability by both paths — the lazy read and the sweeper job. (b) Draw the state diagram from the code, not from memory. If it differs from your original drawing, one of them is wrong. |
| L4 — Anti-patterns | AP-11-a, AP-11-b, AP-11-c — full text |
| Done when | ACC-11 green, and no code outside the aggregate assigns a booking state |
Step 12 — Double-booking: locking vs an exclusion constraint
Section titled “Step 12 — Double-booking: locking vs an exclusion constraint”Story: As a business owner, two customers can never both book Tuesday 10:00 with the same barber, so that I never have to phone one of them to apologise.
Mode: LEARN — write the failing concurrency test first. It is the only way to know your fix worked.
Why now: Everything needed to make the race happen now exists. Doing this earlier would have meant simulating it.
Concepts:
- Why check-then-insert is always a bug: between the
SELECTthat finds the slot free and theINSERTthat takes it, another request does the same thing - Transaction isolation in PostgreSQL: what
READ COMMITTEDactually promises, and why “it worked in testing” means “only one request at a time” - Pessimistic locking:
SELECT ... FOR UPDATEon what, exactly? Locking the booking row does nothing — the row does not exist yet. Lock the staff-day, or the resource. Note that Loft Yoga’s class of 12 is a different problem: there the count is the thing to protect, not the slot. - The database as the guarantee: a PostgreSQL
EXCLUDE USING gistconstraint over atstzrangeand the staff id makes overlap unrepresentable. Compare it honestly with the lock. - Deadlocks — consistent lock ordering, and what to do with the deadlock you still get
- Retry and backoff on serialisation failures, and why blind retry on any exception is dangerous with money involved
- Testing concurrency in PHP: parallel processes against one database, not threads. What your test harness must do to make the race real.
- The user-facing failure — 409 with a fresh slot list, never a 500
Libraries: PostgreSQL btree_gist extension, Pest with parallel processes
Expected outcome: An exclusion constraint preventing overlapping non-cancelled bookings per staff member, transactional slot-taking with the appropriate lock, a translated 409 response with fresh availability, and a concurrency test that reliably fails before the fix.
Verification
| L1 — Gating test | ACC-12 — 50 concurrent requests for the same slot: exactly one booking exists afterwards, 49 receive 409, no 500s, and no orphan holds remain. Run it 20 times; a race that passes once proves nothing. |
| L2 — Manual checks | (a) Drop the exclusion constraint and re-run the test. It must fail. If it still passes, the test is not concurrent. (b) Read the PostgreSQL log for deadlocks during the run and account for each one. |
| L4 — Anti-patterns | AP-12-a, AP-12-b, AP-12-c, AP-12-d — full text |
| Done when | ACC-12 green 20 runs in a row, and the database alone would still prevent the overlap |
Harness impact: AGENTS.md — record that slot-taking is transactional and constraint-backed, and that no code path may create a booking outside that service.
Step 13 — Read performance: N+1, eager loading, indexing
Section titled “Step 13 — Read performance: N+1, eager loading, indexing”Story: As a customer, the booking page shows me next week’s availability immediately, so that I do not give up and phone instead.
Mode: LEARN — reading a query plan is a skill, and it does not transfer from a generated diff.
Why now: The availability query is now the hottest read in the product, and step 19 puts it in front of real customers.
Concepts:
- The N+1 problem properly: how it appears through relations, through accessors, and inside Blade and Livewire loops
- Eager loading —
with,load, constrained eager loads,withCount, and when eager loading makes things worse - Reading
EXPLAIN (ANALYZE, BUFFERS): sequential scan vs index scan, estimated vs actual rows, and what a nested loop over 10k rows costs - Index design for this domain:
(tenant_id, staff_id, starts_at), and why column order is the whole decision. A GiST index for the range queries. - Chunking and lazy collections for exports, and why
->get()on a year of bookings kills the process - Selecting columns:
selecton wide tables, and the cost of hydrating full models for a list view - Caching what is expensive and stable — the computed availability for a staff-day, invalidated by a booking event, with the tenant in the key (step 7)
- Measuring first: a number before and after, or it did not happen
Libraries: laravel/telescope or barryvdh/laravel-debugbar in development only, PostgreSQL EXPLAIN
Expected outcome: Query counts bounded on every list screen, indexes chosen from real plans rather than guesses, an availability cache keyed by tenant, staff and day with event-driven invalidation, and a recorded before/after for the week-view query.
Verification
| L1 — Gating test | ACC-13 — with a seeded dataset of 200 staff-days and 2,000 bookings, assert the week-availability endpoint issues fewer than a fixed number of queries (assert on the count, so a regression fails CI) and returns within a stated budget. |
| L2 — Manual checks | (a) Run EXPLAIN (ANALYZE, BUFFERS) on the availability query before and after your index. Save both plans in docs/. (b) Create a booking and confirm the cached availability for that staff-day is invalidated — and that no other tenant’s cache was touched. |
| L4 — Anti-patterns | AP-13-a, AP-13-b, AP-13-c — full text |
| Done when | ACC-13 green, and you can explain each index you added by pointing at a plan |
Reference material — not a tracked step.