Case study // 10
Diamond Shine
A multi-branch hotel reservation system, built around the rules a front desk actually runs on.
- Role
- Engineer
- Year
- 2026
- Category
- Web
- Status
- Archived
- Laravel 10
- PHP 8.1
- MySQL
- Blade
- three.js
- PHPUnit
// CONTEXT
The problem
A hotel chain running several branches needs one system that four different people trust: a guest booking from home, a clerk working the desk, a manager reading last night's numbers, and an admin moving between branches. The interesting part is not the booking form. It is that a reservation is a six-state object with money attached, and almost every rule that makes it a hotel system rather than a CRUD app lives in the gaps between those states.
// CONSTRAINTS
What made it hard
- Two booking shapes share one table: rooms priced per night, and residential suites priced by week or month with a derived check-out date.
- Clerks and managers may only ever see their own branch; an admin moves between them.
- Two commands run unattended at 19:00 every night, so a failure is silent unless the code makes it loud.
- Card details were being captured, and no part of this system should ever be worth breaking into for them.
// DECISIONS
What I chose, and what I didn't
- 01
Leave reservations.room_id NULL until check-in. A guest books a room type; a clerk assigns the physical room at the desk.
WhyThat is how hotels actually work, and it is why an overbooked night is a capacity problem rather than a collision on a specific door number. It also means housekeeping and maintenance can shuffle rooms right up to arrival without touching a single reservation.
Instead ofAssigning a room at booking time, which looks tidier in the schema and forces a reshuffle of live reservations every time a room goes out of service.
- 02
Derive availability from overlapping reservations in one service, RoomAvailability, rather than reading a room's status column.
Whyrooms.status is a right-now flag: it says a room is free this second and nothing about next Tuesday. Counting active reservations against branch inventory over the requested dates is the only answer that holds for a future booking, and putting it in one place meant all five booking paths could be moved onto it. Overlap is deliberately strict, so a stay ending the morning another begins does not conflict — that is correct hotel semantics, and it is pinned by a test.
Instead ofChecking rooms.status at booking time, which is what the system did before and is why it could be double-booked.
- 03
Validate the card number with Luhn, then throw it away and store only a masked guarantee and expiry.
WhyThe 19:00 auto-cancel only needs to know whether a guarantee exists, not what it is. Once that was clear, holding the number had no upside and unbounded downside. Rows that predated the change were masked in place by an intentionally irreversible migration.
Instead ofEncrypting the full number at rest, which still leaves a database worth stealing and a key worth stealing with it.
- 04
Give walk-ins their own path that bypasses the future-date rule, rather than relaxing the rule everywhere.
WhyEvery other booking path requires arrival after today, which is right for a website and wrong for a guest standing at the desk. A separate path creates the reservation already checked in with the room assigned and the folio open, in one transaction, while still enforcing the occupancy cap and — importantly — capacity for the whole stay, because a room free tonight says nothing about Thursday.
Instead ofDropping the after:today validation globally, which would let a customer book yesterday from the public site.
// OUTCOME
What came of it
- 58 tests passing with 156 assertions, covering the occupancy cap, the four-weeks-becomes-one-month rewrite, refusal when no rooms remain, back-to-back stays not conflicting, cancellation freeing inventory, Luhn rejection, per-branch report isolation, and the nightly command being idempotent.
- Double-booking closed across all five booking paths by a single availability service.
- Full-PAN storage removed, and the historic rows masked by migration.
// REFLECTION
What I'd do differently
The controllers are fat and the business rules are duplicated across them — the four-weeks-to-one-month rewrite exists in four places, and calculateTotal in two. That was fine until it wasn't: the first bug I fixed in one copy was still live in the other three. A rules layer should have arrived the second time a rule was pasted, not the fourth. The repository keeps a written gap analysis of what this is still missing against a real PMS, which I would rather hand over than pretend does not exist.
