Travel Technology
Building Scalable Booking Engines with Laravel
How we architected a booking engine that handles thousands of concurrent reservations without breaking a sweat.
4 min readVeetek team
A travel booking engine looks simple from the outside: search, pick, pay, confirm. Underneath, it has to deal with problems most web applications never meet. Seat inventory on a charter flight is finite and shared between many agencies. Supplier APIs time out at the worst possible moment. Prices change between search and checkout. And when a popular departure opens for sale, hundreds of agents can try to book the same last few seats within the same minute.
When we built Vee Travel on Laravel, we treated these as the core design problems rather than edge cases. This article covers the decisions that mattered most and that we would make again.
Separate search from booking
Search and booking have very different load profiles. Search is read-heavy, bursty and tolerant of slightly stale data. Booking is write-heavy, lower volume and must be correct every time. Running both through the same code paths and the same database connections means a spike in searches can slow down the bookings that actually earn money.
We split them early. Search results are assembled from supplier responses and cached in Redis with short lifetimes, keyed by route, dates, passenger mix and agency. Booking requests go through a separate set of controllers and queue workers, and they always re-check price and availability with the source before committing anything. A cached fare is a hint for the customer, never a promise.
Cache search results for minutes, not hours, and include the agency in the cache key so pricing rules stay correct.
Re-price on the booking step and show the customer a clear message if the fare changed.
Give booking workers their own queue so a backlog of search jobs never delays a confirmation.
Hold inventory with database locks, not hope
Own inventory, such as charter seats and hotel allotments, is where overbooking happens. Two requests read "2 seats left", both decide they can book 2, and the flight is now oversold by 2. The fix is to make the read and the decrement a single atomic step.
In Laravel that means a transaction with a row lock on the allotment record. The lock is held only for the few milliseconds it takes to check and decrement, so contention stays low even on busy departures.
DB::transaction(function () use ($allotmentId, $seats, $booking) {
$allotment = Allotment::whereKey($allotmentId)
->lockForUpdate()
->firstOrFail();
if ($allotment->available < $seats) {
throw new InsufficientInventory($allotment);
}
$allotment->decrement('available', $seats);
$booking->holds()->create([
'allotment_id' => $allotment->id,
'seats' => $seats,
'expires_at' => now()->addMinutes(15),
]);
});
Holds expire. A scheduled job releases any hold that has not turned into a paid booking, and it uses the same lock so a release and a new booking cannot collide.
Treat every supplier call as something that can fail
GDS sessions expire, aggregators return partial results, and airline direct connections occasionally answer after the customer has given up. A booking engine that assumes success will eventually create a booking in its own database that does not exist at the supplier, or the reverse.
We model each supplier booking as a state machine: pending, requested, confirmed, failed, or unknown. "Unknown" is the important one. When a request times out we do not guess. We queue a retrieve call to ask the supplier what actually happened, and we only mark the booking confirmed once we have a record locator back.
Set explicit timeouts on every HTTP client, per supplier, based on how that supplier really behaves.
Store the raw request and response for each supplier call so support staff can see what happened.
Never retry a booking request blindly. Retry the status check instead.
Customers double-click. Mobile networks drop and resend. Agents open two tabs. Every booking request carries an idempotency key generated when the checkout page loads. If the same key arrives twice, the second request returns the result of the first instead of creating a second booking. A unique index on the key in the database is the final safety net, so even a race between two workers cannot produce a duplicate.
Scale the parts that need it
Once these foundations were in place, scaling became mostly an operational task. Stateless web servers sit behind a load balancer on AWS. Queue workers scale with queue depth, with separate pools for search, booking and notifications. Read replicas take reporting traffic away from the primary database. None of this is unusual, and that is the point: the hard problems were solved in the domain model, so the infrastructure can stay ordinary.
What we would tell another team
Start with correctness on inventory and supplier state, because those mistakes cost real money and real trust. Keep search fast and disposable, keep booking slow and careful, and log enough that you can explain any booking to an agent on the phone. Laravel gives you transactions, locks, queues and caching out of the box. The work is in deciding where each one belongs.