Skip to content
MartinsAI.
All writing
Backend & Architecture

Money Math in PHP: Why BCMath Belongs in Your Ledger, Not Floats

Floating-point drift is invisible in a demo and expensive in production. Notes from building a settlement engine on BCMath-safe arithmetic and optimistic locking.

5 min read

Every payments engineer eventually hits the same bug report: a customer's balance is off by one kobo. Not a naira, not a percent — one kobo, on one transaction, that nobody can reproduce on demand. Nine times out of ten, the root cause is the same: somewhere in the codebase, money is being represented as a native float.

This isn't a Laravel problem or a PHP problem specifically — it's an IEEE 754 problem. Floating-point numbers can't represent most decimal fractions exactly, because they're stored in binary. 0.1 + 0.2 famously doesn't equal 0.3 in almost every mainstream language, PHP included. For a currency converter or a chart, that's a rounding error nobody notices. For a settlement engine reconciling merchant payouts against a ledger, it's a discrepancy that compounds across thousands of transactions until the books don't balance and someone has to explain why.

The fix is arbitrary-precision math, applied consistently

PHP ships with BCMath in the standard library specifically for this: arbitrary-precision arithmetic on numbers represented as strings, not floats. The API is unglamorous — bcadd, bcsub, bcmul, bcdiv, bccomp — but it does exactly one thing well: it never silently loses precision.

// Floats: silently wrong, and the error compounds across transactions.
$total = 0.1 + 0.2; // 0.30000000000000004

// BCMath: exact, at whatever scale you specify.
$total = bcadd('0.1', '0.2', 2); // "0.30"

The scale argument matters more than it looks. Every money-handling function in the codebase needs to agree on how many decimal places it's operating at, or you reintroduce the same class of bug one layer up — a settlement calculated at 2 decimal places compared against a running balance calculated at 4. In a settlement engine, I standardize scale at the boundary (a Money value object that owns the scale and exposes add, subtract, multiply, and compare methods wrapping BCMath) so no call site can pick its own precision.

final class Money
{
    private const SCALE = 4;

    public function __construct(private readonly string $amount) {}

    public function add(Money $other): self
    {
        return new self(bcadd($this->amount, $other->amount, self::SCALE));
    }

    public function isGreaterThan(Money $other): bool
    {
        return bccomp($this->amount, $other->amount, self::SCALE) === 1;
    }

    public function toDisplayString(int $displayScale = 2): string
    {
        return number_format((float) $this->amount, $displayScale);
    }
}

Note that toDisplayString is the only place a float touches this class, and only for formatting — never for computation, and never for anything that gets written back to the database.

Precision solves correctness at rest; concurrency solves correctness under load

A settlement engine doesn't just compute balances, it writes them, and it usually writes them from more than one process at once — a scheduled settlement job and a manual reconciliation action, or two settlement runs racing after a retry. If two processes read a ledger balance, both add their transaction, and both write the result back, one write silently overwrites the other. The arithmetic was exact; the outcome is still wrong.

Optimistic locking closes this gap without taking a database lock for the duration of the read-modify-write cycle. Every ledger row carries a version column. A write only succeeds if the version it's updating matches the version it read; otherwise, the write is rejected and the caller retries against the fresh row.

$affected = DB::table('ledger_accounts')
    ->where('id', $account->id)
    ->where('version', $account->version)
    ->update([
        'balance' => $newBalance->raw(),
        'version' => $account->version + 1,
    ]);

if ($affected === 0) {
    // Someone else updated this row first — reload and retry the operation
    // against the current state rather than silently losing a write.
    throw new StaleLedgerAccountException($account->id);
}

The retry path matters as much as the check. A version mismatch isn't an error condition to log and swallow — it's a signal that the operation needs to run again against current data, which usually means re-running the whole settlement calculation for that account rather than patching in the new version number and hoping the old computed delta still applies.

General-ledger structure gives you an audit trail for free

The last piece is less about arithmetic and more about accounting discipline: every balance change should be the sum of immutable ledger entries, not a single mutable balance field that gets overwritten. A balance column tells you where an account stands right now. A general-ledger table — debit, credit, account, reference, timestamp, and nothing ever updated or deleted after insert — tells you how it got there, which is what you actually need when a settlement figure gets questioned six weeks later.

Schema::create('ledger_entries', function (Blueprint $table) {
    $table->id();
    $table->foreignId('account_id');
    $table->enum('direction', ['debit', 'credit']);
    $table->decimal('amount', 20, 4);
    $table->string('reference'); // settlement batch ID, transaction ID, etc.
    $table->timestamp('created_at');
    // Deliberately no updated_at, no soft deletes: entries are immutable.
});

The current balance is then a derived value — SUM(credits) - SUM(debits) for the account, ideally materialized and updated transactionally alongside each new entry rather than recomputed from the full history on every read, but always reconcilable back to the entries if the materialized value is ever in doubt.

None of this is exotic. BCMath, optimistic locking, and append-only ledgers are all decades-old techniques. The failure mode I keep seeing isn't ignorance of the techniques — it's applying them inconsistently, so one code path does the arithmetic in strings at 4 decimal places and another does it in floats at whatever precision the framework's number_format helper happens to default to. The fix isn't a clever library. It's a Money value object that makes the correct behavior the only behavior available, and a schema that makes "what happened" a query instead of an investigation.

PHPLaravelFinancial SystemsPostgreSQL