Ticket booking systems have a problem that most web apps do not. Two users can see the same seat as available at the same time. Both click book. Both requests hit your server at the same millisecond. Without careful handling, both succeed — and you have sold the same seat twice.
This is called a race condition. It is one of the trickiest problems in web development.
Why the obvious solution does not work
Your first instinct might be: check if the seat is available, then book it.
// DO NOT DO THIS
$seat = Seat::find($seatId);
if ($seat->status === 'available') {
$seat->update(['status' => 'booked', 'user_id' => $userId]);
}This looks fine. But between the check and the update, another request can sneak in. Both requests read "available", both decide to book, both update. You have a double booking.
Solution 1: Pessimistic locking
Lock the row when you read it. No other request can read or write it until you are done.
DB::transaction(function () use ($seatId, $userId) {
// This locks the row until the transaction ends
$seat = Seat::where('id', $seatId)
->lockForUpdate()
->first();
if (!$seat || $seat->status !== 'available') {
throw new SeatNotAvailableException('Sorry, this seat was just taken.');
}
$seat->update(['status' => 'booked', 'user_id' => $userId]);
Booking::create([
'seat_id' => $seatId,
'user_id' => $userId,
'booked_at' => now(),
]);
});lockForUpdate() tells the database: "I am reading this row and I am about to change it. Nobody else can touch it until I am done." The second request has to wait. When it gets its turn, it reads the seat as "booked" and throws the exception.
This is the safest approach for ticket booking.
Solution 2: Optimistic locking
Instead of locking, use a version number. If the version has changed by the time you try to save, someone else got there first.
$updated = Seat::where('id', $seatId)
->where('status', 'available')
->where('version', $currentVersion)
->update([
'status' => 'booked',
'user_id' => $userId,
'version' => $currentVersion + 1,
]);
if ($updated === 0) {
throw new SeatNotAvailableException('This seat was just taken.');
}This works without locking. The where('version', $currentVersion) clause means the update only succeeds if nobody else has changed the row. If it returns 0 rows updated, you know there was a conflict.
Optimistic locking is better when conflicts are rare. Pessimistic locking is better when conflicts are frequent (like a popular concert going on sale).
The temporary hold pattern
Good UX means holding a seat while the user fills in their payment details. When they start checkout, hold the seat for 10 minutes. If they do not complete payment, release it.
// When user starts checkout
$seat->update([
'status' => 'held',
'held_by' => $userId,
'held_until' => now()->addMinutes(10),
]);
// A scheduled job releases expired holds
Seat::where('status', 'held')
->where('held_until', '<', now())
->update(['status' => 'available', 'held_by' => null, 'held_until' => null]);This is how real ticketing systems work. The seat is "yours" while you are checking out, but it goes back on sale if you abandon the process.




