PHPackages                             byrcsc/laravel-hold - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. byrcsc/laravel-hold

ActiveLibrary[Database &amp; ORM](/categories/database)

byrcsc/laravel-hold
===================

Temporary resource holds for Eloquent models: any model can be held, any model can hold, with capacity-aware atomic acquisition, expiring or indefinite claims, and lifecycle events.

v1.0.0(yesterday)01↑2900%MITPHPPHP ^8.3CI passing

Since Aug 8Pushed yesterdayCompare

[ Source](https://github.com/byrcsc/laravel-hold)[ Packagist](https://packagist.org/packages/byrcsc/laravel-hold)[ Docs](https://github.com/byrcsc/laravel-hold)[ Fund](https://www.buymeacoffee.com/ryancatapang)[ RSS](/packages/byrcsc-laravel-hold/feed)WikiDiscussions main Synced today

READMEChangelog (1)Dependencies (16)Versions (2)Used By (0)

Laravel Hold
============

[](#laravel-hold)

[![Latest Version on Packagist](https://camo.githubusercontent.com/63ea2302de9b89049954ef1db5f3894fdd770c410a991332a7966d4473a6c378/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6279726373632f6c61726176656c2d686f6c642e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/byrcsc/laravel-hold)[![GitHub Tests Action Status](https://camo.githubusercontent.com/92c221ecc525329a4213c3c2c6c715241abefc1c75446b3b7d485bc1b0b92c38/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6279726373632f6c61726176656c2d686f6c642f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/byrcsc/laravel-hold/actions?query=workflow%3Arun-tests+branch%3Amain)[![GitHub PHPStan Action Status](https://camo.githubusercontent.com/14b9213624d78328b10e04322d1c637ad7a7f423fb8a6d9a0e2e37dab5f51239/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6279726373632f6c61726176656c2d686f6c642f7068707374616e2e796d6c3f6272616e63683d6d61696e266c6162656c3d7068707374616e267374796c653d666c61742d737175617265)](https://github.com/byrcsc/laravel-hold/actions?query=workflow%3APHPStan+branch%3Amain)[![Total Downloads](https://camo.githubusercontent.com/dd5d20e48d9fa2db2c4fe78426fb71285fb4f117e96af0c7e2d8f032c00d364c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6279726373632f6c61726176656c2d686f6c642e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/byrcsc/laravel-hold)

Temporary resource holds for Eloquent models: any model can be held, any model can hold, with capacity-aware atomic acquisition, expiring or indefinite claims, and lifecycle events.

The package provides the hold engine. Your application keeps ownership of its UI, users, checkout flow, and what being held actually means.

LaravelTested PHP versions12.x8.3, 8.413.x8.3, 8.4Installation
------------

[](#installation)

Install the package and publish its migration:

```
composer require byrcsc/laravel-hold
php artisan vendor:publish --tag="hold-migrations"
php artisan migrate
```

Publish the configuration before the migration when you need a custom table name or non-integer model keys:

```
php artisan vendor:publish --tag="hold-config"
```

The published `config/hold.php` has three keys:

KeyDefaultWhat it decides`table``holds`The table the `Hold` model and the migration both read`holdable_key_type``int`The key type of the resource side`holder_key_type``int`The key type of the holder side, and of `released_by`Set `HOLD_HOLDABLE_KEY_TYPE` and `HOLD_HOLDER_KEY_TYPE` to `uuid`, `ulid`, or `string` when the models on either side of a hold do not use integer keys. Both shape the identity columns, so set them before you migrate.

What a hold is
--------------

[](#what-a-hold-is)

A **hold** is a temporary claim by one **holder** (any model: a user, a cart, a session) on one **holdable** (any model: a seat, a domain name, a rental unit). The rules are small and strict:

- Every holdable has a **capacity**, default `1`. A capacity of 1 means the resource is exclusive: at most one active hold at a time. A capacity of N suits slot-style resources such as event tickets or stock units.
- **One hold consumes exactly one slot.** There is no quantity on a hold. 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. No scheduler or worker is required for correctness.
- A hold with no `expires_at` is **indefinite** and blocks its slot until it is explicitly released.
- Acquisition is **atomic**. Two concurrent acquirers racing for the last slot cannot both win, on MySQL, PostgreSQL, and SQLite.

The package controls hold state and nothing else. It does not decide what a held resource means for your application: it does not hide it, price it, reserve payment for it, or queue the next person in line. Your application reads hold state and decides.

Quick start
-----------

[](#quick-start)

Add `Holdable` to the model that can be held:

```
use ByRcsc\LaravelHold\Concerns\Holdable;

class Seat extends Model
{
    use Holdable;
}
```

Acquire, extend, and release a hold:

```
$hold = $seat->acquireHold($user, expiresAt: now()->addMinutes(15));

if ($hold === null) {
    // No slot available.
}

$hold->extend(CarbonInterval::minutes(5)); // now expires 20 minutes from acquisition
$hold->release();
```

That is the whole core loop. Everything below is detail.

Capacity
--------

[](#capacity)

Capacity is a fact about the resource, declared on the model:

```
class Event extends Model
{
    use Holdable;

    public function holdCapacity(): int
    {
        return $this->seat_limit; // or any fixed integer
    }
}
```

The default implementation returns `1`. Capacity is deliberately not an argument to `acquireHold()`: every concurrent acquirer must agree on the same limit for atomic acquisition to mean anything.

Acquiring
---------

[](#acquiring)

`acquireHold()` returns the new `Hold`, or `null` when no slot is free:

```
$hold = $seat->acquireHold($user, expiresAt: now()->addMinutes(15), metadata: [
    'reason' => 'checkout',
    'order_ref' => $orderRef,
]);
```

`acquireHoldOrFail()` does the same but throws `ByRcsc\LaravelHold\Exceptions\NoAvailableSlotsException` instead of returning `null`. The exception carries the resource it refused as `$exception->holdable`.

Both parameters are optional. Omit `expiresAt` for an indefinite hold; omit `metadata` when you have no context to attach.

`acquireHold()` is always an attempt to create a new hold. It never silently returns an existing one, and the same holder may hold the same resource more than once when capacity allows (that is how you hold three seats). For double-submit safety, check first:

```
$hold = $seat->activeHoldFor($user) ?? $seat->acquireHold($user, expiresAt: now()->addMinutes(15));
```

Acquisition takes a row lock on the holdable and counts inside a transaction, so concurrent acquirers are serialized per resource, and never against unrelated ones. SQLite has no row locks and serializes writers itself, which is correct but can surface a "database is locked" error instead of a clean `null`. [Concurrency and databases](https://docs.rcsc.dev/laravel-hold/v1/concurrency)covers the connection settings that avoid it.

Expiration
----------

[](#expiration)

Expiry is lazy. Every check the package performs, `availableSlots()`, `isFullyHeld()`, the `active()` scope, the acquisition count, reads the clock, never a stored status. An expired hold stops blocking its slot at the moment `expires_at` passes, whether or not any command has run.

The `hold:expire` command exists only to tell you about it. It stamps holds that have passed their expiry and fires a `HoldExpired` event for each, exactly once, including across overlapping runs. A hold released after its expiry passed but before the command reached it is left alone: the release is what happened to it. Schedule it if you listen for that event:

```
Schedule::command('hold:expire')->everyMinute();
```

If you do not listen for `HoldExpired`, you do not need the scheduler at all.

Note that `HoldExpired` fires when the command notices, not at the expiry instant. A hold can expire and its slot be re-acquired by someone else before the event fires. Availability is exact; the event is best effort.

Extending and releasing
-----------------------

[](#extending-and-releasing)

`extend()` pushes `expires_at` further out from its current value, not from now:

```
$hold->extend(CarbonInterval::minutes(10));
```

It throws `ByRcsc\LaravelHold\Exceptions\CannotExtendHoldException` when the hold is released, already expired, or indefinite. The message says which of the three it was, and `$exception->hold` carries the hold itself. An expired hold cannot be revived by extension: the slot may already belong to someone else. Re-acquire instead.

```
$hold->release();
$hold->release(by: $request->user(), metadata: ['reason' => 'cancelled by support']);
```

Releasing stamps `released_at`, optionally records who released it (`released_by`, polymorphic, any model), and merges any metadata you pass into the hold's metadata. Released holds stay in the table as history until you prune them.

Releasing twice is a no-op. The first stamp, the first releaser, and the one `HoldReleased` event all stand, so a double-submitted cancel button cannot rewrite who released a hold or when. [Releasing and extending](https://docs.rcsc.dev/laravel-hold/v1/releasing-and-extending)covers the merge depth and the expired-then-released case.

Reading holds
-------------

[](#reading-holds)

Add `HasHolds` to holder models to read from the other direction:

```
use ByRcsc\LaravelHold\Concerns\HasHolds;

class User extends Model
{
    use HasHolds;
}
```

A model can be both. Each trait names its relations `holds` and `activeHolds`for its own side of the table, so composing them needs one resolution block telling PHP which side keeps the plain names, after which `holdsAsHolder` and `activeHoldsAsHolder` reach the other side. [Holdables and holders](https://docs.rcsc.dev/laravel-hold/v1/holdables-and-holders)carries that block verbatim.

The whole read surface:

MemberReturns`$seat->holdCapacity()``int`, default `1``$seat->availableSlots()``int``$seat->isFullyHeld()``bool``$seat->activeHoldFor($user)``?Hold`, the newest active hold by that holder`$seat->holds`, `$user->holds`every hold, including released and expired`$seat->activeHolds`, `$user->activeHolds`currently blocking holds only`Hold::query()->active()`scope, with `released()` and `expired()` alongside`$hold->status``HoldStatus::Active`, `Released`, or `Expired``$hold->isActive()``bool`, with `isReleased()`, `isExpired()`, `isIndefinite()``$hold->metadata``?array``$hold->holdable`the resource being held`$hold->holder`whoever holds it`$hold->releasedBy`whoever released it, or nullStatus is computed from the timestamps at read time. There is no stored status column to drift out of date between scheduler runs.

Events
------

[](#events)

EventFires`HoldAcquired`After a hold is acquired`HoldExtended`After `extend()` succeeds`HoldReleased`After `release()``HoldExpired`From `hold:expire`, once per expired holdEach event exposes the `Hold` as a public readonly property. Events are the extension point for anything user-facing. Holders are polymorphic and may not be notifiable, so the package ships no notifications; a listener reads `$event->hold->holder` and decides who to tell. [Events and listeners](https://docs.rcsc.dev/laravel-hold/v1/events) has a worked listener.

Pruning
-------

[](#pruning)

Released and expired holds accumulate as history. Delete old ones on your schedule:

```
php artisan hold:prune --days=30
```

This removes holds that were released, or passed their expiry, more than the given number of days ago. Active and indefinite holds are never pruned.

`--days` must be a whole number, zero or more; anything else exits non-zero without deleting a row.

Documentation
-------------

[](#documentation)

Full documentation is at [docs.rcsc.dev/laravel-hold](https://docs.rcsc.dev/laravel-hold/v1/introduction).

Out of scope
------------

[](#out-of-scope)

These are decisions, not omissions. The default answer to a feature request in this list is this section:

- **Quantity per hold.** One hold is one slot, always.
- **Waitlists and queues.** Who gets a freed slot next is application policy.
- **Hold approval.** A hold either succeeds atomically or fails. If claims need review, see [laravel-approval](https://github.com/byrcsc/laravel-approval).
- **Notifications.** The events carry everything a listener needs.
- **Transition-log audit trail.** The hold row records who released it and when; a full actor-attributed history table is application territory.
- **UI, pricing, and payments.** The package has no opinion on what a hold costs or looks like.

Versioning
----------

[](#versioning)

The package follows [semantic versioning](https://semver.org/spec/v2.0.0.html).

- Upgrading within `1.x` is safe. Nothing you use will break.
- Only a new major version, like `2.0.0`, can break your code.
- If the README or the documentation describes it, it is safe to build on. If they don't, treat it as internal and expect it to change.

Bug fixes go into the newest version only. To get a fix, upgrade to it.

Questions and issues
--------------------

[](#questions-and-issues)

- **Stuck, or have an idea?** Start a [discussion](https://github.com/byrcsc/laravel-hold/discussions). Usage questions and feature ideas both live there.
- **Found a bug you can reproduce?**[Open an issue](https://github.com/byrcsc/laravel-hold/issues). A failing test is the fastest way to a fix, and a short reproduction is the next best thing.
- **Found a security problem?** Please don't open a public issue. See [SECURITY.md](SECURITY.md) for how to report it privately.
- **Planning a pull request?** [CONTRIBUTING.md](CONTRIBUTING.md) covers the setup and the three checks it needs to pass.

This package is maintained by one person, so replies can take a while. Everything gets read.

License
-------

[](#license)

MIT. See [LICENSE.md](LICENSE.md). Changelog in [CHANGELOG.md](CHANGELOG.md).

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance100

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

1d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/9258c643a563d82b6ae3f5b1c71158ed150c5bfd95ec48b52f837dbf8caf55d6?d=identicon)[rcscatapang](/maintainers/rcscatapang)

---

Top Contributors

[![rcscatapang](https://avatars.githubusercontent.com/u/60214290?v=4)](https://github.com/rcscatapang "rcscatapang (22 commits)")

---

Tags

laraveleloquentinventorybookingresource-lockcapacityreservationhold

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/byrcsc-laravel-hold/health.svg)

```
[![Health](https://phpackages.com/badges/byrcsc-laravel-hold/health.svg)](https://phpackages.com/packages/byrcsc-laravel-hold)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[laravel/ai

The official AI SDK for Laravel.

1.1k4.6M286](/packages/laravel-ai)[spatie/laravel-medialibrary

Associate files with Eloquent models

6.2k45.4M688](/packages/spatie-laravel-medialibrary)[spatie/laravel-health

Monitor the health of a Laravel application

88212.7M183](/packages/spatie-laravel-health)[illuminate/queue

The Illuminate Queue package.

20433.0M1.8k](/packages/illuminate-queue)[masterix21/laravel-licensing

Laravel licensing package with polymorphic assignment to any model, activation keys, expirations/renewals, and seat control via LicenseUsage. Supports offline verification with public-key–signed tokens, a CLI to generate/rotate/revoke keys, and an extensible architecture via config and contracts.

1614.1k4](/packages/masterix21-laravel-licensing)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
