Context.
It was the final project of my diploma, built as a team with Ismael Achamrouk and Danna Guevara. I later picked it up on my own to modernise it: deployment, CI, Stripe payments, an accessible seat map and a new design.
The purchase confirmation, with its pessimistic locking, stays as the team left it. I wanted a project where concurrency was a real problem, not just a list of films.
The problem.
Minutes go by between choosing a seat and finishing the payment. If someone else picks it in that window, both could buy it.
What I decided, and why.
01
Hold the seat when it is picked
- what
- Picking a seat creates an 8-minute temporary hold tied to that person. Everyone else sees it as taken straight away, thanks to AJAX polling.
02
Confirm with pessimistic locking
- what
- The purchase is confirmed inside a transaction with SELECT … FOR UPDATE (lockForUpdate) and up to 3 attempts. Two simultaneous purchases are serialised and the second one fails cleanly.
03
Price by ticket type
- what
- The price starts from the session base price and applies a factor per type: adult, reduced, family and over-65. It is calculated on the server, in whole cents: the browser sends no amounts.
04
Holds that work the same for guests and accounts
- what
- A seat held by a guest showed as free to a signed-in user, who could take it. Now the hold is respected for both.
- why
- A hold that only counts for some people does not protect the seat.
05
Stripe confirms, not the browser
- what
- Payment is Stripe Checkout in test mode. The booking is not confirmed when the browser comes back: a signed webhook confirms it. Each event is processed once, even if it arrives twice.
- why
- If the booking depended on the browser coming back, closing the tab would leave a charge with no booking.
06
Seats held while paying
- what
- While someone pays, their seats stay held: the hold is extended until the payment expires. If the payment expires, fails or is cancelled, the seats are released straight away.
- why
- Without extending the hold, someone else could take the seat during payment. Without releasing it, an abandoned payment would leave the seat taken.
07
Refund if another purchase wins
- what
- If another purchase takes the seat an instant earlier, the payment is refunded automatically.
- why
- The seat is already sold. Returning the money without being asked avoids a charge with no ticket.
08
A seat map you can use with the keyboard
- what
- The map is a grid you drive with the arrow keys, Home, End and Space. Each seat shows its state with shape, icon and text, not colour alone. A screen reader announces which seat you pick and how many you have left, and the hold countdown is announced at 5, 2 and 1 minutes and at 30 seconds.
- why
- If the map only worked with a mouse or with colour, some people could not pick a seat.


Result.
- 76 PHPUnit tests (up from 38), none skipped, including race conditions, hold expiry and a webhook with an invalid signature or a repeated event.
- Two browser tests on every PR: one with axe walks 13 screens at 390 and 1440 px and the other drives the seat map with the keyboard. Mobile Lighthouse gives 100 for accessibility on the home page, listings and film page.
- Tickets with signed QR codes. Without Stripe keys, the demo uses a simulated payment.
What I learned.
- A payment has more states than paid and not paid: abandoned, expired, repeated and late. The two bugs the final review found were in those edges.
- A webhook can arrive twice. If it is not idempotent, the second time has a cost.
- Releasing a seat for a single instant is enough for someone else to take it.
