skip to content

A PHP point-of-sale system's float currency totals drift by a cent; what causes it, and how do you fix the money arithmetic?

level: seniorimportance: should knowfreq 42%

answer

  1. prices as floats accumulate error
  2. (int) truncates 1998.9999...
  3. integer minor units
  4. bcmath: strings, scale, truncation
  5. BcMath\Number in 8.4, round once

basics

~20 s

Floats cannot store most decimal prices exactly, so sums, tax multiplications and (int) conversions drift by a cent. Keep money as integer cents or as bcmath decimal strings (BcMath\Number since PHP 8.4), and round once, at a defined step, with an explicit RoundingMode.

solid answer

~40 s

The drift comes from storing prices as `float`: `19.99` is really `19.989999999999998...`, so `(int) (19.99 * 100)` is `1998`, and adding ten `0.10` items gives `0.9999999999999999`. Rounding each line, then summing rounded floats, compounds it. Fix the representation, not the display. Either store **integer minor units** (cents) and do `int` arithmetic, converting at the edges with `(int) round($x * 100)` exactly once; or use **bcmath** with numeric strings and an explicit scale, remembering that bc functions **truncate** to the scale and default to `bcmath.scale=0`, so `bcmul('19.99', '0.2')` is `"3"`. PHP 8.4's immutable `BcMath\Number` adds operator overloading, automatic result scale and `->round(2, RoundingMode::HalfEven)`, and ignores `bcmath.scale`. Keep amounts as strings from the database, compare with `bccomp()` or `Number` comparisons, and round tax once per receipt with a documented mode.

code

php · 12 lines
php
<?php
var_dump((int) (19.99 * 100));          // int(1998): the lost cent
var_dump((int) round(19.99 * 100));     // int(1999)

$total = 0.0;
foreach (array_fill(0, 10, 0.10) as $price) {
    $total += $price;
}
var_dump($total);                        // float(0.9999999999999999)

var_dump(bcmul('19.99', '0.2'));        // string(1) "3": default scale 0
var_dump(bcmul('19.99', '0.2', 2));     // string(4) "3.99": truncated

go deeper

for a junior

Recall that floats cannot store most prices exactly and that money should be kept as integer cents or decimal strings, not floats.

for a middle

Explain how (int) truncation and accumulation lose cents, bcmath's string operands, explicit scale and truncation, and what BcMath\Number adds in PHP 8.4.

for a senior

Fix the representation end to end: strings from storage, one rounding step with an explicit mode, integer splitting with remainders, and reconciliation to catch stray float paths.

for a principal

Own the money model across services: currency minor units, rounding policy, the decimal library, and how legacy float code is migrated without changing historical totals.

