Looking to hire Laravel developers? Try LaraJobs

laravel-balance-engine maintained by ssbityukov

Description
Double-entry ledger for Laravel: race-safe deposits, withdrawals, transfers and reservations.
Last update
2026/08/05 14:36 (dev-main)
License
Links
Downloads
0

Comments
comments powered by Disqus

Laravel Balance Engine

A double-entry ledger for Laravel that does not lose money under load.

The problem

Almost every Laravel app starts here:

$user->balance += 100;
$user->save();

With balance = 100 and two simultaneous requests:

Request A Request B
reads 100 100
writes 200 200

Result: 200 instead of 300. Money is gone, and there is no trace of it — no record that a second request ever happened, nothing to reconcile against, no way to find out later.

The solution

Balance::deposit(to: $user, amount: 10_000);
Balance::withdraw(from: $user, amount: 5_000);
Balance::transfer(from: $alice, to: $bob, amount: 10_000);
$reservation = Balance::reserve(from: $buyer, amount: 10_000);

Every one of those writes at least two ledger entries that sum to zero, takes row locks in a deterministic order, and leaves a record that cannot be edited or deleted.

Prove it

php artisan balance:verify
Ledger is sound: every invariant holds.

And when something is wrong, it says so and exits 1:

Ledger verification found 1 problem(s):

+-----------------+-------------------------------------------------------------------+
| Check           | Detail                                                            |
+-----------------+-------------------------------------------------------------------+
| account_balance | Account [1] has cached balance 9999 but its entries sum to 10000. |
+-----------------+-------------------------------------------------------------------+

Do not run balance:rebuild before finding out why this happened.

Seven invariants are checked: the global sum is zero, every cached balance matches its entries, no reservation is drawn past zero, every capture and release points at a reserve, nothing but a reservation chain has touched a hold account, no hold account is negative, and every owner type still resolves.

How this compares

bavix/laravel-wallet abivia/ledger Balance Engine
deposit / withdraw / transfer as an API yes no, journal-level API yes
holds with partial capture holds, no partial no yes, via a hold account inside the ledger
strict double entry, SUM = 0 no yes yes
invariant check command, exit 1 no no balance:verify
idempotency keys with fingerprint no no yes
immutable records, corrections by reversal no partial yes
race safety proven by tests no no forked-process tests on MySQL and PostgreSQL
barrier to entry low high (chart of accounts) low

The niche is between the two: the double-entry strictness of abivia/ledger with the developer experience of bavix/laravel-wallet. No chart of accounts, an application-level API rather than a journal one, and an invariant you can verify in CI.

Requirements

  • PHP 8.4 or newer
  • Laravel 13
  • MySQL or PostgreSQL in production — SQLite has no row-level locking, so it cannot serialise concurrent operations. It is fine for local work and tests.

Installation

composer require ssbityukov/laravel-balance-engine
php artisan balance:install
php artisan migrate

balance:install detects whether your owner models use integer, UUID, ULID or string keys and writes that into the published config, so the polymorphic columns match your application.

Register a morph map

This is not optional. The ledger stores owner types forever and its records are immutable, so a raw App\Models\User string becomes unfixable the day you rename or move the class:

Relation::enforceMorphMap([
    'user' => User::class,
    'team' => Team::class,
]);

balance:verify fails on any owner type it cannot resolve, so a forgotten entry here shows up as a failing check rather than as a mystery years later.

Usage

Add the trait to anything that holds money:

use Bityukov\BalanceEngine\Concerns\HasBalance;

class User extends Model
{
    use HasBalance;
}

Every member is prefixed with balance, because most models already have a balance attribute of their own.

Reading balances

$user->balanceAmount();     // available, in minor units
$user->balanceReserved();   // held by open reservations
$user->balanceAccount();    // the Account model itself

Amounts are integers in minor units. There is no float anywhere in this package.

Deposit and withdraw

Balance::deposit(to: $user, amount: 10_000);
Balance::withdraw(from: $user, amount: 5_000);

Withdrawing more than is available throws InsufficientFunds, which carries the account, what was requested and what was there.

Transfer

Balance::transfer(from: $alice, to: $bob, amount: 10_000);

Locks are always taken in ascending account id order, which is what stops two transfers running in opposite directions between the same pair from deadlocking.

Reservations

Money is moved onto a hold account, not marked with a flag:

