Syed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer Shah — home
● MenuAHMERAHMER
20+Certificates
PHPMERNMySQLSEO

Explore

01About→02Education→03Certificates→04Skills→05Projects→06Services→07Testimonials→08Contact→

Pages

01Blog→02Saved articles→03Privacy Policy→04Terms of Service→
SoundHire me →

Available for new work · --:--:-- PKT

Have an idea?

support@ahmershah.dev
Book a call WhatsApp +92 370 4831994

On this site

  • 01About
  • 02Education
  • 03Certificates
  • 04Skills
  • 05Projects
  • 06Services
  • 07Testimonials
  • 08Contact

Pages

  • Blog
  • Saved articles
  • RSS
  • Privacy Policy
  • Terms of Service
  • llms.txt

Elsewhere · official profiles

26 links

Professional

Work history, code and credentials.

  • in/syedahmershah
  • company/syedahmershah
  • @ahmershahdev
  • syed-ahmer-shah
  • syed-ahmer-shah
  • u/syedahmershah
  • @syedahmershah
  • AWS@syedahmershah

Coding & credentials

Problem solving, verified badges.

  • u/syedahmershah
  • syedahmershah
  • users/syedahmershah
  • learner/syedahmershah

Google & business

The official listings.

  • Google Business Profileg.page
  • Syed Ahmer Shah
  • syedahmershah
  • Beaconssyedahmershah
  • syedahmershah

Social (@ahmershahdev)

Build logs, clips, threads.

  • @ahmershahdev
  • @ahmershahdev
  • @ahmershahdev
  • ahmershahdev
  • @ahmershahdev
  • @bluesky.ahmershah.dev
  • ahmershahdev

Design

Interfaces and visuals.

  • syedahmershah
  • syedahmershah

Direct

  • support@ahmershah.dev

    Support & projects

  • syedahmershahofficial@gmail.com

    Personal

  • +92 370 4831994

    Phone · WhatsApp

  • Hyderabad, Sindh, Pakistan

    Remote-first · Asia/Karachi (UTC+5)

Résumé ↗
SYED AHMER SHAH

© 2026 Syed Ahmer Shah. All rights reserved.

PrivacyTermsRSS
Syed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer Shah — home
● MenuAHMERAHMER
20+Certificates
PHPMERNMySQLSEO

Explore

01About→02Education→03Certificates→04Skills→05Projects→06Services→07Testimonials→08Contact→

Pages

01Blog→02Saved articles→03Privacy Policy→04Terms of Service→
SoundHire me →

Available for new work · --:--:-- PKT

Have an idea?

support@ahmershah.dev
Book a call WhatsApp +92 370 4831994

On this site

  • 01About
  • 02Education
  • 03Certificates
  • 04Skills
  • 05Projects
  • 06Services
  • 07Testimonials
  • 08Contact

Pages

  • Blog
  • Saved articles
  • RSS
  • Privacy Policy
  • Terms of Service
  • llms.txt

Elsewhere · official profiles

26 links

Professional

Work history, code and credentials.

  • in/syedahmershah
  • company/syedahmershah
  • @ahmershahdev
  • syed-ahmer-shah
  • syed-ahmer-shah
  • u/syedahmershah
  • @syedahmershah
  • AWS@syedahmershah

Coding & credentials

Problem solving, verified badges.

  • u/syedahmershah
  • syedahmershah
  • users/syedahmershah
  • learner/syedahmershah

Google & business

The official listings.

  • Google Business Profileg.page
  • Syed Ahmer Shah
  • syedahmershah
  • Beaconssyedahmershah
  • syedahmershah

Social (@ahmershahdev)

Build logs, clips, threads.

  • @ahmershahdev
  • @ahmershahdev
  • @ahmershahdev
  • ahmershahdev
  • @ahmershahdev
  • @bluesky.ahmershah.dev
  • ahmershahdev

Design

Interfaces and visuals.

  • syedahmershah
  • syedahmershah

Direct

  • support@ahmershah.dev

    Support & projects

  • syedahmershahofficial@gmail.com

    Personal

  • +92 370 4831994

    Phone · WhatsApp

  • Hyderabad, Sindh, Pakistan

    Remote-first · Asia/Karachi (UTC+5)

