›
›
›
  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

Troubleshooting.

Diagnose failed acquisitions, extensions, key configuration, and concurrency errors.

acquireHold() returns null on a resource that looks free

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 them

The usual causes:

  • An indefinite hold. A hold acquired with no expiresAt blocks its slot until something releases it. No clock frees it and hold:prune never deletes it.
  • A capacity of 1. holdCapacity() returns 1 unless overridden, so a resource you meant to be slot-style is exclusive.
  • A stale model instance. availableSlots() re-counts, but if you are reading $seat->activeHolds as a property it is answering from the relation cache. Call $seat->load('activeHolds').

Two holds appear from one form submission

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 holds has not been applied

Trait method ByRcsc\LaravelHold\Concerns\Holdable::holds has not been applied
as Locker::holds, because of collision with
ByRcsc\LaravelHold\Concerns\HasHolds::holds

A 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;
}

See holdables and holders.

A query on status returns nothing

Hold::query()->where('status', 'active')->get();   // always empty

There 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

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.

Database is locked, on SQLite

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.

A deadlock exception from acquireHold()

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.

HoldExpired never fires

Work through these in order:

  1. Is hold:expire scheduled and running? The event only comes from that command. Run it by hand and read the count.
  2. Has the hold already been stamped? A hold is announced once, ever. A run that reports Expired 0 holds. has nothing unstamped left.
  3. Was the hold released first? A hold released after its expiry passed but before the command reached it is left alone. The release is what happened to it.
  4. Is the listener registered? Type-hint discovery only works if your application uses it. Otherwise register it explicitly.

No event fires at all, for any operation

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.

releasedBy is null after a release

release() records a releaser only when you pass one:

$hold->release();                    // released_by stays null
$hold->release(by: $request->user()); // released_by is set

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

extend() throws on a hold that looks fine

CannotExtendHoldException covers three cases, and the message says which:

Message containsMeaning
already releasedreleased_at is set
already expiredexpires_at has passed
indefiniteexpires_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.

holdCapacity() throws a TypeError

Return value must be of type int, string returned

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

$hold->holdable is null

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());
}

hold:prune deletes nothing

Check the window and the shape of what you expect it to remove:

  • Indefinite holds are never pruned, however old. Their expires_at is null, so no cutoff reaches them.
  • Active holds are never pruned. Only released or clock-expired rows qualify.
  • --days counts back from now. A hold released yesterday survives --days=30.

--days=0 prunes every dead hold and still spares every live one.

What to read next

  • Concurrency and databases for the mechanism behind the locking failures above.
  • Hold state for the truth table that explains most surprising status readings.
  • Testing for reproducing any of this in a test rather than in production.
PreviousTesting
View source

On this page

  1. acquireHold() returns null on a resource that looks free
  2. Two holds appear from one form submission
  3. Trait method holds has not been applied
  4. A query on status returns nothing
  5. Unsupported hold key type
  6. Database is locked, on SQLite
  7. A deadlock exception from acquireHold()
  8. HoldExpired never fires
  9. No event fires at all, for any operation
  10. releasedBy is null after a release
  11. extend() throws on a hold that looks fine
  12. holdCapacity() throws a TypeError
  13. $hold->holdable is null
  14. hold:prune deletes nothing
  15. What to read next