---
title: "Holds — Credits for Laravel | Roundly"
description: "Reserve credits now and capture them in full or in part, release them, or let them expire — race-safe, under the same owner lock as every debit."
url: https://roundly-consulting.com/open-source/docs/credits-for-laravel/holds
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Credits for Laravel](https://roundly-consulting.com/open-source/docs/credits-for-laravel.md)

# Holds

A hold reserves credits in a bucket without writing a ledger row. It lowers what can be spent until it is captured — in full, or in part with the rest released — released, or expires. Think of a checkout that reserves credits while payment runs, or an API call that reserves its estimated cost up front.

## Switching holds on

Holds need the credit\_holds table (see Installation) and are off until you switch them on — while off, the debit guard never reads the holds table:

```bash
CREDITS_HOLDS=on
# optional: holds created without their own expiry live this long
CREDITS_HOLD_EXPIRE_AFTER_MINUTES=30
```

## Hold, capture, release

```php
use RoundlyConsulting\Credits\Facades\Credits;

$hold = Credits::for($user)->hold(30, now()->addMinutes(15), 'Order #42', ['order' => 42]);

Credits::for($user)->balance();    // 100 — the ledger is untouched
Credits::for($user)->held();       // 30
Credits::for($user)->available();  // 70 — what a debit or another hold can still take

$hold->capture();        // debit all 30 (one ledger row), or…
$hold->capture(12);      // …debit 12 and release the other 18, or…
$hold->release();        // …free all of it, nothing written to the ledger
```

The flat facade verbs take a CreditHoldData and the hold itself; the scope’s capture() and release() refuse another owner’s hold:

```php
use RoundlyConsulting\Credits\DataTransferObjects\CreditHoldData;
use RoundlyConsulting\Credits\Facades\Credits;

$hold = Credits::hold($user, new CreditHoldData(amount: 30, expiresAt: now()->addMinutes(15), bucket: 'points'));

Credits::capture($hold, 20, 'Order #42');        // the debit row (-20); the other 10 are released
Credits::release($hold);                         // throws CreditHoldNotActiveException — already captured

Credits::for($user)->bucket('points')->release($otherHold); // ForeignCreditHoldException for another owner's hold
Credits::held($user, 'points');                  // int
Credits::available($user, 'points');             // int
Credits::for($user)->hasAvailable(50);           // bool
```

The trait shorthand (not on the Creditable interface):

```php
$hold = $user->holdCredits(30, now()->addMinutes(15), bucket: 'points');

$user->heldCredits('points');           // 30
$user->availableCredits('points');      // balance minus active holds
$user->hasAvailableCredits(50, 'points');
```

$amount must be positive and $expiresAt in the future (InvalidArgumentException); a capture amount must be 1..$hold->amount, and its description and meta default to the hold’s. $hold->capture() and $hold->release() go through the manager, so the fake records them, and the passed hold is updated in place (status, captured\_amount, credit\_id, resolved\_at).

## The rules

- Available = balance − active holds, per bucket. An active hold is held and not past expires\_at. balance(), has() and CreditsModified’s balance stay the ledger balance.
- A hold is guarded like a debit: unless an overdraft is allowed (per scope or by credits.allow\_overdraft), it may not take the available balance below credits.minimum\_balance — InsufficientCreditsException, whose $available is the available balance.
- Debits respect holds: with holds on, a guarded debit decides on the available balance. An overdraft debit may still spend held credits.
- A capture is never refused for the minimum balance — the hold already reserved the credits. It writes one ledger debit, linked from the hold’s credit\_id.
- Each hold is resolved once: capturing or releasing a hold that was captured, released, expired (swept or merely past expires\_at) or deleted throws CreditHoldNotActiveException with the status it was found in.
- Expiry is immediate, the sweep is bookkeeping: a hold stops reserving the moment expires\_at passes; the sweep marks it expired and fires CreditHoldExpired.
- Holds live on the ledger’s connection — the credits.model connection, else the owner’s — next to the ledger rows and the owner lock.

## Race safety

Every hold, capture, release and expiry takes the owner lock (the same credit\_locks row a debit bumps) and re-reads what it decides on under row locks, in one transaction with its write:

| Race | Outcome |
| --- | --- |
| Two holds that together exceed the available balance | the second waits, then is refused (InsufficientCreditsException) |
| A hold and a debit for the same credits | whichever commits second is refused |
| Two captures of one hold | one debit; the other gets CreditHoldNotActiveException (captured) |
| A capture and the expiry sweep | the capture wins (the sweep skips it) or the sweep wins (the capture gets expired) — never both |
| A capture and a release | one wins; the other gets CreditHoldNotActiveException |

On Postgres REPEATABLE READ / SERIALIZABLE a waiter fails with SQLSTATE 40001 and the transaction is retried with a fresh snapshot, exactly as for debits.

## Expiring stale holds

Schedule the sweep — the command or the queueable job:

```php
// routes/console.php
use Illuminate\Support\Facades\Schedule;
use RoundlyConsulting\Credits\Jobs\ExpireCreditHolds;

Schedule::command('credits:expire-holds')->everyMinute()->withoutOverlapping();
// or queued:
Schedule::job(new ExpireCreditHolds)->everyMinute();
```

- credits:expire-holds {--connection=} prints how many holds it expired; with holds off it says so and succeeds.
- Credits::expireHolds(?$now, ?$connection) is the same sweep and returns the count; $now sweeps as of an instant. It reads holds in chunks of 100 on the ledger model’s connection, else the default — pass $connection when your holds live elsewhere.
- Safe to run on several servers at once: each hold is expired at most once, under its owner’s lock. A hold whose owner has been deleted is still expired.

## The credit\_holds table

| Column | Type | Notes |
| --- | --- | --- |
| `id` | per primary\_key\_type | |
| `creditable_type / creditable_id` | morph pair, id per key\_type | Indexed. |
| `bucket` | string | |
| `amount` | bigInteger | The reserved amount. |
| `status` | string(16) | held, captured, released or expired. |
| `captured_amount` | bigInteger, nullable | |
| `credit_id` | per primary\_key\_type, nullable | The capture’s ledger row. |
| `description / meta` | string / json, nullable | |
| `expires_at / resolved_at` | timestamp, nullable | Stored in app.timezone, compared as instants. |
| `created_at / updated_at / deleted_at` | timestamps, soft deletes | A deleted hold no longer counts. |

Indexes: (creditable\_type, creditable\_id, bucket, status) and (status, expires\_at). CreditHold ships a factory for seeding holds directly.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/credits-for-laravel.md)
