Money
A £10 discount, spread across three £5 lines so each line's VAT is right, comes to £3.33, £3.33 and £3.33. That's £9.99. The order says £10 off, the lines say £9.99, and once a month someone in finance spends an afternoon finding the penny.
Money is the value object everyone needs and most codebases get wrong in the same three ways.
1. Floats can't hold pennies
A float is binary. It can hold ½ and ¼ exactly, but not ⅒, so 0.1 + 0.2 is 0.30000000000000004 and 19.99 * 100 is 1998.9999999999998. Rounding at the end hides that most of the time, and "most of the time" is the problem.
Store an integer count of the smallest unit: pence, cents. 1349 is exactly £13.49, adding two of them is exact, and PHPStan can tell an int from a float.
2. An amount without a currency is a guess
1349 is £13.49 or €13.49, and adding one to the other gives a number that's neither. Keep the currency in the same object, and refuse to combine different ones:
public function plus(Money $other): self { if ($other->currency !== $this->currency) { throw new InvalidArgumentException("Can't add {$other->currency->value} to {$this->currency->value}"); } return new self($this->pence + $other->pence, $this->currency); }
Currency is a closed set, so it's an enum (1.6), and !== compares cases safely.
3. Don't divide money. Allocate it
Dividing 1000p by three has no exact answer, so the question is where the spare penny goes. Allocation answers it: work out each share rounded down, then hand the pennies left over to the first shares, one each. The shares always add up to the original amount.
1000p in the ratio 1 : 1 : 1 → 333 + 333 + 333 = 999, 1p left → 334, 333, 333
5p in the ratio 70 : 30 → 3 + 1 = 4, 1p left → 4, 1
Ratios cover splitting a bill evenly (1, 1, 1) and proportionally: spreading a discount by each line's price is allocate(...$linePrices).
| round(10 / 3, 2) × 3 | £9.99 |
|---|---|
| allocate(1, 1, 1) | £3.34 + £3.33 + £3.33 = £10.00 |
Power-up
<?php final readonly class Money { public function __construct(public float $amount) {} // pounds, and pounds of what? /** @return list<Money> */ public function split(int $parts): array { $share = round($this->amount / $parts, 2); return array_fill(0, $parts, new self($share)); // 10.00 / 3 → 3.33 × 3 = 9.99 } }
<?php final readonly class Money { public function __construct(public int $pence, public Currency $currency) {} /** @return list<Money> shares that always add up to this amount */ public function allocate(int ...$ratios): array { $total = array_sum($ratios); $roundedDown = array_map(fn (int $ratio): int => intdiv($this->pence * $ratio, $total), $ratios); $left = $this->pence - array_sum($roundedDown); $shares = []; foreach ($roundedDown as $i => $pence) { $shares[] = new self($i < $left ? $pence + 1 : $pence, $this->currency); // 334, 333, 333 } return $shares; } }