Résumé ↗
SYED AHMER SHAH

© 2026 Syed Ahmer Shah. All rights reserved.

PrivacyTermsRSS
Syed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer ShahSyed Ahmer Shah — home
● MenuAHMERAHMER
20+Certificates
PHPMERNMySQLSEO

Explore

01About→02Education→03Certificates→04Skills→05Projects→06Services→07Testimonials→08Contact→

Pages

01Blog→02Saved articles→03Privacy Policy→04Terms of Service→
SoundHire me →
← Engineering Logs/The Engineering Logs · part 10 of 10

One Seat, Two Buyers: Race Conditions, IDOR and Keys in a Cinema Booking System

What happens in the half-second when two people press Confirm on the same seat, and the other quiet ways a booking system can lie

Written by

Syed Ahmer Shah

Software Engineer

Sep 29, 202611 min read2,507 words—reading now▼
One Seat, Two Buyers: two moviegoers reaching for the same sold seat, each holding the BookMyMovie seat map, beside tags for race conditions, IDOR and keys
Read this articlevoice
Markdown

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:

php
// 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:

php
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:

sql
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.

The booking core of the data model: shows, seats, carts and cart items, bookings and booking seats. The unique keys on cart items, booking seats and bookings are the database's last word on who owns a seat.
The booking core of the data model: shows, seats, carts and cart items, bookings and booking seats. The unique keys on cart items, booking seats and bookings are the database's last word on who owns a seat.

#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:

php
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:

php
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.');
    }
}
The back office's bookings and refunds list. The Cancel button on each confirmed booking runs through the same status lock as a customer's cancellation.
The back office's bookings and refunds list. The Cancel button on each confirmed booking runs through the same status lock as a customer's cancellation.

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.

php
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.

The booking lifecycle: available, held for 10 minutes, confirmed, then completed, no-show or cancelled. Only cancelling puts the seat back on sale.
The booking lifecycle: available, held for 10 minutes, confirmed, then completed, no-show or cancelled. Only cancelling puts the seat back on sale.

#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:

php
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
Staff and roles: owner, manager and box office, each explained in one line, with every person's role and two-step sign-in status.
Staff and roles: owner, manager and box office, each explained in one line, with every person's role and two-step sign-in status.

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_KEY does 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 .env only. 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

  1. Put every "check, then act" on money, seats or stock inside one transaction with FOR UPDATE.
  2. Take locks in one documented order everywhere, and retry deadlocks.
  3. Make the database refuse the impossible state (UNIQUE with a nullable column is a neat trick for "at most one live row").
  4. Give every submit an idempotency key with a unique index.
  5. Move statuses with compare-and-set, and put the allowed transitions in one place.
  6. Use atomic cache locks for jobs and caches that more than one process can run.
  7. Scope every lookup to its owner and return 404 for someone else's object.
  8. Sign anything you hand out that can't be scoped (tickets, share links), and compare signatures in constant time.
  9. Write down what each secret signs or encrypts before you ever rotate it.
  10. 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.

Find me across the web.

Same person, every platform

Work & code

  • ASPortfolioahmershah.dev
  • LinkedInin/syedahmershah
  • GitHub@ahmershahdev
  • AWSAWS Builder Center@syedahmershah
  • Crunchbasesyed-ahmer-shah
  • LinkedIn · Companycompany/syedahmershah

Writing

  • Dev.tosyedahmershah
  • Medium@syedahmershah
  • Hashnode@syedahmershah
  • Substack@syedahmershah
  • HackerNoonu/syedahmershah
  • CLCoderLegionuser/syedahmershah

Reviews & listings

  • Google Knowledge PanelSyed Ahmer Shah
  • Google Business Profileg.page
  • ClClutch
  • TpTrustpilot
  • DRDesignRush
  • TBTechBehemoths

Social

  • YouTube@ahmershahdev
  • Instagram@ahmershahdev
  • TikTok@ahmershahdev
  • Facebookahmershahdev
  • X@ahmershahdev
#bookmymovie#concurrency#mysql#laravel#websecurity#idor#backend#engineeringlogs
ShareXLinkedInRedditWhatsApp

← Previous in series

BookMyMovie: Building a Cinema Ticketing Platform

