Delivery-slot capacity booking
Reserve a delivery time slot with a single conditional UPDATE that cannot oversell, plus a matching release when an order is cancelled.
LaravelDatabasebookingcapacityatomicdelivery
Code
<?php
namespace App\Delivery;
use App\Models\DeliverySlot;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class SlotBooking
{
/**
* Claim one place in a slot. The WHERE clause is the lock: the row only
* updates while booked < capacity, so two buyers can never take the last place.
*/
public function reserve(int $slotId, int $orderId): void
{
DB::transaction(function () use ($slotId, $orderId) {
$claimed = DeliverySlot::query()
->whereKey($slotId)
->where('is_open', true)
->where('starts_at', '>', now()->addMinutes(30)) // cut-off before the window
->whereColumn('booked', '<', 'capacity')
->increment('booked');
if ($claimed === 0) {
throw ValidationException::withMessages([
'slot' => 'That time slot is full or no longer available. Please pick another.',
]);
}
DB::table('orders')->where('id', $orderId)->update(['delivery_slot_id' => $slotId]);
});
}
/** Give the place back (cancellation or reschedule). Never goes below zero. */
public function release(int $orderId): void
{
DB::transaction(function () use ($orderId) {
$slotId = DB::table('orders')->where('id', $orderId)->lockForUpdate()->value('delivery_slot_id');
if (! $slotId) {
return; // nothing reserved: releasing twice is harmless
}
DeliverySlot::whereKey($slotId)->where('booked', '>', 0)->decrement('booked');
DB::table('orders')->where('id', $orderId)->update(['delivery_slot_id' => null]);
});
}
/** Open slots for a day, with how many places remain. */
public function available(string $date)
{
return DeliverySlot::query()
->whereDate('starts_at', $date)
->where('is_open', true)
->whereColumn('booked', '<', 'capacity')
->selectRaw('*, capacity - booked as remaining')
->orderBy('starts_at')
->get();
}
}
How it works
What it solves
Delivery windows have limited capacity, for example 20 orders between 10:00 and 12:00. The usual "read the count, compare, then write" approach lets two shoppers both see one place left and both book it. You end up with 21 orders for a 20 order slot.
How it works
reserve() does the check and the write in a single SQL statement. The query increments booked only where the slot is open, starts later than a 30 minute cut-off and booked < capacity. The database applies that condition atomically, so exactly one concurrent request can take the final place. increment() returns the number of affected rows: zero means the slot was full, closed or too soon, and the method throws a validation error with a friendly message. No explicit locks or retries are needed.
When the claim succeeds, the order is linked to the slot in the same transaction, so the counter and the order cannot disagree.
release() is the mirror image. It reads the order's slot id with a lock, decrements only if booked is above zero, and clears the link. Calling it twice does nothing the second time. available() lists open slots with the remaining places for the checkout screen.
Adapting it
- Add a per-slot fee column and copy it onto the order at reservation time.
- Block reservation for postcodes outside the slot's service area.
- Use a separate counter for express slots with their own capacity.
Pitfalls
- Always release on cancellation, failed payment and expiry of unpaid carts, otherwise capacity leaks.
- Compare times in one timezone, ideally UTC in storage.
How to use it
- Create
delivery_slots(starts_at, ends_at, capacity, booked default 0, is_open) and adddelivery_slot_idtoorders. - Put
SlotBooking.phpinapp/Delivery/. - At checkout:
app(SlotBooking::class)->reserve($request->integer('slot_id'), $order->id); - On cancellation:
app(SlotBooking::class)->release($order->id); - List choices with
app(SlotBooking::class)->available('2026-03-14').
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.