A capacity limit that cannot be exceeded
Two parents click “Book” in the same second and one place is left. How to prevent overbooking in Laravel with a transaction and a row lock.
The most common bug in booking systems: checking free capacity and saving the booking are two separate queries. A second request can slip in between them. When bookings open for a popular camp session, that is not a theory but a certainty.
The fix: a transaction and lockForUpdate
<?php
use App\Exceptions\CampSessionFullException;
use App\Models\Booking;
use App\Models\CampSession;
use Illuminate\Support\Facades\DB;
function reserve(int $sessionId, array $data): Booking
{
return DB::transaction(function () use ($sessionId, $data) {
// Lock the session row until the transaction ends.
// A second request waits here instead of counting the same places.
$session = CampSession::whereKey($sessionId)
->lockForUpdate()
->firstOrFail();
$taken = $session->bookings()
->whereIn('status', ['pending', 'paid'])
->count();
if ($taken >= $session->capacity) {
throw new CampSessionFullException($session);
}
return $session->bookings()->create($data + ['status' => 'pending']);
}, attempts: 3); // retry on deadlock
}
Why it works
SELECT … FOR UPDATE in InnoDB locks the session row. A second request that wants the same row waits until the first transaction finishes, and only then counts the places, now including the new booking.
What else
pendingbookings occupy a place too. A separate job cancels unpaid ones after the payment deadline and frees the place.- The message for the parent must never read “Error 500”.
CampSessionFullExceptionmaps to a clear explanation and a waiting-list offer. - A limit checked in JavaScript is only a hint for the user. The server tells the truth.