On this page

  • Why "check, then insert" is wrong
  • Layer 1: row locks, always in the same order
  • The snapshot trap I checked for
  • Layer 2: a unique key that tolerates NULL
  • Layer 3: idempotency for the double tap
  • Layer 4: status locks, so nothing is refunded twice
  • Layer 5: locks that live outside the database
  • IDOR: the booking number is not a password
  • Keys: what breaks when a secret moves
  • When adding keys added a bug
  • What still got through
  • The checklist I'd use again

The Engineering Logs

  1. 01The Edge Latency Lie: Solving Global Consistency
  2. 02Top 10 AI Tools Every Frontend Developer Should Know (2026 Guide)
  3. 03Change One Number. Take Everything.
  4. 04How Databases Work: From Tables to Query Execution
  5. 05The New Era of Software Engineering
  6. 06The Art of Object-Oriented Programming
  7. 07GleanGrid: Building a Pre-Order Marketplace for Farmers Markets
  8. 08The Last Mango Problem: Race Conditions in a Real Marketplace
  9. 09BookMyMovie: Building a Cinema Ticketing Platform
  10. 10One Seat, Two Buyers: Race Conditions, IDOR and Keys in a Cinema Booking System

Up next

Keep reading.

All 48 articles→
  1. 01

    The Last Mango Problem: Race Conditions in a Real Marketplace

    What happens when fifty people try to buy the last item, use a single-use coupon or book a nearly full pickup window at the same instant, the MySQL snapshot detail that almost overbooked a stall, and the quieter races that hide in stock forms, restocks and payments.

    Sep 27, 202610 minThe Engineering Logs

  2. 02

    BookMyMovie: Building a Cinema Ticketing Platform

    How I built BookMyMovie on my own, a Laravel, React and MySQL cinema booking platform with live seat maps, trailers, QR e-tickets that work offline, online payments and an Urdu interface: stack, architecture, data model, design, SEO, security, what broke and what I would change.

    Sep 29, 202617 minThe Engineering Logs

  3. 03

    GleanGrid: Building a Pre-Order Marketplace for Farmers Markets

    How I designed and built GleanGrid on my own, a Laravel and React marketplace where Hyderabad's farmers publish weekly stock and customers reserve it, pay online or at the stall, and collect it: architecture, data model, payments, security and UX.

    Sep 27, 202615 minThe Engineering Logs

Available for new work · --:--:-- PKT

Have an idea?

support@ahmershah.dev
Book a call WhatsApp +92 370 4831994

On this site

  • 01About
  • 02Education
  • 03Certificates
  • 04Skills
  • 05Projects
  • 06Services
  • 07Testimonials
  • 08Contact

Pages

  • Blog
  • Saved articles
  • RSS
  • Privacy Policy
  • Terms of Service
  • llms.txt

Elsewhere · official profiles

26 links

Professional

Work history, code and credentials.

  • in/syedahmershah
  • company/syedahmershah
  • @ahmershahdev
  • syed-ahmer-shah
  • syed-ahmer-shah
  • u/syedahmershah
  • @syedahmershah
  • AWS@syedahmershah

Coding & credentials

Problem solving, verified badges.

  • u/syedahmershah
  • syedahmershah
  • users/syedahmershah
  • learner/syedahmershah

Google & business

The official listings.

  • Google Business Profileg.page
  • Syed Ahmer Shah
  • syedahmershah
  • Beaconssyedahmershah
  • syedahmershah

Social (@ahmershahdev)

Build logs, clips, threads.

  • @ahmershahdev
  • @ahmershahdev
  • @ahmershahdev
  • ahmershahdev
  • @ahmershahdev
  • @bluesky.ahmershah.dev
  • ahmershahdev

Design

Interfaces and visuals.

  • syedahmershah
  • syedahmershah

Direct

  • support@ahmershah.dev

    Support & projects

  • syedahmershahofficial@gmail.com

    Personal

  • +92 370 4831994

    Phone · WhatsApp

  • Hyderabad, Sindh, Pakistan

    Remote-first · Asia/Karachi (UTC+5)

Résumé ↗
SYED AHMER SHAH

© 2026 Syed Ahmer Shah. All rights reserved.

PrivacyTermsRSS