›
byrcsc/laravel-hold · 1.x
Laravel Hold reserves capacity on Eloquent models until release or expiry.
A checkout may reserve the last item while a buyer pays. A booking flow may hold a hotel room or event seat for several minutes. During that time, another user must not claim the same capacity.
Laravel Hold reserves capacity on any Eloquent model. Row locks prevent two holders from taking the last slot, and an expired timestamp frees the slot without waiting for a scheduled command.
Your application keeps ownership of its UI, its users, its checkout flow, and whatever being held actually means.
| Requirement | Supported versions |
|---|---|
| PHP | 8.3, 8.4 |
| Laravel | 12.x, 13.x |
The package follows semantic versioning: upgrading within 1.x is safe. Source
and issues live at
github.com/byrcsc/laravel-hold.
A holdable is the resource. It uses the Holdable trait, and it owns every
write, because capacity is a fact about the resource rather than about whoever
wants it. A seat, a domain name, a rental unit.
A holder is whoever claims it. It uses the HasHolds trait, which is
read-only. A holder is asked for nothing beyond being an Eloquent model: a
user, a cart, a session record. It does not need to be authenticatable, and it
does not need to be notifiable.
A hold is one claim by one holder on one holdable. It is a row. Its timestamps derive its state at read time, and no stored status column exists to drift out of date.
Every holdable has a capacity, and it defaults to 1. A capacity of 1
means the resource is exclusive: at most one active hold at a time. A capacity
of N models slot-style resources such as event seats or stock units.
One hold consumes exactly one slot. There is no quantity column. Holding three seats means three holds.
A hold is active when it has not been released and its expiry, if it has one,
is in the future. The timestamps are authoritative. The instant expires_at
passes, the slot is free for the next acquirer, whether or not any command has
run.
A hold with no expires_at is indefinite. It blocks its slot until
something releases it explicitly.
Acquisition is atomic. Two acquirers racing for the last slot cannot both win, on MySQL, PostgreSQL, and SQLite.
Holdable for the resource side, HasHolds for the holder side,
composable on one model when it is both.hold:expire to announce expiries exactly once, and
hold:prune to bound history.The package draws its edges deliberately. What follows describes what it sets out to do rather than what it might do later.
use ByRcsc\LaravelHold\Concerns\Holdable;
class Seat extends Model
{
use Holdable;
}$hold = $seat->acquireHold($user, expiresAt: now()->addMinutes(15));
if ($hold === null) {
// No slot was free.
}
$hold->extend(CarbonInterval::minutes(5));
$hold->release();That is the whole core loop. Everything else is detail.
Four decisions shape the rest of the package.
Timestamps are the state. status is computed on every read from
released_at and expires_at, and the query scopes encode the same truth
table. Nothing stores a status, so nothing can disagree with the clock between
scheduler runs. See hold state.
Expiry is lazy, and the command is optional. Availability is correct with
hold:expire never running. The command exists to fire an event, not to free a
slot. See expiry.
The holdable owns every write. HasHolds offers no acquire-from-here and
no release-all, because a holder cannot serialize a slot it does not yet
occupy. Acquisition takes a row lock on the holdable. See
concurrency.
Acquisition always attempts to create. acquireHold() never returns an
existing hold, so the same holder can hold the same resource as many times as
capacity allows. Guard double submits yourself with activeHoldFor(). See
acquiring holds.