›
byrcsc/laravel-hold · 1.x
Read active, released, and expired state from hold timestamps.
A hold is active, released, or expired. Nothing stores that. Two nullable timestamps decide it on every read, and a third is bookkeeping that decides nothing.
active = released_at IS NULL AND (expires_at IS NULL OR expires_at > now)
released = released_at IS NOT NULL
expired = released_at IS NULL AND expires_at <= nowWritten out per combination:
expires_at | released_at | Status |
|---|---|---|
| future | null | Active |
| null | null | Active |
| future | set | Released |
| null | set | Released |
| past | set | Released |
| past | null | Expired |
expired_at appears in no rule. It records when hold:expire announced the
hold and nothing else.
use ByRcsc\LaravelHold\Enums\HoldStatus;
$hold->status; // HoldStatus::Active | Released | ExpiredHoldStatus is a string-backed enum with the values active, released, and
expired.
The accessor recomputes on every read. Object caching is switched off
deliberately: a cached Active would survive past the expiry instant, and lazy
expiry forbids exactly that.
$hold->isActive(); // bool
$hold->isReleased(); // bool
$hold->isExpired(); // bool
$hold->isIndefinite(); // boolThe first three are the status compared against each case, so exactly one of them is true at any moment.
isIndefinite() is separate. It reports expires_at === null and says nothing
about release, so a released indefinite hold reports true.
use ByRcsc\LaravelHold\Models\Hold;
Hold::query()->active()->get();
Hold::query()->released()->get();
Hold::query()->expired()->get();Each scope selects exactly the rows whose accessor returns that case, so a scope and the accessor can never disagree about the same row.
The scopes compose with anything else:
Hold::query()
->active()
->whereMorphedTo('holder', $user)
->latest('id')
->get();They are also layered inside the traits: $seat->activeHolds is the holdable's
holds relation with active() applied, so the truth table keeps one
definition.
| Column | Type | Meaning |
|---|---|---|
id | big integer | Primary key |
holdable_type | string | Resource class |
holdable_id | key type | Resource key |
holder_type | string | Holder class |
holder_id | key type | Holder key |
released_by_type | string, null | Releaser class, if named |
released_by_id | key type, null | Releaser key, if named |
expires_at | timestamp, null | Null means indefinite |
released_at | timestamp, null | Set by release() |
expired_at | timestamp, null | Set by hold:expire, decides nothing |
metadata | json, null | Cast to array |
created_at | timestamp | Acquisition time |
updated_at | timestamp |
Full column shaping and indexes are in database schema.
metadata casts to an array in both directions. The column is nullable, so
read it as ?array.
$hold->metadata; // ['reason' => 'checkout']
$hold->metadata['reason']; // 'checkout'Acquisition writes an empty array when you pass none. A release merges its metadata over what is there, one level deep.
$hold->holdable; // the resource
$hold->holder; // whoever holds it
$hold->releasedBy; // whoever released it, or nullAll three are MorphTo. releasedBy stays null until a release names someone,
and survives refresh() and load().
Eager-load them like any other relation:
Hold::query()->active()->with(['holdable', 'holder'])->get();Because nothing stores a status, there is no scheduler run to be stale relative to and no cache to invalidate. Two consequences are worth planning for.
Every status read is a clock read. The same hold can report Active and
then Expired inside one request, which is correct.
A status cannot be queried as a column. Filter with the scopes rather than
writing where('status', 'active'), which matches nothing because there is no
such column.