Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Money Is Not a Float

  • software-engineering
  • php
  • laravel
  • typescript

Open a console and type this:

0.1 + 0.2; // 0.30000000000000004

Everybody has seen it. Everybody laughs. Then somebody writes this in a checkout flow:

const cents = Math.trunc(0.57 * 100); // 56

That customer just got a one cent discount nobody approved. Multiply that by every order and every line item and you have a reconciliation problem that shows up months later in a spreadsheet from accounting.

PHP does the exact same thing, because it uses the same floating point numbers under the hood:

(int) (0.57 * 100); // 56

This isn't a JavaScript bug or a PHP bug. Floats are binary approximations. They are great for physics and graphics. They are bad at "exactly 57 cents."

Store the smallest unit as an integer

The fix that holds up is boring. Store money as an integer count of the smallest unit. For dollars, that's cents.

// migration
$table->unsignedBigInteger('price_cents');

1999 is $19.99. It never drifts, it adds up exactly, and every database, language, and JSON parser agrees on what an integer is. Stripe's API works this way too. Amounts go in as the smallest currency unit.

A DECIMAL(10, 2) column is also exact in the database, and that's a fine choice. Just know that in PHP it usually comes back as a string, and the moment someone does $price * 1.07 you're back in float land. Integers make the rule obvious.

Name the column with the unit. price_cents tells the next developer what they're holding. price makes them guess, and they'll guess wrong at the worst time.

Convert at the edges, not in the middle

People type dollars. Your code should think in cents. So convert once, on the way in, and don't do it with floats.

In Laravel, validate the shape first, then convert with string math:

$validated = $request->validate([
    'price' => ['required', 'decimal:0,2', 'min:0'],
]);
 
$product->price_cents = (int) bcmul($validated['price'], '100', 0);

bcmul works on strings, so "0.57" becomes 57, not 56. It needs the bcmath extension, so make sure it is installed wherever the app runs.

In TypeScript, parse the string yourself instead of multiplying a float:

export function toCents(input: string): number {
  const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(input.trim());
  if (!match) throw new Error(`Invalid amount: ${input}`);
 
  const [, dollars, fraction = ""] = match;
  return Number(dollars) * 100 + Number(fraction.padEnd(2, "0"));
}
 
toCents("0.57"); // 57
toCents("5.5");  // 550

On the way out, divide for display and nothing else:

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});
 
usd.format(1999 / 100); // "$19.99"

That division is the last thing that happens. The result goes on the screen, not back into a calculation.

Splitting money needs a remainder

Split $100 three ways. Each person gets $33.33, and a penny goes missing.

Integers make the leftover visible, so you can hand it to someone on purpose:

export function splitCents(totalCents: number, parts: number): number[] {
  const base = Math.floor(totalCents / parts);
  const remainder = totalCents % parts;
 
  return Array.from({ length: parts }, (_, i) =>
    base + (i < remainder ? 1 : 0),
  );
}
 
splitCents(10_000, 3); // [3334, 3333, 3333]

The pieces always add back up to the total. That's the whole test. This version assumes a positive total, so handle refunds and negative amounts deliberately instead of hoping it works.

Round once, in one place

Tax, discounts, and percentages will produce fractions of a cent. You have to round somewhere. The trick is picking where, and doing it exactly once.

Also, your languages don't agree on how to round:

round(-2.5); // -3 (PHP rounds halves away from zero)
Math.round(-2.5); // -2 (JS rounds halves up)

So if the frontend calculates a refund and the backend calculates the same refund, they can disagree by a cent. Neither one is wrong. They just follow different rules.

The fix isn't a clever rounding function. It's deciding that the server owns the math. The frontend can show an estimate, but the number that gets charged, stored, and emailed comes from one place.

When to reach for a library

If you're dealing with multiple currencies, exchange rates, or currencies that don't have two decimal places, stop hand-rolling it. Libraries like brick/money or moneyphp/money in PHP and dinero.js in TypeScript exist because this gets hard fast.

For a single currency and a normal checkout, integer cents and a couple of helpers go a long way.

The habit

Store integers. Convert at the edges. Round once. Let one side of the app own the math.

Floats are fine for a lot of things. Somebody's money isn't one of them.

← All posts