Seat G7 at the 9 PM show. Two people have it open on their phones. Both tap it, both see it turn yellow, both press Confirm within the same half-second.
Exactly one of them should get a ticket. The other should see "that seat was just taken, here are the two next to it". Not an error page, not a refund e-mail tomorrow, not two people in the lobby holding the same seat.
I built BookMyMovie, a cinema booking platform, on my own with Laravel, React and MySQL. This is how it handles the moments where people collide, what it does about the quieter security problems (guessable IDs, secret keys, bots), and the bugs that still got through.
Why "check, then insert" is wrong
The obvious code looks like this:
// Looks fine. Isn't.
if (Seat::isAvailable($showId, $seatId)) {
BookingSeat::create(['show_id' => $showId, 'seat_id' => $seatId, ...]);
}Two requests run the if at the same moment. Both see "available", because neither has inserted anything yet. Both insert. You've sold the seat twice, and no test that runs one request at a time will ever notice.
Every fix below is a different way of making "check" and "act" one indivisible step.
Layer 1: row locks, always in the same order
Holding seats and checking out both run inside a transaction that starts by locking rows with SELECT … FOR UPDATE:
DB::transaction(function () use ($seatIds, $data) {
// 1. The customer: serialises this person's double clicks and second tab.
User::query()->whereKey(Auth::id())->lockForUpdate()->firstOrFail();
// 2. The show: everyone buying seats for this show now queues here.
$show = Show::query()->lockForUpdate()->find($data['show_id']);
// 3. The seats themselves, then 4. the cart.
Seat::query()->whereIn('id', $seatIds)->lockForUpdate()->count();
$cart = Cart::query()->where('user_id', Auth::id())->lockForUpdate()->first();
// Only now: is every seat still available?
// ...insert the holds...
}, 3);Two details carry most of the weight.
The order never changes. Adding seats and checking out, the two paths that race for the same seats, take user, then show, then seats, then cart. The shorter paths take fewer locks but never reverse that order: removing a seat locks only the cart, and cancelling locks the booking and then the show, which nothing locks the other way round. If one request locked the show and then the cart while another locked the cart and then the show, each could end up holding the lock the other needs, and MySQL would have to kill one. With one order everywhere, the second request just waits its turn.
The 3 at the end retries the whole transaction up to three times if MySQL still reports a deadlock (it can, under odd index-gap locking). The customer never sees it.
The snapshot trap I checked for
MySQL's default isolation level, REPEATABLE READ, has a trap I wrote about in The Last Mango Problem: a plain SELECT inside a transaction reads from a snapshot taken at the transaction's first plain read, not from the latest data. If that snapshot is taken before you wait for a lock, you can wait patiently for the other buyer to finish and then read a picture of the world from before they bought the seat.
In BookMyMovie the availability check reads a view, v_seat_availability, with a plain SELECT. It's safe here only because of when the snapshot is taken. The locking reads at the top (FOR UPDATE) don't create a snapshot. The first plain read happens after the show lock is held. And every transaction that sells a seat for this show must hold that same show lock, so by the time we read, any competing sale has either committed (and is in our snapshot) or hasn't started. Move one innocent-looking exists() query above the show lock and that guarantee is gone. That is exactly why the next layer exists.
Layer 2: a unique key that tolerates NULL
Locks are code, and code has bugs. So the database has the final word:
UNIQUE KEY booking_seats_live_seat_unique (show_id, seat_id, seat_lock)seat_lock is 1 while a ticket is live and NULL once the booking is cancelled. In MySQL a unique index allows any number of rows that contain NULL. So:
- a cancelled ticket (
seat_lock = NULL) doesn't block the seat being sold again; - a second live ticket for the same seat at the same show is impossible, whatever the PHP does.
I have a test that skips every check in the application and inserts the duplicate row directly. MySQL throws, and the test passes only when it does. The cart has the same kind of guard, UNIQUE (seat_id, show_id) on cart_items, so two carts can't hold one seat either.