## Where the cent goes A point-of-sale system that holds prices in PHP `float` variables is doing binary arithmetic on decimal amounts. Most prices have no exact binary form, so each is stored as the nearest double, and three things turn those tiny errors into visible cents: 1. **Truncating conversions.** `19.99 * 100` is `1998.9999999999998`, and `(int)` truncates toward zero, so the line is recorded as `1998` cents. 2. **Accumulation.** Summing many small prices adds their errors: ten `0.10` items sum to `0.9999999999999999`, which a later truncation or `floor()` turns into `0.99`. 3. **Rounding at the wrong step.** Rounding tax per line and then summing gives a different total from rounding once on the receipt; mixing the two in different code paths makes two reports disagree by a cent. `echo` hides all of this, because it prints floats with 14 significant digits; `var_dump()` shows the stored values. ## Fix 1: integer minor units Store every amount as an `int` count of the smallest unit (cents for most currencies): - Prices, line totals and receipts are `int`; addition and multiplication by quantities are exact. - Convert from a decimal string or float **once**, at the boundary, with `(int) round($amount * 100)`, never with a bare `(int)`. - Percentages such as tax need one rounding step: `intdiv()` does not round, so compute `$taxCents = (int) round($netCents * $rate, 0, RoundingMode::HalfEven)` at the step the business rule names. - Format for display only at the very end. This is fast and simple, but multiplying by fractional rates still passes through a float once per rounding step, and currencies with different minor units need a per-currency factor. ## Fix 2: bcmath decimal strings The bcmath extension computes on **numeric strings** of any length with a chosen **scale** (digits after the point): | Call | Result | Why | |---|---|---| | `bcadd('19.99', '0.01', 2)` | `"20.00"` | exact decimal addition | | `bcmul('19.99', '0.2')` | `"3"` | default scale is `bcmath.scale`, `0` unless configured | | `bcmul('19.99', '0.2', 2)` | `"3.99"` | `3.998` **truncated** to two places, not rounded | | `bcround('3.998', 2)` | `"4.00"` | rounding is a separate step (PHP 8.4+) | Two traps are visible in the table: the global default scale of `0`, and **truncation**. Always pass the scale explicitly, compute with extra scale, and round once at the end with `bcround()`, which since PHP 8.4 accepts a `RoundingMode`. Compare amounts with `bccomp()`, not with `==` on floats. ## Fix 2b: BcMath\Number (PHP 8.4+) `BcMath\Number` is a `final readonly` class wrapping an arbitrary-precision decimal: - construct it from a **string or int**, never a float: the constructor is declared `string|int`, so `new Number(0.1234)` in a coercive file becomes `0` with a deprecation, and a float operand in `$n + 1.01` is likewise truncated to `int`; - it supports `+`, `-`, `*`, `/`, `%`, `**` and comparison operators; - results carry an automatic **scale**: addition keeps the larger operand scale, multiplication uses the sum of scales, division expands as needed up to ten extra digits; - it is **not** affected by the `bcmath.scale` directive; - `->round(2, RoundingMode::HalfEven)` rounds explicitly, and `(string)` or `->value` gives the decimal string. ```php <?php declare(strict_types=1); use BcMath\Number; $subtotal = new Number('0'); foreach (['19.99', '5.01', '0.10'] as $price) { $subtotal = $subtotal + new Number($price); // scale 2 } $tax = ($subtotal * new Number('0.0825'))->round(2, RoundingMode::HalfEven); $total = $subtotal + $tax; echo $subtotal, ' + ', $tax, ' = ', $total, PHP_EOL; // 25.10 + 2.07 = 27.17 ``` ## Making the fix stick - **Read amounts as strings** from the database and from request input, and never pass them through `floatval()` or `(float)` on the way in. - **Define the rounding rule** — per line or per receipt, which mode — in one place, and test it with the awkward cases: halves, many small items, refunds. - **Keep one representation per layer**, so that no code path quietly converts cents back to a float to "do the maths". - **Reconcile**: a nightly total computed independently in the database catches any path still using floats.

  • In PHP, why is new BcMath\Number(19.99) a mistake even though the class exists for exact decimals?
    The constructor is declared `string|int`, so a float is converted to `int`: in a coercive file `new BcMath\Number(19.99)` becomes `19` with an `Implicit conversion from float` deprecation, and in a strict file it throws `TypeError`. Pass the decimal as a string, `new BcMath\Number('19.99')`, read from the database or request as text.
  • In PHP, why can bcmul('19.99', '0.2', 2) and a float calculation rounded to two places disagree?
    bcmath truncates to the requested scale, so `3.998` becomes `"3.99"`, whereas `round(19.99 * 0.2, 2)` rounds to `4.0`. Neither is wrong; they apply different rules. Compute with extra scale and round once with `bcround()` or `Number::round()` in the mode the business rule names.
  • In a PHP system storing cents as integers, how do you split 1000 cents three ways without losing a cent?
    Use integer division and distribute the remainder: `intdiv(1000, 3)` is `333` for each share, and `1000 % 3` is `1`, which goes to one share, giving 334, 333, 333. The shares always sum to the original, which rounding each third of `10.00` as a float cannot guarantee.

saying these in an interview costs you the question

  • Fixes the drift by formatting totals with number_format() for display.
  • Converts prices to cents with a bare (int) cast.
  • Believes bcmath functions round to the given scale.
  • Creates BcMath\Number objects from float literals.
  • Rounds every intermediate step instead of once at a defined point.
  • Relies on bcmath.scale instead of passing the scale explicitly.