Skip to content
Backend Infrastructure8 min

Where the rand goes: float accumulation behind an ORM decimal cast

`'amount' => 'decimal:2'` looks like a money type. It is a display formatter. Every calculation between load and save still runs in binary floating point — and the error compounds.

Failure modeAn ORM `decimal:2` cast is applied only at serialisation. Internal arithmetic runs in IEEE-754 floats, so rounding error accumulates across many operations and the reported totals drift from the true sum.

Commercial risk

One hundred line items of R1.33 should total R133.00. Rounded independently in float, they total R132.99 or R133.01 — and that cent, multiplied across a forecasting model or a reconciliation run, is the number your finance team cannot make balance.

The failure is not a crash; it is a slow erosion of trust in the numbers. Once a monthly reconciliation is off by cents that nobody can source, every downstream figure — tax, commission, forecast — inherits the doubt, and the cost is measured in accountant-hours and audit findings, not exceptions.

Almost every ORM offers a `decimal` cast, and almost every team reads it as "this column is money, handled correctly." It is not. In Eloquent, `decimal:2` calls `number_format()` when the attribute is read for output. It does nothing to the value while your code is doing arithmetic on it. Between the moment the model is hydrated and the moment it is saved, the value lives as a PHP float — binary floating point, the exact representation that cannot store 0.10 precisely.

This is one of the highest-consequence, lowest-visibility failure modes in financial software, because it never throws. Here is where it hides and how to build money that actually adds up.

1. The cast that formats but does not compute

Consider a forecast model with twelve monthly values, each cast `decimal:2`. When you sum them in PHP — `$forecast->months->sum('value')` — the ORM hands you twelve floats, PHP adds them in binary, and the result carries whatever representation error binary addition produces. The `decimal:2` cast only re-enters the picture when you read a single attribute back out for display, long after the sum is computed.

The percentage helpers are worse. `round(($completed / $total) * 100)` runs an integer division promoted to float, multiplies, and rounds once at the end — fine in isolation, but chain several of these across a report and each intermediate rounding nudges the total. The database column being `DECIMAL(10,2)` does not save you either: the value is correct at rest, wrong in motion, and re-rounded on write, so the error is baked in permanently.

// Looks like money. Computes like float.
class Forecast extends Model
{
    protected $casts = [
        'value' => 'decimal:2', // formatting on read — NOT arithmetic safety
    ];
}

// This sum runs in binary floating point:
$total = $forecast->months->sum('value'); // 132.99999999999997
echo number_format($total, 2);            // "133.00" — hides the drift
// ...until the same drift is summed across 10,000 rows and reported.

2. Nullable money and the missing constraint

The second half of this failure mode is the schema. Money columns are frequently `nullable` with no `CHECK` constraint, because the ORM makes it easy and nobody pushes back. A nullable price means arithmetic can silently coerce `null` to `0`, turning a missing value into a real zero that flows into a total as if it were a genuine free item.

Worse, without a non-negative constraint, a sign error upstream can persist a negative price that inverts a subtotal. There is no guard rail: the database will store `-499.00` as happily as `499.00`, and the first time anyone notices is when a refund report shows a customer being owed money they never paid.

3. Integer minor units, or a real decimal library

The robust fix is to stop storing money as a decimal and store it as an integer count of minor units — cents. Every amount is a `BIGINT` of cents, all arithmetic is integer arithmetic (exact by definition), and conversion to a display string happens once, at the very edge, in the view layer. This is what payment processors do internally, and it is why their totals always balance.

If ripping out a decimal schema is too invasive, the interim fix is a decimal library — PHP's `BCMath` or a value object like `brick/money` — used for every operation, never a raw float. The rule is absolute: money never touches a native float, not for a sum, not for a percentage, not for a tax line. Pair that with `NOT NULL` and a `CHECK (amount >= 0)` constraint at the schema level so the database refuses to store a value your domain considers impossible.

// Money as integer minor units — arithmetic is exact.
// Schema: amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0)

final class Money
{
    public function __construct(public readonly int $cents) {}

    public function plus(Money $other): self
    {
        return new self($this->cents + $other->cents); // integer, exact
    }

    public function allocate(int $parts): array
    {
        // Distribute remainder deterministically — no cent goes missing.
        $base = intdiv($this->cents, $parts);
        $remainder = $this->cents % $parts;
        return array_map(
            fn ($i) => new self($base + ($i < $remainder ? 1 : 0)),
            range(0, $parts - 1),
        );
    }

    public function format(): string { return number_format($this->cents / 100, 2); }
}

An ORM decimal cast is a formatter wearing the costume of a money type. It makes the value look right on the way out while every calculation in between runs in the one number format guaranteed to lose cents. In a financial system that is not a cosmetic issue — it is the difference between books that reconcile and books that don't.

Store money as integer minor units, do arithmetic in integers or a decimal library, and let the database enforce non-null and non-negative. Then the cent that used to go missing has nowhere to hide.

Need this level of structural integrity engineered for your system?

We audit and re-architect systems where a single one of these failure modes costs real money. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint