NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Credits for Laravel

Overdraft protection

By default a deduction that would take the balance below minimum_balance (zero by default) is rejected with an InsufficientCreditsException — balances cannot silently go negative, and nothing is written. On the facade, allowOverdraft() returns a scope that may go below the floor:

use RoundlyConsulting\Credits\Exceptions\InsufficientCreditsException;

try {
    Credits::for($user)->deduct(1000, 'big purchase');
} catch (InsufficientCreditsException $e) {
    // $e->requested, $e->available, $e->creditable — nothing was written
}

Credits::for($user)->allowOverdraft()->deduct(1000, 'manual debit');   // may go below the floor
Credits::for($user)->allowOverdraft()->setTo(-50, 'write-off');

The same through the trait:

use RoundlyConsulting\Credits\Exceptions\InsufficientCreditsException;

try {
    $user->modifyCredits(-1000, 'big purchase');
} catch (InsufficientCreditsException $e) {
    $e->requested;    // -1000 — the signed amount that was refused
    $e->available;    // 70 — the bucket's balance when the guard decided
    $e->creditable;   // the entity
    $e->getMessage(); // 'Insufficient credits: tried to deduct 1000 but only 70 are available.'
}

Allowing an overdraft

Allow a negative balance for a single call — allowOverdraft() on the scope, or the allowOverdraft argument of the trait methods:

$user->modifyCredits(-1000, 'manual debit', allowOverdraft: true);

$user->setCreditsTo(-50, 'write-off', allowOverdraft: true);

Or globally, via the allow_overdraft config key or its env variable. The env value is read as a boolean: 1, true, on and yes enable it; 0, false, off, no and anything unrecognised leave it off:

CREDITS_ALLOW_OVERDRAFT=true   # 1, true, on or yes — anything else leaves it off

A custom floor

minimum_balance is the floor the guard enforces. Raise it to keep a reserve, or set it below zero for a bounded overdraft:

// config/credits.php — a bounded overdraft: balances may fall to -500, never below
'minimum_balance' => -500,

The guard is enforced per bucket: a deduction against the purchased bucket can never draw on promotional credit. Grants never pass through the guard.

Concurrency

The balance is never a stored column — it is the sum of an append-only ledger. Every change (add, deduct, modify, setTo and their Money forms) runs in one transaction that first takes the owner’s lock — an UPDATE of the owner’s row in credit_locks — then reads the bucket’s ledger rows under lockForUpdate(), decides (the overdraft guard for a debit, the delta for setTo()) and appends the new row. Racing changes of one owner therefore serialise on every isolation level:

  • READ COMMITTED (the Postgres default): the second change waits for the first, then reads the balance the first left behind — also in an empty bucket with a negative minimum_balance.
  • MySQL / MariaDB REPEATABLE READ (their default): the lock and the ledger read are current reads, so they see the first change’s committed row too.
  • Postgres REPEATABLE READ / SERIALIZABLE: the second change’s snapshot predates its wait, so rather than decide on stale data it fails with a serialisation failure (SQLSTATE 40001), and the package retries its transaction — up to 5 attempts — with a fresh snapshot.

Inside your own transaction

Inside your own DB::transaction(), the snapshot belongs to your transaction, so the package cannot retry for you: on Postgres REPEATABLE READ / SERIALIZABLE a racing change surfaces as Illuminate\Database\DeadlockException (SQLSTATE 40001) and nothing is written. Retry the whole transaction — DB::transaction($callback, attempts: 3) does not, because the nested exception no longer carries the SQLSTATE Laravel’s retry checks for:

use Illuminate\Database\DeadlockException;
use Illuminate\Support\Facades\DB;
use RoundlyConsulting\Credits\Facades\Credits;

retry(3, fn () => DB::transaction(function () use ($user): void {
    // … your own reads and writes …
    Credits::for($user)->deduct(30, 'purchase');
}), when: fn (Throwable $e): bool => $e instanceof DeadlockException);

The lock is held only for that short transaction. Because every change is written as a new delta row (never an absolute balance), a change is never lost, and credit_locks holds a version counter only — the ledger stays the single source of truth.

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

By 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.