Download the PHP package ianfoxdev/ledger without Composer
On this page you can find all versions of the php package ianfoxdev/ledger. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download ianfoxdev/ledger
More information about ianfoxdev/ledger
Files in ianfoxdev/ledger
Package ledger
Short Description Double-entry ledger for PHP that runs inside your database transaction: balances derived from postings, idempotent transactions, holds and expiring credits.
License MIT
Homepage https://github.com/IanFoxDev/ledger
Informations about the package ledger
ledger
Double-entry ledger for PHP that runs inside your application's database transaction:
wallets, holds, refunds and prepaid credits that expire. Balances are derived from
postings, nothing is updated or deleted, and verify() recalculates everything and
names what does not add up.
Status: v0.1. Until 1.0 a minor version may change the API; such changes are marked BREAKING in the CHANGELOG. Stored transactions keep verifying: a change to what their hash covers would be breaking.
The usual wallet is a balance column and UPDATE accounts SET balance = balance + ?.
Two requests read the same balance and both spend it. A wrong row gets fixed in place, and
the history that would explain the balance is gone. A deposit is added to the user and
taken from nowhere, so the books do not balance and nothing notices. Reconciliation finds
it a month later.
Install
PHP 8.3 or later, PostgreSQL or MySQL 8. Create the tables from
schema/mysql.sql.
The only dependency is brick/math.
Example
The first argument of every write is an idempotency key. The same key with the same content returns the stored transaction; with different content it throws:
examples/saas runs a wallet and expiring credits end to end, including a retried capture, a refunded duplicate charge and credits that expire.
What it guarantees
- The books balance. A transaction is a set of debit and credit legs; per currency, debits equal credits, or it throws before anything is written.
- A balance is derived. Every posting stores the account's sequence number and the balance after it. There is no balance column to update. The primary key (account, sequence) makes a write that skipped the lock fail instead of forking the chain.
- Nothing is edited. Rows are only inserted.
reverse()undoes a transaction with a new one that swaps every side and links to the original; a transaction is reversed at most once. - No overdrafts by race. A transaction locks its accounts with
SELECT ... FOR UPDATEin the order of account code, so two writers cannot deadlock on each other, and checks accounts that must not go negative under the lock. - No floats, no overflow. Amounts are integers in minor units, as arbitrary-precision
integers stored in
NUMERIC(38,0)."10.50"is rejected; 25,000 ETH in wei works. - A retry does not move money twice. Keys are unique. A retry that finds its key taken returns what was written. In MySQL that holds even when your transaction reads from an older snapshot (REPEATABLE READ); the ledger rereads with a locking read before it reports a failure.
- Changes made by hand are found.
verify()recalculates every chain and checks every transaction's content against a SHA-256 taken when it was written. A deposit "fixed" from 50.00 to 60.00 directly in the database, with both legs and every later balance adjusted to match, is still reported.
Why it is built this way: docs/adr/0001-balances-are-derived-and-nothing-is-edited.md.
Holds
hold() moves the amount to an account of its own (user:42@hold:...); capture() takes
part or all of it to an account with the same normal side, release() returns the rest.
What remains on a hold is that account's balance, read under the lock, so a second capture
of a settled hold fails even if it runs twice at once. held('user:42') is the total on
open holds.
Credits that expire
Each grant is a lot on an account of its own, named after its expiry, so usage takes the
lot that expires first. The customer's credits account is a liability: the platform owes
the service. Used credits go to the lot's revenue account; what expires unused goes to its
breakage account. For promotional credits, breakage can be the promotion expense itself,
which undoes the part of the expense that was never used. An expired lot is never used,
even before expire() has run.
Isolation and throughput
Run writes at READ COMMITTED. A transaction the ledger opens itself does that. When it joins yours, your level applies:
- PostgreSQL: READ COMMITTED is the default. Under REPEATABLE READ the end of an
account's chain is read from your snapshot; if another transaction wrote to the account
since, the posting is refused on its primary key and you get
ConcurrentWrite, and a retry of a key committed after your snapshot getsIdempotencyConflict, because your snapshot cannot see it. Roll back and retry. - MySQL: under the default REPEATABLE READ the writes stay correct, but two of them on
accounts without postings yet can deadlock (error 1213) and must be retried.
SET SESSION transaction_isolation = 'READ-COMMITTED'avoids it.
Every write to an account waits for the previous one. A revenue account that every
purchase credits is locked by every purchase. On a laptop (M1 Pro, the database in Docker,
each transfer its own transaction, 8 processes), bench/transfers.php gives:
| 1000 accounts | 2 accounts | |
|---|---|---|
| PostgreSQL 17 | 440 to 520 transfers/s | 150 to 160 transfers/s |
| MySQL 8.4 (Percona) | 560 to 690 transfers/s | 235 to 255 transfers/s |
These are not server numbers. The ratio is the point: spread writes scale, one hot account does not.
Checking the ledger
Exit code 0 when nothing is wrong, 1 with violations (each one names the account or
transaction), 2 on a usage or connection error. --json for a machine-readable report.
It reads the whole ledger: run it from a nightly job, not a request.
How it differs
- bavix/laravel-wallet keeps the balance in a
balancecolumn of itswalletstable and offersdepositFloat(1.37). A deposit is a row on one wallet, with no account it came from, so there is no second side that has to agree. - climactic/laravel-credits is a one-sided log with a running balance and float
amounts (
creditAdd(100.00)); credits do not expire. - Formance, Blnk, TigerBeetle are ledgers as separate services. They do more, and your business row and the ledger entry cannot commit in one database transaction.
This library is for the case in between: the ledger lives in your database, writes in your transaction, and can prove its balances.
More: docs/usage.md.
Not yet
Laravel and Symfony integration, multi-currency transactions with a rate, refunds of used credits, expiry across all customers in one call, snapshots for very long chains, export to recon, writing an outbox event in the same transaction. Open an issue if you need one of them first.
License
MIT
All versions of ledger with dependencies
brick/math Version ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0