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:
CREDITS_HOLDS=on
# optional: holds created without their own expiry live this long
CREDITS_HOLD_EXPIRE_AFTER_MINUTES=30Hold, capture, release
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 ledgerThe flat facade verbs take a CreditHoldData and the hold itself; the scope’s capture() and release() refuse another owner’s hold:
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); // boolThe trait shorthand (not on the Creditable interface):
$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:
// 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 cryptoBy donating, you agree to our donation terms.
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.