Float у PHP - це C-шний double, тобто 64-бітне число за IEEE 754: знак, 11 біт експоненти і 53 біти мантиси. Мантиса двійкова, тож точно представляються тільки дроби зі знаменником-степенем двійки: 0.5, 0.25, 0.125. Десяткова 0.1 у двійковій системі періодична так само, як 1/3 у десятковій, тому в памʼять лягає найближче доступне наближення. Складіть два наближення, отримаєте третє: 0.1 + 0.2 дорівнює 0.30000000000000004, а не 0.3. Помітити це заважає форматування. echo друкує число з ini-директивою precision=14 і чесно показує 0.3, тоді як var_dump() і json_encode() працюють через serialize_precision=-1, тобто виводять найкоротший рядок, який при зворотному читанні дає те саме число, і хвіст виринає. Дві константи тут згодяться: PHP_FLOAT_DIG (15 надійних десяткових цифр) і PHP_FLOAT_EPSILON (2.22e-16).
Для грошей це болить двічі. Похибка накопичується: тисяча додавань у циклі зсуває підсумок, і звірка з банком не сходиться. А на межах вона ще й асиметрична: (int) (19.99 * 100) дає 1998, бо добуток трохи менший за 1999, а зведення до int не округлює, а відкидає дробову частину. Причому та сама операція для 9.99 чи 1.15 може спрацювати правильно, тож баг живе в коді місяцями і стріляє на конкретних цінах. Якщо float уже прийшов ззовні, мінімальний захист - (int) round($value * 100), хоч це й лікування симптому.
Правильних варіантів зберігання небагато. Найпростіший - цілі копійки в int: PHP_INT_MAX на 64-бітних системах дорівнює 9223372036854775807, запасу вистачить на будь-який бюджет, а арифметика лишається нативною і швидкою. У базі це BIGINT, у коді - маленький value object на кшталт Money з прикладу. Альтернатива - десятковий тип: DECIMAL(13,2) у MySQL або numeric у PostgreSQL. Тут точне десяткове зберігання, SUM() по таких колонках теж точний, а драйвери PDO повертають значення рядком, тому кастити результат у (float) не варто: це знищує все, заради чого колонку робили десятковою. Чого робити не можна, так це тримати суму в FLOAT/DOUBLE чи в PostgreSQL-типі money, вивід якого залежить від lc_monetary.
Коли дроби в обчисленнях таки потрібні (відсотки, курс валют, ПДВ), у PHP є bcmath - арифметика довільної точності над рядками. Класичні bcadd(), bcsub(), bcmul(), bcdiv(), bccomp() приймають третім аргументом scale, і найчастіше його просто забувають: типовий scale дорівнює нулю, тому bcadd('0.1', '0.2') повертає '0'. У PHP 8.4 розширення нарешті стало зручним. Зʼявився клас BcMath\Number: незмінний обʼєкт із readonly-полями value і scale, приймає string|int, підтримує оператори +, -, *, /, %, ** і порівняння, сам обчислює масштаб результату та реалізує Stringable і JsonSerializable. Арифметика з float для нього заборонена і кидає TypeError, щоб похибка не пролізла обхідним шляхом. Разом із класом додали bcround(), bcfloor() і bcceil().
Далі йде шар, до якого джуни зазвичай не доходять: валюта і момент округлення. Сума без валюти неповна, додати 100 UAH до 100 USD не можна, і клас, який дозволяє це зробити мовчки, рано чи пізно породить неправильний рахунок. Кількість дробових знаків теж не універсальна: ISO 4217 задає нуль для JPY, три для KWD і BHD, два для UAH, тому «множимо на 100» - припущення, а не закон. Правило округлення обирайте свідомо: round() у PHP типово округлює половину від нуля, а з PHP 8.4 приймає RoundingMode і вміє банківське HalfEven, яке не зміщує похибку в один бік на великих обсягах. Округлюють один раз, у точці, де число стає платежем. А розбиття суми між кількома отримувачами - це intdiv() плюс роздача залишку по копійці, а не ділення з наступним округленням кожної частки.
Забороняти float назовсім теж не варто. Для аналітики, графіків, середніх і прогнозів він доречний і швидкий, бо там похибка в шістнадцятому знаку нікому не заважає. І точність має свою ціну: bcmath рахує рядками й помітно повільніший за нативний int, тому в гарячому циклі копійки в int виграють. Звідси звичний продакшн-компроміс: зберігати в BIGINT або DECIMAL, рахувати в цілих копійках, вмикати bcmath чи BcMath\Number лише там, де потрібні відсотки й курси, а форматувати для користувача через NumberFormatter з ext-intl у режимі CURRENCY. Якщо проєкт на PHP до 8.4 або потрібна готова модель із валютою, курсами й розподілом залишків, беріть moneyphp/money замість власного велосипеда.
declare(strict_types=1);
use BcMath\Number;
var_dump(0.1 + 0.2 === 0.3); // false: точних 0.1 і 0.2 у double немає
echo 0.1 + 0.2, PHP_EOL; // 0.3 - хвіст ховає ini-директива precision=14
var_dump(0.1 + 0.2); // float(0.30000000000000004)
var_dump((int) (19.99 * 100)); // 1998, бо 19.99 * 100 = 1998.9999999999998
// Варіант 1: цілі копійки, уся арифметика в int
final readonly class Money
{
public function __construct(public int $cents, public string $currency) {}
public function add(self $other): self
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('UAH і USD не додаються');
}
return new self($this->cents + $other->cents, $this->currency);
}
/** @return list<self> ділимо суму на частини, не загубивши залишок */
public function split(int $parts): array
{
$base = intdiv($this->cents, $parts);
$rest = $this->cents % $parts; // роздаємо по одній копійці
return array_map(
fn (int $i): self => new self($base + ($i < $rest ? 1 : 0), $this->currency),
range(0, $parts - 1),
);
}
}
$total = (new Money(1999, 'UAH'))->add(new Money(50, 'UAH'));
echo $total->cents, PHP_EOL; // 2049, без жодного float на шляху
// Варіант 2: BcMath\Number з PHP 8.4 - десятковий дріб як обʼєкт
$price = new Number('19.99');
$vat = $price * new Number('0.20'); // 3.9980: масштаб порахувався сам
echo $vat->round(2, RoundingMode::HalfEven), PHP_EOL; // 4.00
var_dump($price->compare(new Number('19.990'))); // int(0): значення рівні
Одна фраза, яка закриває питання: «float - це двійковий дріб, а гривня з копійками - десятковий, тому 19.99 у double не існує; я тримаю суму в цілих копійках або в DECIMAL і рахую bcmath». Далі назвіть `BcMath\Number` із PHP 8.4 і скажіть, що валюта й правило округлення для вас частина типу, а не сусіднє поле, - це вже рівень, який рідко чують від джуна.