›
byrcsc/laravel-hold · 1.x
Acquire one slot by returning null on refusal or throwing an exception.
Acquisition is the only operation that has to be atomic, and it is the only one that opens a transaction. Both methods live on the holdable.
$hold = $seat->acquireHold($user);
$hold = $seat->acquireHoldOrFail($user);| Method | On success | On a full resource |
|---|---|---|
acquireHold() | Hold | null |
acquireHoldOrFail() | Hold | NoAvailableSlotsException |
Both take the same three arguments:
public function acquireHold(
Model $holder,
?DateTimeInterface $expiresAt = null,
array $metadata = [],
): ?HoldUse the named form, because the second argument is easy to misread positionally:
$hold = $seat->acquireHold($user, expiresAt: now()->addMinutes(15), metadata: [
'reason' => 'checkout',
'order_ref' => $orderRef,
]);Reach for acquireHold() when a refusal is a normal outcome you are going to
render, which is most of the time:
$hold = $seat->acquireHold($user, expiresAt: now()->addMinutes(15));
if ($hold === null) {
return back()->withErrors('That seat was taken a moment ago.');
}Reach for acquireHoldOrFail() when a refusal is exceptional, or when you are
inside a job or a transaction where there is nowhere to put a null:
use ByRcsc\LaravelHold\Exceptions\NoAvailableSlotsException;
try {
$hold = $seat->acquireHoldOrFail($user);
} catch (NoAvailableSlotsException $e) {
report($e);
$e->holdable; // the Seat it refused, carried rather than only named
}The exception extends RuntimeException. Its message names the holdable class
and key, and reads unsaved when the model has no key yet.
$expiresAt defaults to null, and null means the hold has no expiry at all.
$seat->acquireHold($user); // indefinite
$seat->acquireHold($user, expiresAt: now()->addHour()); // expires in an hourAn indefinite hold blocks its slot until something releases it. No clock will
ever free it, and hold:prune will never delete it. See
expiry.
Metadata is a plain array, stored in a JSON column and cast back to an array on read.
$hold = $seat->acquireHold($user, metadata: ['reason' => 'checkout']);
$hold->metadata; // ['reason' => 'checkout']Omitting it stores an empty array, not null. A release can merge more context into it later.
acquireHold() is an attempt to create a new hold, never a lookup. It does
not return the caller's existing hold, and the same holder may hold the same
resource as many times as capacity allows. That is how you hold three seats.
$row = Event::find(1); // capacity 3
$a = $row->acquireHold($user);
$b = $row->acquireHold($user);
$a->is($b); // false, two separate holdsA double-submitted form is two requests. Both would acquire, and on a resource with spare capacity both would succeed. Check first:
$hold = $seat->activeHoldFor($user)
?? $seat->acquireHold($user, expiresAt: now()->addMinutes(15));activeHoldFor() returns the newest active hold for that holder, ordered by
primary key rather than by created_at, because two holds can share a
timestamp to the second but never share a key. It returns null when the holder
has no active hold, and it never returns another holder's hold.
This is a convenience, not a lock. Two truly simultaneous requests can both read null and both acquire. If one hold per holder is a hard invariant, add a unique index that expresses it, or check
activeHoldFor()inside your own transaction.
Acquisition opens a transaction of its own. Nested inside yours, it joins yours, and two things follow.
The HoldAcquired event waits for your commit. The package registers it
with afterCommit(), so a listener never reads a row your outer transaction
has not written yet.
Your rollback takes the hold with it. The row and the event both disappear, which is the behaviour you want when the surrounding work fails.
DB::transaction(function () use ($seat, $user, $order) {
$hold = $seat->acquireHoldOrFail($user);
$order->update(['hold_id' => $hold->id]);
// A throw here rolls back the order and the hold,
// and HoldAcquired never fires.
});Acquisition attempts the transaction up to three times on a database concurrency error. A retry is not a second chance at the slot: the replay re-reads under a fresh lock and returns null as readily.
It exists because two of the three supported engines can abort a correctly written transaction purely for contention. InnoDB picks a deadlock victim, and SQLite aborts a writer whose read snapshot went stale. Without the replay, those callers would see an exception where the honest answer is that someone else took the slot.
Once the replays are spent, the underlying exception surfaces. See concurrency and databases.
HoldAcquired after the
commit.