Download the PHP package ssbityukov/laravel-balance-engine without Composer
On this page you can find all versions of the php package ssbityukov/laravel-balance-engine. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download ssbityukov/laravel-balance-engine
More information about ssbityukov/laravel-balance-engine
Files in ssbityukov/laravel-balance-engine
Package laravel-balance-engine
Short Description Double-entry ledger for Laravel: race-safe deposits, withdrawals, transfers and reservations.
License MIT
Informations about the package laravel-balance-engine
Laravel Balance Engine
A double-entry ledger for Laravel that does not lose money under load.
The problem
Almost every Laravel app starts here:
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
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
And when something is wrong, it says so and exits 1:
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
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:
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:
Every member is prefixed with balance, because most models already have a
balance attribute of their own.
Reading balances
Amounts are integers in minor units. There is no float anywhere in this package.
Deposit and withdraw
Withdrawing more than is available throws InsufficientFunds, which carries the
account, what was requested and what was there.
Transfer
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:
Reserving and capturing usually happen in different requests. Keep the uuid and load the reservation back when you need it:
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:
Reversal
Records are immutable. A mistake is corrected by writing its mirror image, never by editing or deleting:
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
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
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:
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:
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
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.md.
All versions of laravel-balance-engine with dependencies
illuminate/console Version ^13.0
illuminate/contracts Version ^13.0
illuminate/database Version ^13.0
illuminate/support Version ^13.0