Ayman, home

Case studies / One seat, one purchase

One seat, one purchase.

A cinema booking system where two people who pick the same seat at the same time cannot both end up paying for it.

role
Team final project, extended on my own
year
2025
stack
PHP 8.4Laravel 12MySQLStripeJavaScriptPHPUnit
links
Code ↗Demo ↗
CineFlow home page with the featured film and a button to see showtimes
// context

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.

// problem

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.

// decisions

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.
Seat map with states shown by shape and text, and the hold countdown
Seat map with states shown by shape and text, and the hold countdown
How to buy: Stripe payment in test mode and a ticket with a QR code
How to buy: Stripe payment in test mode and a ticket with a QR code
// result

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

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.