all projects

case study

runs on a server

Online Stadium Booking

My 3rd-year engineering project, which I found five years later and rebuilt. The admin panel turned out to be reachable without logging in, and the booking flow would happily oversell a stadium. Both are fixed now, with a concurrency test that proves it. The live demo runs on a free tier, so give it a moment to wake up.

Reading your own code back after five years

This was my third-year engineering project, a PHP and MySQL ticket-booking system, and five years later I found it and read it properly instead of remembering it fondly. Twenty defects came out of it, each written up with the original code, the failure it caused and the fix. The 2021 version is kept unmodified in a legacy folder instead of deleted, so the whole thing reads as a diff and you can check every claim against the code it describes. Two of the twenty are worth a portfolio's space, not because they were the hardest to fix but because of what each one shows about how a defect survives review: one was invisible to anyone clicking through the app as its author (me), and the other hid behind a second bug in the same four lines.

Authorisation implemented as navigation

The admin entry point included the dashboard unconditionally: no session check, no role check. Requesting the admin URL while logged out returned the full interface, with create access to fixtures and read access to every customer's bookings. What makes it worth retelling is that the mechanism to stop it was already there and wired up. The users table had a role column, it was populated, a roles table existed, and the login code read it. It read it to decide where to redirect you. The role picked which URL you were sent to; nothing picked which URLs you were allowed to visit. That distinction is the whole bug, and it's invisible from the inside. Click through the app as the developer and it behaves exactly like working access control, because you always land where your role says you should. It only shows up when someone declines to follow the redirect. Now an admin check runs as the very first statement of the admin entry point, before any routing: anonymous requests are sent to log in, and authenticated non-admins get a 403.

Two bugs in four lines, and the second one hid the first

Seat availability was computed by counting rows in the bookings table and subtracting. The query had no match condition, so every booking ever made was subtracted from every fixture, and selling out one match cost every other match its seats. It also filtered on a seat-type value the insert had stopped writing, so the count matched nothing and always returned zero. Pinned at zero, the cross-match contamination never once showed up in testing; the second bug was the reason nobody noticed the first. Both value formats are still in the recovered data, which is how I found it. And none of it mattered anyway, which is the part that changes the whole finding: the number on screen was decorative. Nothing checked it before writing a booking, and there was no constraint behind it either.

The obvious fix still oversells

Repairing the count isn't the fix. Select the count, compare it to capacity, then insert, and the system still oversells, because under load every request reads the same number before any of them writes. That's easy to assert and worth measuring instead, so the test does: 24 workers race for 4 seats, against all three strategies, on every run. The original with no check at all sells 24. The obvious fix sells 24 too, which is the point. A transaction holding a row lock on the match sells exactly 4. The lock does the work; the check doesn't. I keep the two failing strategies in the test on purpose, because a concurrency test that has never been seen to fail isn't evidence of anything. One detail took measuring too: the lock names the match row specifically, because also locking the joined stadium row would serialise unrelated matches at the same ground. A unique constraint across match, user and seat tier sits behind all of it, on the principle that the application won't always be the only thing writing to that table.

The schema had to be recovered from screenshots

The original shipped no schema file, and the local database it assumed didn't survive. The only surviving record was a set of phpMyAdmin screenshots embedded in the project report. A .docx is a zip archive, so the images can be extracted and tied back to their captions through the document XML. Two defects were visible in the recovered data before I'd read a line of PHP: a seat-type column holding two incompatible vocabularies at once, and a matches table with no date column at all, its kickoff stored as a text field containing strings like '01:00 UKT'. The recovered schema had no foreign keys, no unique constraints and no check constraints anywhere, which is exactly what tables created by hand through a database GUI look like. The rebuilt schema adds them and marks every departure from the original, so it stays honest about which parts are recovered and which are new. One constraint needed a compromise MySQL forced: a check constraint is rejected outright if the same column also carries a foreign key with a referential action, so the keys became restrict-on-update, which cost nothing on a surrogate key that's never updated and let the check survive.

Two of the twenty came from auditing the rebuild

Two of the twenty findings aren't about the 2021 code at all. They came from auditing my own rebuild in 2026, and they belong in the list precisely because a restoration that only audits the original is telling you a comfortable story. The URL helper fell back to the client-supplied Host header whenever a base URL wasn't configured, and it wasn't configured on the deployed service; it only existed as a commented-out line in the example environment file. A request carrying a forged Host header came back with a redirect pointing at it. The fix isn't to set the variable: redirects now emit a path, which the browser resolves against the current request and which therefore can't leave the origin whatever any header claims. That distinction matters more than the bug did, because a configuration fix can be forgotten on the next deploy and a structural one can't. The second was login throttling, the one item on the project's own not-addressed list worth closing instead of accepting, since bcrypt makes a guess expensive for the server but not for the attacker. Failures are counted per email and per address on a rolling window, with the per-address limit deliberately loose because an address isn't a person, and there's no lockout to sit out.

Two things that were only found by looking

The same habit applied to the interface turned up two defects that no amount of using the site would ever surface. Bootstrap ships a rule zeroing the outline on focused buttons, and it out-specifies a plain focus-visible rule, so every button on the site had no keyboard focus ring at all while the stylesheet seemed to define one. It's completely invisible if you use a mouse; I found it by tabbing to a button and reading its computed outline width, not by looking at the page. The other: the scroll reveals use an observer with a negative bottom margin, and an element that only ever comes to rest inside that shrunken strip (the last section above the footer) never reports as intersecting, so it stayed fully transparent with no way for a reader to bring it back.