case study
Online Stadium Booking
- defects fixed
- 20
- PHP 8
- MySQL 8
- PDO
- Docker
- Bootstrap 5
My 3rd year engineering project, restored — 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, with a concurrency test that proves it. Live demo runs on a free tier, so give it a moment to wake.
Reading your own code back after five years
This was my third-year engineering project, a PHP and MySQL ticket-booking system, found five years later and read properly rather than remembered 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 preserved unmodified in a legacy folder instead of deleted, so the whole thing reads as a diff and the claims can be checked against the thing they describe. 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, and the other hid itself 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 already 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 entire bug, and it is invisible from the inside. Click through the app as the developer and the behaviour is indistinguishable from working access control, because you always arrive where your role says you should. It only shows up when someone declines to follow the redirect. An admin check now runs as the first statement of the admin entry point, before any routing: anonymous requests are sent to log in, 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, so selling out one match cost every other match its seats. It also filtered on a seat-type value that 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 the first was never noticed. Both value formats are still present in the recovered data, which is how I found it. And none of it mattered anyway, which is the part that reframes the whole finding: the number on screen was decorative. Nothing consulted it before writing a booking, and there was no constraint behind it either.
The obvious fix still oversells
Repairing the count is not 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 is 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. The two failing strategies stay in the test deliberately, because a concurrency test that has never been observed to fail is not evidence of anything. One detail took measuring too: the lock names the match row specifically, because locking the joined stadium row as well would serialise unrelated matches happening at the same ground. A unique constraint across match, user and seat tier sits behind all of it, on the principle that the application will not 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 did not 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 are extractable and can be tied back to their captions through the document XML. Two defects were visible in the recovered data before I had 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, 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 is never updated and let the check survive.
Two of the twenty came from auditing the rebuild
Two of the twenty findings are not about the 2021 code at all. They came out of auditing the 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 was not configured, and it was not configured on the deployed service, existing only 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 is not to set the variable: redirects now emit a path, which the browser resolves against the current request and which therefore cannot 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 cannot. 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 and not for the attacker. Failures are counted per email and per address on a rolling window, the per-address figure deliberately loose because an address is not a person, with 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 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 appeared to define one. It is completely invisible to anyone using a mouse; it was found by tabbing to a button and reading its computed outline width rather than 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 stays fully transparent with no way for a reader to recover it.