Layer 3: idempotency for the double tap
Networks retry. People double-tap. A slow checkout gets a second press of Confirm. Each checkout form carries a UUID generated when the page loads:
if ($existing = Booking::query()
->where('idempotency_key', $data['idempotency_key'])
->where('user_id', Auth::id())
->first()) {
return redirect()->route('user.booking.show', $existing->booking_number);
}bookings.idempotency_key is unique, so even two submits that both pass this check at the same instant can't both insert: the second fails on the key and the customer lands on the booking the first one made.
Layer 4: status locks, so nothing is refunded twice
The cancellation path had a hole I found while writing tests for it. A customer cancels from the app while an admin refunds the same booking from the back office. Both callers locked the booking and checked "is it still confirmed?", but that check lived in each caller, not in the code that does the unwinding. Any future caller that forgot it (a scheduled job, a new admin button) could run the unwind a second time: seats released twice, the coupon returned twice, the gift card refilled twice.
So the booking's status now moves through a compare-and-set:
public const STATUS_TRANSITIONS = [
'confirmed' => ['completed', 'cancelled', 'no_show'],
'completed' => [], 'no_show' => [], 'cancelled' => [],
];
public function claimStatus(string $status, array $extra = []): void
{
$from = (string) $this->booking_status;
if (! $this->canMoveTo($status)) {
throw new StaleStatusException("Cannot move from {$from} to {$status}.");
}
$claimed = static::query()
->whereKey($this->getKey())
->where('booking_status', $from) // only if nobody changed it
->update(['booking_status' => $status, ...$extra]);
if ($claimed !== 1) {
throw new StaleStatusException('Changed while it was being updated.');
}
}
BookingLifecycle::unwind() calls claimStatus('cancelled', …) first, before it touches a single seat or balance. If the row isn't confirmed any more, the UPDATE matches nothing, the exception rolls the whole transaction back, and nothing is refunded. The test for it cancels a booking, then tries again with a stale copy of the model that still says "confirmed". It must be refused, and the show's sold count must not move.
The same guarded-update idea appears in smaller places: "tell the watchlist this film is open for booking" is claimed with UPDATE … WHERE booking_opened_notified_at IS NULL, so it happens exactly once even if two admins save the film at the same moment, and the show's sold count is only lowered WHERE booked_seats >= n, so it can never go negative.
Layer 5: locks that live outside the database
When seats come back (a cancellation, or a 10-minute hold that ran out), a job tells that show's waitlist, first come, first served. The scheduler sweeps every minute and a cancellation dispatches the job straight away, so the same show's job was often queued twice.
class ProcessShowWaitlist implements ShouldBeUnique, ShouldQueue
{
public int $uniqueFor = 120;
public function uniqueId(): string { return (string) $this->showId; }
public function middleware(): array
{
return [(new WithoutOverlapping('show-'.$this->showId))->releaseAfter(10)->expireAfter(120)];
}
}ShouldBeUnique drops a second dispatch while one is waiting; WithoutOverlapping stops two workers running the same show at once. Both use atomic cache locks, not database rows. Inside the job, the waitlist rows are still locked with FOR UPDATE and each person is marked notified in the same transaction, so nobody gets two "seats just opened" pushes.
The home page's cached rails use the same idea against a different problem. With Cache::remember, the moment the cache expires every visitor rebuilds it at once (a cache stampede). Cache::flexible keeps serving the stale copy while exactly one request, holding a lock, rebuilds it.

IDOR: the booking number is not a password
Insecure Direct Object Reference is the bug where changing an ID in the URL shows you someone else's data. Booking numbers look like BM-2026-7KQ2X9TD. They're random, but "hard to guess" is not access control, because booking numbers get screenshotted, forwarded and printed.
So every customer-facing lookup is scoped to the person asking:
private function userBooking(string $number): Booking
{
return Booking::query()
->where('user_id', Auth::id()) // the whole point
->where('booking_number', $number)
->firstOrFail(); // someone else's booking: 404, not 403
}It returns 404 rather than 403 on purpose: a 403 would confirm the booking exists. A test signs in as a second customer and tries to view and cancel the first customer's booking; both must 404.
The places that have to work without an account get a different treatment:
| Thing | How it's protected |
|---|---|
| Split-the-bill share links | A 40-character random token per share, unique in the database. Knowing the booking number gets you nothing |
| Ticket QR codes | The booking number plus an HMAC-SHA256 signature over it, keyed with the app key. The box office recomputes it and compares with hash_equals(), so an edited booking number fails |
| Gift card balance check | The code is random (BMM-XXXX-XXXX), and the endpoint is limited to 10 checks a minute |
| Admin member pages | Numeric IDs, but only reachable by signed-in staff, and every admin route maps to a role; routes missing from the map require the highest one |

