---
title: "BookMyMovie: Building a Cinema Ticketing Platform"
description: "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."
author: "Syed Ahmer Shah"
date: 2026-09-29
url: https://ahmershah.dev/blogs/bookmymovie-case-study-cinema-ticketing-platform
tags: ["bookmymovie", "case-study", "laravel", "react", "mysql", "system-design", "software-architecture", "web-security"]
series: "The Engineering Logs" (part 9)
---

# BookMyMovie: Building a Cinema Ticketing Platform

_A case study on selling cinema seats online without ever selling the same seat twice, and on everything around that one rule: trailers, QR tickets, Urdu, the back office and the bugs that got through_

![BookMyMovie: Building a Cinema Ticketing Platform — Ahmer at a laptop in a cinema lobby beside the BookMyMovie app on a phone, with popcorn and a Now Showing wall behind](https://ahmershah.dev/blog/bookmymovie-case-study-cinema-ticketing-platform/cover-fbfa49ce.webp)


Buying a cinema ticket in Pakistan still often means calling the cinema, queueing at the counter, or using a site that shows you a seat map, lets you pick a seat, and then tells you at the last step that somebody else already has it. Prices are rarely clear before you commit, premium screens (IMAX, Dolby, recliners) are hard to compare across cinemas, and the ticket is usually a screenshot you hope the usher will accept.

BookMyMovie is my answer to that. It started as my Aptech DISM project in July 2026 and ended up as a full platform that I designed and built alone: the research, the UI, the database, the backend, payments, security, SEO, the admin back office, tests and documentation. You browse films, watch trailers, pick seats on a live map (or from inside a 3D model of the hall), add snacks, book, and get a signed QR e-ticket that still opens with no signal.

This is how it's put together, what went wrong, and what I'd change.

* * *

## The one rule

"Never sell the same seat twice, and never show a price you won't charge."

A shop can apologise for a late parcel. Two people holding tickets for G7 at the 9 PM show is a scene in the lobby. Almost every design decision below comes back to that sentence.

![BookMyMovie's home page: a trailer plays silently behind the featured film, with Book tickets, Watch trailer and Film details, and the next films in the rotation along the bottom.](/blog/bookmymovie-case-study-cinema-ticketing-platform/home.webp)

## Who uses it

- **Moviegoers** browse films and cinemas, watch trailers, compare up to four films, pick seats, mix adult and child tickets, add snacks, apply a coupon, a gift card or loyalty points, and pay online or at the counter. They get a QR e-ticket (with Apple and Google Wallet passes when configured), a watchlist, a waitlist for sold-out shows, a way to split the bill with friends, and "verified booking" reviews.
- **Box-office staff** scan tickets at the door, refund bookings, issue gift cards and look up members.
- **Managers and the owner** run films, showtimes, row prices, coupons, snack stock, reviews, bans, site settings and SEO, and read revenue and occupancy reports. Three roles (owner, manager, box office) are enforced on every admin route, with authenticator-app two-step sign-in.

![A film page: poster, rating and runtime, with Choose a showtime, Add to watchlist, Watch trailer and Compare, and the film's trailer playing behind it.](/blog/bookmymovie-case-study-cinema-ticketing-platform/film.webp)

## Choosing the stack

I wanted an interface that feels like an app, with no full page reloads, and a backend where routing, authentication and validation stay on the server where I can reason about them.

**Laravel 12 and React 19, joined by Inertia 2,** gave me both. Every page is a React component, but there's no public JSON API to secure. A controller validates the request, checks who is asking, and hands props to the page. The layout never unmounts, so the navbar, the seat-hold countdown and the assistant survive navigation.

| Layer | Choice | Why |
|---|---|---|
| Backend | Laravel 12, PHP 8.2 | Routing, Eloquent, queues, rate limiting, mail |
| Bridge | Inertia 2 + Ziggy | SPA navigation with server-side routing and auth |
| Frontend | React 19, strict TypeScript, Tailwind CSS 4 | Components, one set of design tokens for two themes |
| Motion and 3D | Motion, Lenis, Three.js via @react-three/fiber | Page motion, smooth scroll, the poster ring and the 3D hall |
| Database | MySQL 8 / MariaDB | Foreign keys, unique guards, reporting views, row locks |
| Payments | JazzCash, Easypaisa, card, pay at counter | What people in Pakistan actually use |
| Mail and jobs | Resend, database queue | E-mail never blocks a page |

## Architecture

![How a request flows: React pages in the browser, Laravel middleware, controllers and support services, then MySQL, the cache store, media files and external services.](/blog/bookmymovie-case-study-cinema-ticketing-platform/architecture.webp)

The rule I kept coming back to was **thin controllers, and one class for every set of rules that must hold together**. Cancelling a booking is a good example. It has to put the seats back on sale, lower the show's sold count, return the coupon use, refill the gift card, reverse the loyalty points, update the payment and tell the waitlist. A customer cancelling from their phone and an admin issuing a refund go through the same `BookingLifecycle::unwind()`, so the two can never drift apart.

Other decisions that paid off:

- **One source of truth per concern.** `App\Support\Seo` builds titles, Open Graph and JSON-LD for every page. `App\Support\MovieMedia` links artwork and trailers by the film's slug, so adding a trailer is "drop three files in a folder and run a migration".
- **Anything slow goes on the queue.** Receipts, waitlist alerts and web push are jobs, retried on failure.
- **The scheduler does the boring, important work:** expiring 10-minute seat holds every minute, sweeping waitlists and draining the queue where no long-running worker exists.

## The data model

![The booking core of the data model: cities, theaters, screens and seats; movies and shows with row prices; users, carts and cart items; bookings, booking seats, payments and splits; reviews and waitlists.](/blog/bookmymovie-case-study-cinema-ticketing-platform/erd.webp)

The schema is 56 tables and 9 reporting views. The shape is simple once you see it: a **city** has **theaters**, a theater has **screens**, a screen has **seats**. A **show** is one film on one screen at one time. A customer's **cart** holds seats for ten minutes; checkout turns it into a **booking** with one **booking seat** per ticket.

![The cinemas page: six cinemas in four cities, 13 screens and 1,224 seats, all read from the same city, theater, screen and seat tables.](/blog/bookmymovie-case-study-cinema-ticketing-platform/cinemas.webp)

What makes it trustworthy is what the database itself refuses:

- `shows (screen_id, show_date, show_time)` is unique: two films can't be scheduled on the same screen at once.
- `cart_items (seat_id, show_id)` is unique: a seat can sit in only one cart.
- `booking_seats (show_id, seat_id, seat_lock)` is unique, and `seat_lock` is `1` while a ticket is live and `NULL` once it's cancelled. MySQL lets a unique index hold any number of `NULL`s, so a cancelled seat can be sold again, but no bug, script or admin can ever insert a second live ticket for the same seat. I have a test that bypasses every piece of PHP and inserts the row by hand; the database throws.
- `bookings.idempotency_key` is unique: a double-tapped "Confirm" returns the booking it already made.
- `reviews (user_id, movie_id)` and `show_waitlists (show_id, user_id)` are unique: one review per film, one waitlist place per show.

The reporting views (`v_seat_availability`, `v_show_details`, `v_daily_revenue` and six more) keep the seat map and every dashboard chart down to a single query each.

## Booking a seat

Every booking starts from a film's showtimes: one row per cinema, the lowest seat price on each show, the matinée discount in red and a warning once a show is more than 60% sold.

![A film's showtimes: a date strip, city filters and each cinema's shows with the lowest seat price.](/blog/bookmymovie-case-study-cinema-ticketing-platform/film-showtimes.webp)

![The seat map: row-tier prices, best-seat suggestions, a seat map / 3D hall switch, and the selection summary with the total.](/blog/bookmymovie-case-study-cinema-ticketing-platform/seats.webp)

1. **Pick seats.** Each row has its own price (front rows cheapest, "Prime Centre" best, recliners at the back), weekday shows before 5 PM carry a 15% matinée discount, and child tickets are about 25% cheaper (not on recliners). "Best 2 together" finds adjacent seats near the centre, about two-thirds of the way back.
2. **Hold.** Adding seats to the cart runs one transaction: lock the customer, then the show, then the seats, then the cart, check availability, insert the holds. The seats are yours for ten minutes, with a countdown in the navbar.
3. **Check out.** The same lock order again. Prices, coupon limits, gift card balance and points are all re-checked inside the transaction, never trusted from the browser.
4. **Ticket.** The booking gets a QR code carrying an HMAC signature, so the box office can tell a real ticket from an edited one.

Coupons have their own page, with the minimum spend, the cap and the end date printed on each one, so the discount at checkout is never a surprise.

![The offers page: live coupon codes with their minimum spend, maximum discount and end date.](/blog/bookmymovie-case-study-cinema-ticketing-platform/offers.webp)

![Hold, sell, and the only ways out: available, held, confirmed, then completed, no-show or cancelled. Cancelling puts the seat back on sale and tells the waitlist.](/blog/bookmymovie-case-study-cinema-ticketing-platform/lifecycle.webp)

The fixed lock order (user → show → seats → cart) matters as much as the locks. Two requests that take the same locks in a different order can each hold one lock and wait forever for the other. Taking them in one order everywhere means one request simply waits for the other to finish. Deadlocks that still happen are retried up to three times.

I wrote a separate article about the concurrency and security side, including the bugs: [One seat, two buyers: race conditions, IDOR and keys in BookMyMovie](/blogs/one-seat-two-buyers-race-conditions-idor-and-keys-in-bookmymovie).

## The back office

The admin side is where most of the day-to-day work happens, so I rebuilt it around one question: "what does this person need to do right now?" The sidebar is grouped by job (Today, Catalogue, People, Reports, Settings), every item has a one-line hint, and every page opens with a short "How this page works" guide that can be tucked away once you know it.

![The admin overview: the grouped sidebar, the page guide and the 14-day revenue with a 3D skyline of daily takings.](/blog/bookmymovie-case-study-cinema-ticketing-platform/admin-overview.webp)

The overview answers "is tonight going well?" at a glance. The timeline gives each screen its own lane and fills each show's bar by the share of seats sold, with a red line for the time right now. A "Needs you" list links straight to the unpaid bookings and unread messages that want a person.

![Tonight on every screen: one lane per screen, each show filled by the share of seats sold, and a live now-line.](/blog/bookmymovie-case-study-cinema-ticketing-platform/admin-timeline.webp)

Editing a film is a numbered form (basics, price and rating, artwork, home carousel, search engines) with one save bar that says whether anything is unsaved. Every input shows an example value, so nobody has to guess what "slug" means.

![The film editor: a searchable film list on the left and numbered sections with example placeholders on the right.](/blog/bookmymovie-case-study-cinema-ticketing-platform/admin-movie-editor.webp)

Refunds are one button. Behind it, the same `BookingLifecycle::unwind()` a customer's cancellation uses puts the seats back on sale and returns the coupon use, gift card balance and loyalty points, and the customer is e-mailed.

![Bookings and refunds: recent bookings with their payment and booking status and a one-step cancel.](/blog/bookmymovie-case-study-cinema-ticketing-platform/admin-bookings.webp)

Three roles decide who sees what: the owner can do everything, a manager runs films, prices, coupons, members and refunds, and box-office staff see the dashboard and bookings and check tickets at the door. Each person's two-step status is visible at a glance, and banning a member also blocks every IP and browser they used.

![Staff and roles: owner, manager and box office described in plain words, and the staff table with each person's role and two-step status.](/blog/bookmymovie-case-study-cinema-ticketing-platform/admin-staff.webp)

![A member's page: profile, spend and points beside a ban form that can also block their browsers and IP addresses, with a note that shared IPs can catch innocent people.](/blog/bookmymovie-case-study-cinema-ticketing-platform/admin-member.webp)

Taking these screenshots found two layout bugs I had missed: a shared form-field style that stretched labels away from their inputs whenever a row was taller than the field, and a staff table that cut off its last column. Both are fixed; the first was one line of CSS (`align-content: start`) that tidied every form on the site.

## Design

The design brief I set myself was "cinema signage": near-black ink, warm paper, and one electric volt accent, with condensed, variable-width Archivo capitals the way a marquee spells a title. Geist Mono carries anything that is data (times, prices, booking numbers). Edges are hard; the only curves are the screen on the seat map.

A few pieces I'm proud of:

- **Trailers everywhere.** Short silent trailers play behind the home carousel and film pages, with a WebP still first. They only autoplay when motion is allowed, Data Saver is off and the connection is faster than 3G.
- **See it from your seat.** A Three.js model of the hall: sit in any seat, watch the trailer on the screen as the lights dim, and get a view score (distance, how much of your view the screen fills, off-centre angle and neck tilt).
- **Two themes, one set of tokens.** Every colour is a CSS variable, and dark and light themes redefine them. Both pass WCAG AA contrast. They didn't at first (more on that below).
- **Urdu.** A full English/Urdu switch with a right-to-left layout and Noto Naskh Arabic. Film titles, prices and booking numbers stay in Latin script so they match the ticket in your hand.
- **Usher.** A small assistant that answers preset questions ("cheapest seat tonight?", "where can I watch in IMAX?") from live listings. Signed-in customers get personal answers: their next show, their points, which watchlist films are on sale, and a pick based on the genres they book. It has no free-text box on purpose: nothing a visitor types is ever sent anywhere.

![Usher answering "What's good this week?" from live listings, with preset questions underneath and no free-text box.](/blog/bookmymovie-case-study-cinema-ticketing-platform/usher.webp)

![The home page in the light theme: the same tokens redefined as warm paper and ink, with the volt accent kept for the one action that matters.](/blog/bookmymovie-case-study-cinema-ticketing-platform/home-light.webp)

On a phone the whole thing collapses to one column without a single sideways scroll, from 320 px up. I check that with a script rather than by eye.

![The home page on a phone: the trailer carousel with full-width Book tickets and Watch trailer buttons.](/blog/bookmymovie-case-study-cinema-ticketing-platform/m-home.webp)
![BookMyMovie's seat map on a phone.](/blog/bookmymovie-case-study-cinema-ticketing-platform/m-seats.webp)

## SEO, and search for AI

Inertia renders the page body in the browser, so the server has to do the rest of the work for crawlers:

- **Server-rendered head.** Every page's title, description, canonical URL, Open Graph image (generated per film, 1200×1200) and robots rule come from the controller and are written into the HTML before any JavaScript runs.
- **JSON-LD.** Organization, WebSite with a search action, BreadcrumbList, and for each film a `Movie` with its cast, rating and every upcoming `ScreeningEvent` with offers.
- **A generated sitemap** (64 URLs with film images) and **`llms.txt` / `llms-full.txt`**, a plain-text description of the site and its whole catalogue for AI assistants, regenerated from the database with `php artisan seo:generate`.
- **Deliberate `noindex`.** Search results, password reset and the admin area are kept out of search engines. Lighthouse marks those pages down for it, and I accept that.

Lighthouse's accessibility, best practices, SEO and agentic-browsing categories score 100 on every public page (the `noindex` pages lose SEO points by design, and admin login loses a few best-practice points to Google reCAPTCHA's own iframe).

## Security

| Area | What protects it |
|---|---|
| Passwords and sign-in | Argon2id; optional e-mailed 6-digit codes for customers; authenticator-app TOTP with recovery codes for staff; sessions regenerated on sign-in |
| Access control | Every customer query is scoped to the signed-in user, so a guessed booking number is a 404; admin roles are mapped per route and anything not in the map needs the highest role (it fails closed) |
| Bots | Honeypot, minimum fill time, a server-drawn single-use image code, invisible reCAPTCHA v3 with the v2 checkbox only as a step-up, rate limits, and bans that cover the account, every IP and every browser it used |
| Browser | Nonce-based Content-Security-Policy, HSTS, `X-Frame-Options: DENY`, CSRF on every form |
| Payments | Amounts recomputed on the server; JazzCash callbacks verified by signature and amount before anything changes; payment and booking rows locked while settling |
| Tickets | HMAC-signed QR payloads, checked with a constant-time comparison at the door |
| Uploads | Re-encoded to WebP, which also strips EXIF and GPS data |
| Audit | Every data-changing action by staff and customers is logged with before and after values |

## Performance and caching

- **Images** ship in two sizes (`card-sm.webp` 640 px and `card.webp` 1200 px) with `srcset`, explicit dimensions and lazy loading. The server tells the browser to preload the one image that is the largest paint, plus the two text fonts. Converting the original artwork from PNG to WebP took it from about 85 MB to 5.4 MB.
- **Trailers** are VP9 WebM first with an H.264 MP4 fallback. The latest one went from 3.4 MB to 1.1 MB.
- **Five cache layers:** HTTP (hashed assets cached for a year, film art for a month), the service worker (offline tickets, stale-while-revalidate film art), the application cache (home rails and menus served stale while one request rebuilds them under a lock, so an expiry never stampedes the database), a per-request memo, and the database views.

## Where it broke

This is the part I'd want to read in someone else's case study.

**The seat map returned a 500 in production builds.** The seat map page and the lazy-loaded 3D hall both imported a small geometry file. Rollup quietly merged that file into the page's own chunk. The page then had no entry of its own in Vite's manifest, and Laravel answered every visit to the most important page on the site with "Unable to locate file in Vite manifest". Development mode hid it completely. I found it while taking screenshots for this article. The fix was one line (give the shared file its own chunk). The lesson was a test that checks every page is in the manifest, which now runs with the others.

**The service worker never installed.** It pre-cached four files with `cache.addAll()`, one path was wrong, and `addAll` rejects the whole batch if any one file fails. So offline tickets, the feature I talk about most, silently didn't work, and the only symptom was an "Uncaught (in promise)" in the console. Now each file is cached on its own and a missing one is skipped.

**Two captchas at once.** When the real reCAPTCHA keys went in, forms showed the v2 checkbox *and* v3's floating badge, and the server required both. I redesigned it: v3 runs invisibly on every submit, and the checkbox appears only as a step-up when v3's score is too low or it fails to load.

**Unreadable buttons.** The selected "Newest / Most helpful / Highest / Lowest" chip got a light background from one rule and accent-coloured text from another. In the dark theme that was volt on off-white; in the light theme it was olive on near-black. Both were unreadable. The quietest grey text was also below 4.5:1 contrast. I fixed the tokens, not the individual screens.

![Film reviews in the dark theme after the fix: the selected Newest chip is dark on light, the others light on dark.](/blog/bookmymovie-case-study-cinema-ticketing-platform/film-reviews.webp)
![The same reviews in the light theme: rating breakdown, sort chips and verified-booking badges.](/blog/bookmymovie-case-study-cinema-ticketing-platform/film-reviews-light.webp)

**Phones scrolled sideways.** A CSS grid with no column template sizes its one column to its widest content, so on the FAQ a row of category buttons made the page 968 px wide on a 375 px phone. The shared grid component now defaults to `minmax(0, 1fr)`, and a script loads every page at 320, 375, 414, 768 and 1024 px and fails if anything scrolls sideways.

**Old links 404'd.** Seat maps moved from `/movies/{film}/{show}` to `/movies/{film}/book/{show}`, and bookmarked or shared links broke. They now redirect permanently.

## Downsides I'd be honest about in a review

- **No server-side rendering of the page body.** The head, JSON-LD and sitemap cover search engines well, but a crawler that doesn't run JavaScript sees an empty body. Inertia SSR would fix that at the cost of a Node process on the server.
- **Mobile performance is the weak score.** Lighthouse performance on a throttled phone is low on the film pages: Three.js, a trailer and two variable fonts are a lot for a mid-range phone. The 3D scenes are lazy and skipped on slow connections, but the first load is still heavy.
- **One door for most admin writes.** Many admin forms post to a single endpoint with an `_action` field. The role check maps each action to a permission and fails closed, but a single dispatcher is harder to audit than one route per action.
- **"Set the rating by hand."** The admin can show a hand-set star rating for launch week. It's clearly labelled internally, but it's the kind of feature that becomes a dark pattern in the wrong hands. In a real deployment I'd remove it.
- **Short ticket signatures.** The QR carries 64 bits of an HMAC-SHA256 (16 hex characters) to keep the code small enough to scan quickly. Guessing one means brute-forcing an endpoint only staff can reach, but it's still a trade-off, not a free lunch.
- **Payments are sandbox-only.** The JazzCash and Easypaisa flows, signatures and refunds are built and tested, but no real money has moved through them.
- **Tests stop at the server.** 38 PHPUnit feature tests cover booking, payments, bans, roles, waitlists and group bookings on a real MySQL database, and I use scripted Lighthouse and headless-Chrome checks for the front end. There are no committed end-to-end browser tests yet.

## Where it stands

BookMyMovie is finished as a portfolio project. It isn't hosted anywhere; the code lives on GitHub, archived, with everything needed to run it locally in one `migrate --seed`. If I picked it up again for a real cinema, this is the order I'd work in:

1. Turn on Inertia SSR and measure the phone performance again.
2. Replace the admin dispatcher with one route per action.
3. Add Playwright end-to-end tests for "two people, one seat" in two real browsers.
4. Move cache, sessions and the queue to Redis and put the media behind a CDN.
5. Go live with one partner cinema and real payments.

## What it taught me

The interesting work in a booking system isn't the booking form. It's the half-second where two people press "Confirm" on the same seat, the payment callback that arrives twice, the admin who refunds a booking the customer is cancelling at the same moment, and the build tool that quietly moves your page into the wrong file. Most of what I'm proud of in BookMyMovie is invisible when it works.

The code is on [GitHub](https://github.com/ahmershahdev/bookmymovie) under the MIT licence (archived, read-only), with the full data model, locking and caching design and every screen in the README.


---

*Originally published at https://ahmershah.dev/blogs/bookmymovie-case-study-cinema-ticketing-platform — © Syed Ahmer Shah*
