›
byrcsc/laravel-hold · 1.x
Release a slot or extend the expiry of an active hold.
Both operations live on the Hold model, and both return the hold so they
chain. Neither opens a transaction: a hold owns the writes to its own row.
$hold->release();The slot is free immediately. The row is not deleted: it stays as history until
hold:prune takes it.
Record who decided it, and why:
$hold->release(by: $request->user(), metadata: [
'reason' => 'cancelled by support',
]);public function release(?Model $by = null, array $metadata = []): self| Argument | Effect |
|---|---|
by | Associated through the polymorphic releasedBy relation |
metadata | Merged over the hold's existing metadata, one level deep |
by is any model. A releaser is an actor of the same kind as a holder, and the
released_by columns take the holder key type. See
configuration.
$hold->release(by: $support, metadata: ['reason' => 'cancelled']);
$hold->status; // HoldStatus::Released
$hold->isReleased(); // true
$hold->released_at; // the moment of the call
$hold->releasedBy->name; // Support
$hold->metadata; // acquisition metadata, with 'reason' merged overThe metadata merge is one level deep and your keys win. A nested array replaces its counterpart rather than combining with it. Passing no metadata writes none, so a null metadata column stays null rather than becoming an empty array.
The second release changes nothing. The first stamp, the first releaser, and
the one HoldReleased event all stand.
$hold->release(by: $support);
$hold->release(by: $someoneElse);
$hold->fresh()->releasedBy->name; // SupportThis holds across instances, not only on one object. A double-submitted cancel button is two requests holding two copies of the same row, and the guard is a single conditional update, so the database decides the winner. The loser reads the winner's values back and stays silent.
A hold whose expiry has passed can still be released, and it stamps normally.
$hold->status; // HoldStatus::Expired
$hold->release();
$hold->status; // HoldStatus::ReleasedThe slot was free either way. The stamp records that somebody decided so, and
Released wins over Expired in the status truth table because a release
records a decision while expiry only records the clock passing.
use Carbon\CarbonInterval;
$hold->extend(CarbonInterval::minutes(10));public function extend(DateInterval $by): selfThe interval is added to the current expires_at, never to now. Extending
early must not shorten the hold.
// Acquired at 12:00 for 15 minutes, so expires_at is 12:15.
// Extended at 12:05 by 10 minutes.
$hold->expires_at; // 12:25, not 12:15Extensions accumulate. Call it three times and the window grows three times.
The interval is added as given. extend() does not check that you passed a
positive interval, so a negative one moves the expiry backwards.
extend() throws CannotExtendHoldException in three cases. Each has its own
message, so a caller reporting to a user does not have to work out which it
caught.
| Case | Message says |
|---|---|
| Already released | it was already released |
| Already expired | it has already expired; acquire a new hold instead |
| Indefinite, so no expiry | it is indefinite and has no expiry to push |
use ByRcsc\LaravelHold\Exceptions\CannotExtendHoldException;
try {
$hold->extend(CarbonInterval::minutes(10));
} catch (CannotExtendHoldException $e) {
$e->hold; // the hold it refused
$e->getMessage();
}A hold that is both released and expired reports the release, because that is the decision somebody made.
An expired hold cannot be revived by extension. Its slot is free and may already belong to somebody else. Acquire a new hold and contend for the slot like every other acquirer.
The row is untouched on every refusal, and no event fires.
HoldReleased fires once per hold, after the transaction commits.
HoldExtended fires only on a successful extension, after the new expiry is
saved, so a listener reads the extended window rather than the one it replaced.
Both are dropped if the surrounding transaction rolls back. See events and listeners.