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

Capacity and slots.

Define how many active holds a resource can accept.

A holdable has a capacity, and a capacity is a number of slots. One hold occupies exactly one slot. A resource with three slots admits three concurrent holds, and the fourth acquirer is refused.

There is no exclusive mode and no slot mode. Exclusivity is a capacity of 1, which is the default.

Declaring capacity

holdCapacity() returns 1 unless you override it. Override it on the model, and nowhere else.

A fixed capacity:

class MeetingRoom extends Model
{
    use Holdable;

    public function holdCapacity(): int
    {
        return 3;
    }
}

A capacity that varies per row:

class Event extends Model
{
    use Holdable;

    protected function casts(): array
    {
        return ['seat_limit' => 'integer'];
    }

    public function holdCapacity(): int
    {
        return $this->seat_limit;
    }
}

Cast the column to integer. The return type coerces a numeric string on most setups, but a model that declares strict_types=1 throws a TypeError instead.

Why capacity is not an argument

Every concurrent acquirer has to read the same limit for the count under the lock to mean anything. If capacity were a parameter, two requests could pass 2 and 5 for the same resource in the same second, and the winner would be whoever happened to run last.

Declaring it on the model makes the limit a property of the resource, which is what it already is in the domain.

Reading availability

$event->holdCapacity();     // 3
$event->availableSlots();   // 2
$event->isFullyHeld();      // false

availableSlots() is holdCapacity() minus the current count of active holds, floored at zero. It never returns a negative number, so a resource whose capacity was lowered below its live hold count reports 0 rather than -2.

isFullyHeld() is availableSlots() === 0.

Both reads hit the database every time

availableSlots() issues a fresh COUNT on every call. It does not reuse a loaded relation and it does not use withCount(). Expiry is lazy, so a count captured at hydration ages immediately and would report an expired hold's slot as still taken.

That is the trade-off, and it is deliberate: availability is only true as of the clock that read it. The cost is an N+1 across a collection.

// One query per seat. Fine for a handful, wrong for a seat map.
foreach ($seats as $seat) {
    $seat->availableSlots();
}

For a page-sized collection, filter with the active() scope yourself in one query and count in PHP:

use ByRcsc\LaravelHold\Models\Hold;

$blocking = Hold::query()
    ->active()
    ->whereMorphedTo('holdable', $seats)
    ->get()
    ->countBy('holdable_id');

foreach ($seats as $seat) {
    $free = max(0, $seat->holdCapacity() - ($blocking[$seat->id] ?? 0));
}

That count is a snapshot. A hold inside the set can expire between the query and the render, which is exactly what the per-model read refuses to pretend away.

The relation properties cache

$seat->activeHolds is an ordinary Eloquent relation. It hydrates once and keeps answering from memory, including across an expiry boundary.

$seat->activeHolds->count();   // 1
sleep(60);                     // the hold's expiry passes
$seat->activeHolds->count();   // still 1, from the cache
$seat->availableSlots();       // 1, because this re-counts

Call $seat->load('activeHolds') or use the method form $seat->activeHolds()->get() when the model instance is long-lived.

The same holder can take several slots

Capacity limits total active holds, not holds per holder. One user taking three seats is three holds:

$row = Event::find(1);          // seat_limit 3

$row->acquireHold($user);       // hold 1
$row->acquireHold($user);       // hold 2
$row->acquireHold($user);       // hold 3
$row->acquireHold($user);       // null, the event is full

If you want one hold per holder, enforce it with activeHoldFor() before acquiring. See acquiring holds.

What to read next

  • Acquiring holds for what happens at the moment the last slot is taken.
  • Concurrency and databases for the locking that makes the count trustworthy under load.
  • Expiry for why availability reads the clock rather than a stored status.
PreviousHoldables and holdersNextAcquiring holds
View source

On this page

  1. Declaring capacity
  2. Why capacity is not an argument
  3. Reading availability
  4. Both reads hit the database every time
  5. The relation properties cache
  6. The same holder can take several slots
  7. What to read next