›
byrcsc/laravel-hold · 1.x
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.
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.
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.
$event->holdCapacity(); // 3
$event->availableSlots(); // 2
$event->isFullyHeld(); // falseavailableSlots() 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.
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.
$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-countsCall $seat->load('activeHolds') or use the method form
$seat->activeHolds()->get() when the model instance is long-lived.
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 fullIf you want one hold per holder, enforce it with activeHoldFor() before
acquiring. See
acquiring holds.