That last point is the rule I'd pass on: authorisation should fail closed. In BookMyMovie's admin, a new route that nobody remembered to classify needs the highest role, instead of being open to every staff account.
Keys: what breaks when a secret moves
Most security incidents in small apps aren't clever. They're a key in the wrong place or a key that changed. Things I learned the hard way in this project:
APP_KEYdoes more than encrypt sessions. It signs every ticket QR code and encrypts staff two-step secrets. Rotating it invalidates every ticket already issued and locks every staff member out of 2FA. That's written down in the security policy now, not just in my head.- Truncating a signature is a trade-off. The ticket signature is the first 16 hex characters (64 bits) of the HMAC, to keep the QR code small enough to scan quickly in a dark lobby. That's plenty against guessing, because checking a signature needs a staff session, but I'd rather say it out loud than pretend 64 bits is 256.
- Keys belong in
.envonly. reCAPTCHA, JazzCash, Easypaisa, wallet certificates and the web-push VAPID pair are read from configuration and nowhere else. On XAMPP for Windows, generating VAPID keys failed until OpenSSL was pointed at its config file; the command that generates them now sets that itself. - Timing is a leak too. When an admin e-mail doesn't exist, the login still hashes a throwaway password, so "unknown e-mail" and "wrong password" take the same time and don't reveal which staff accounts exist.
When adding keys added a bug
The most embarrassing key problem was the opposite of a leak. With no reCAPTCHA keys, local development used Google's public test keys and everything looked fine. When the real v2 and v3 keys went in, every form showed the v2 checkbox and v3's floating badge, and the server demanded both tokens. Two captchas on a login form is a great way to lose customers.
The redesign: when a v3 key exists, v3 runs invisibly on every submit and the checkbox is hidden. If v3's score is too low, or its token didn't load, the server remembers that for this form in the session and fails with "one more check". The next render shows the checkbox, and that submit is judged by the checkbox alone. One Google check at a time, and the stricter one only for traffic that earned it.
What still got through
Everything above is about data. The worst bug I shipped in this project had nothing to do with data.
The seat map page and the lazy-loaded 3D model of the hall both imported one small geometry file. In a production build, Rollup merged that file into the seat map's own chunk, which made the page a "shared chunk" with no entry of its own in Vite's manifest. Laravel looks pages up in that manifest by name, so every visit to the seat map (the page every booking goes through) returned a 500. The development server builds nothing ahead of time, so it never showed up there. All the locks in this article were protecting a page nobody could open.
I found it while taking screenshots for these articles, fixed it by giving the shared file its own chunk, and added a test that fails if any page is missing from the manifest. The general lesson: your concurrency tests are only as good as your deployment tests. A race-proof checkout behind a broken build is still a broken checkout.
The checklist I'd use again
- Put every "check, then act" on money, seats or stock inside one transaction with
FOR UPDATE. - Take locks in one documented order everywhere, and retry deadlocks.
- Make the database refuse the impossible state (
UNIQUEwith a nullable column is a neat trick for "at most one live row"). - Give every submit an idempotency key with a unique index.
- Move statuses with compare-and-set, and put the allowed transitions in one place.
- Use atomic cache locks for jobs and caches that more than one process can run.
- Scope every lookup to its owner and return 404 for someone else's object.
- Sign anything you hand out that can't be scoped (tickets, share links), and compare signatures in constant time.
- Write down what each secret signs or encrypts before you ever rotate it.
- Test the build you deploy, not just the code you wrote.
The code, including the tests that try to sell one seat to two customers and to cancel one booking twice, is on GitHub. The case study covers the rest of the project: the stack, design, SEO and what I'd change.