$reservation = Balance::reserve(
    from: $buyer,
    amount: 6_000,
    expiresAt: now()->addMinutes(30),
);

$reservation->capture(to: $seller);              // all of it
$reservation->capture(to: $seller, amount: 1_000); // part of it, stays open
$reservation->release();                          // hand the rest back
$reservation->release(amount: 2_000);             // hand part of it back

$reservation->remaining();  // int
$reservation->captured();   // int
$reservation->isOpen();     // bool
$reservation->status();     // ReservationStatus

Reserving and capturing usually happen in different requests. Keep the uuid and load the reservation back when you need it:

$uuid = Balance::reserve(from: $buyer, amount: 6_000)->uuid();

// Later, in another request:
Balance::reservation($uuid)->capture(to: $seller);

Hold accounts are not ordinary accounts. Money reaches one only through reserve() and leaves it only through capture() and release(); depositing, withdrawing or transferring against one throws HoldAccountNotDirectlyUsable. Otherwise balanceReserved() would report money that no reservation was holding.

The destination of a capture is mandatory. A default recipient would let money drift onto a system account unnoticed.

Nothing about a reservation is stored: it is the reserve transaction, captures and releases are its children, and every figure above is derived from the ledger. Expired reservations are returned by a scheduled command:

Schedule::command('balance:expire-reservations')->everyFiveMinutes();

Reversal

Records are immutable. A mistake is corrected by writing its mirror image, never by editing or deleting:

Balance::reverse($transaction, meta: ['reason' => 'chargeback']);

A reversal is an ordinary ledger operation and obeys the ordinary rules, so reversing a deposit whose money has already been spent fails with InsufficientFunds rather than pushing an account negative.

Freezing

Balance::freeze($user, reason: 'aml-review');
Balance::unfreeze($user);

Freezing blocks debits only. Credits keep landing, which is the correct semantics for fraud and AML work: stop payouts without losing money already in flight.

Idempotency

Balance::deposit(
    to: $user,
    amount: 10_000,
    idempotencyKey: "stripe:{$event->id}",
);

A repeated call returns the stored transaction instead of moving money again. Reusing a key for a different operation throws IdempotencyKeyReused rather than silently replaying, because that would hide a bug in the caller.

Two independent mechanisms back this: a check before the transaction opens for the ordinary retry, and a unique index for two workers racing on the same key.

Named accounts and currencies

One owner can hold many accounts, separated by name and currency:

$user->balanceAccount('bonus');
$user->balanceAccount('main', 'EUR');

Balance::deposit(to: $user->balanceAccount('bonus'), amount: 2_000);

There is no cross-currency transfer. An exchange is two operations at a rate your application decides — see docs/recipes.md.

Currencies are validated against balance.currencies, so a typo throws UnsupportedCurrency instead of quietly opening an account in a currency that does not exist. Records are immutable, so that account would have been permanent.

Production notes

SQLite has no row-level locking. lockForUpdate() is a silent no-op there, so concurrent operations are not safe. Use MySQL or PostgreSQL in production; the package logs a warning if it finds itself on SQLite in a production environment.

Retries do not work inside your own transaction. Deadlock retries happen at the DB::transaction level. If you wrap a balance call in an outer transaction of your own, the inner one becomes a savepoint and the retry is lost. Call the package outside your transaction where you can.

Put the two commands to work:

Schedule::command('balance:expire-reservations')->everyFiveMinutes();
Schedule::command('balance:verify')->daily();

balance:verify exits 1 on any discrepancy, so it works as a monitoring check. balance:rebuild can repair a drifted cached balance from the entries, but find out why it drifted first — an automatic repair hides the bug that caused it.

References use an integer morph. reference_type and reference_id are a standard nullableMorphs, so for UUID-keyed reference models put the identifier in meta instead.

Documentation

  • docs/concepts.md — why the design is what it is
  • docs/recipes.md — marketplaces, platform fees, webhooks, bonus balances, chargebacks, currency exchange

Every code block in this README and in docs/ is copied from a passing test in tests/Feature/DocumentationTest.php.

Testing

composer test              # the suite, on sqlite
composer analyse           # phpstan level 8
composer format            # pint

# concurrency needs a real database
DB_CONNECTION=mysql DB_DATABASE=balance_engine_test DB_USERNAME=root \
  composer test:concurrency

Changelog

See CHANGELOG.md.

License

MIT. See LICENSE.md.