›
byrcsc/laravel-hold · 1.x
Diagnose failed acquisitions, extensions, key configuration, and concurrency errors.
Something is still holding a slot. Check what, rather than what you expect:
$seat->holdCapacity(); // is the capacity what you think?
$seat->availableSlots(); // 0 means every slot is taken
$seat->activeHolds()->get(); // this is what is taking themThe usual causes:
expiresAt blocks its slot
until something releases it. No clock frees it and hold:prune never deletes
it.holdCapacity() returns 1 unless overridden, so a
resource you meant to be slot-style is exclusive.availableSlots() re-counts, but if you are
reading $seat->activeHolds as a property it is answering from the relation
cache. Call $seat->load('activeHolds').acquireHold() always attempts to create. It is not idempotent, and on a
resource with spare capacity two requests both succeed.
Guard it:
$hold = $seat->activeHoldFor($user)
?? $seat->acquireHold($user, expiresAt: now()->addMinutes(15));That closes the ordinary double submit. It is not a lock, so two genuinely simultaneous requests can still both read null. If one hold per holder is a hard invariant, express it with a unique index.
Trait method ByRcsc\LaravelHold\Concerns\Holdable::holds has not been applied
as Locker::holds, because of collision with
ByRcsc\LaravelHold\Concerns\HasHolds::holdsA model uses both traits without telling PHP which side keeps the plain names. Add the resolution block:
use Holdable, HasHolds {
Holdable::holds insteadof HasHolds;
Holdable::activeHolds insteadof HasHolds;
HasHolds::holds as holdsAsHolder;
HasHolds::activeHolds as activeHoldsAsHolder;
}Hold::query()->where('status', 'active')->get(); // always emptyThere is no status column. Status is computed from the timestamps on every
read. Use the scopes:
Hold::query()->active()->get();
Hold::query()->released()->get();
Hold::query()->expired()->get();Unsupported hold key type [bigint]. Use int, uuid, ulid, or string.holdable_key_type or holder_key_type is set to something the migration does
not recognise. The four accepted values are int, uuid, ulid, and
string. See configuration.
If the migration already ran with the wrong type, changing config alone does not fix the table. Write a migration that alters the column, and convert the values in it.
A losing acquirer surfaced a lock error rather than a clean null. SQLite has
no row locks and serializes writers itself, which is correct but noisy under
concurrency.
Configure the connection:
'sqlite' => [
// ...
'journal_mode' => 'WAL',
'busy_timeout' => 5000,
'transaction_mode' => 'IMMEDIATE',
],transaction_mode applies on PHP 8.4 and above. See
concurrency and databases.
Acquisition attempts the transaction up to three times on a database concurrency error. An exception that reaches you has exhausted those attempts.
This is a load or configuration signal, not a capacity one. Check that the holdable's primary key is indexed as a primary key should be, that you are not holding the surrounding transaction open across slow work, and that SQLite is configured as above if that is your engine.
Work through these in order:
hold:expire scheduled and running? The event only comes from that
command. Run it by hand and read the count.Expired 0 holds. has nothing unstamped left.All four events are dispatched with afterCommit(). If the surrounding
transaction never commits, the event never fires.
DB::transaction(function () use ($seat, $user) {
$seat->acquireHoldOrFail($user);
throw new PaymentFailed; // HoldAcquired is dropped with the row
});That is intended. A rollback should not leave an announcement behind.
release() records a releaser only when you pass one:
$hold->release(); // released_by stays null
$hold->release(by: $request->user()); // released_by is setIf you passed one and it is still null, check whether this is the second release of that hold. The first release wins, and its releaser stands.
CannotExtendHoldException covers three cases, and the message says which:
| Message contains | Meaning |
|---|---|
| already released | released_at is set |
| already expired | expires_at has passed |
| indefinite | expires_at is null, so there is nothing to push |
An expired hold cannot be revived. Its slot is free and may already belong to somebody else, so acquire a new hold instead.
A hold that is both released and expired reports the release.
Return value must be of type int, string returnedThe column backing your capacity is not cast. Add the cast:
protected function casts(): array
{
return ['seat_limit' => 'integer'];
}Models declaring strict_types=1 throw rather than coercing.
The resource was deleted. The identity columns are polymorphic, so there are no foreign keys and nothing cascades.
Handle it where you read it, and delete holds alongside their resource if you would rather they did not outlive it:
protected static function booted(): void
{
static::deleting(fn (self $seat) => $seat->holds()->delete());
}Check the window and the shape of what you expect it to remove:
expires_at is
null, so no cutoff reaches them.--days counts back from now. A hold released yesterday survives
--days=30.--days=0 prunes every dead hold and still spares every live one.