›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-hold
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Holdables and holders
  • Capacity and slots
  • Acquiring holds
  • Releasing and extending
  • Expiry
  • Hold state

Operations

  • Events and listeners
  • Scheduling expiry
  • Pruning history
  • Concurrency and databases

Reference

  • Configuration
  • Console commands
  • Database schema
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Holdables and holders
  • Capacity and slots
  • Acquiring holds
  • Releasing and extending
  • Expiry
  • Hold state

Operations

  • Events and listeners
  • Scheduling expiry
  • Pruning history
  • Concurrency and databases

Reference

  • Configuration
  • Console commands
  • Database schema
  • Testing
  • Troubleshooting

byrcsc/laravel-hold · 1.x

Acquiring holds.

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.

The two methods

$hold = $seat->acquireHold($user);
$hold = $seat->acquireHoldOrFail($user);
MethodOn successOn a full resource
acquireHold()Holdnull
acquireHoldOrFail()HoldNoAvailableSlotsException

Both take the same three arguments:

public function acquireHold(
    Model $holder,
    ?DateTimeInterface $expiresAt = null,
    array $metadata = [],
): ?Hold

Use 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,
]);

Choosing between them

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.

An omitted expiry is an indefinite hold

$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 hour

An 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

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.

Acquisition always creates

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 holds

Guarding against double submits

A 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.

Acquiring 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.
});

Retries on a concurrency error

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.

What to read next

  • Releasing and extending for giving the slot back and pushing an expiry out.
  • Concurrency and databases for the row lock, the count, and the per-driver notes.
  • Events and listeners for reacting to HoldAcquired after the commit.
PreviousCapacity and slotsNextReleasing and extending
View source

On this page

  1. The two methods
  2. Choosing between them
  3. An omitted expiry is an indefinite hold
  4. Metadata
  5. Acquisition always creates
  6. Guarding against double submits
  7. Acquiring inside your own transaction
  8. Retries on a concurrency error
  9. What to read next