Handling money in PHP: a practical guide for developers
Currency calculations are deceptively complex. You'll encounter rounding errors, precision loss, and subtle bugs that only emerge at scale if you're not careful with how you represent money in your PHP applications. This guide covers the practical patterns we've learned handling payments across e-commerce, SaaS, and fintech projects.
Why floats fail for currency
PHP's float type uses IEEE 754 binary floating-point representation, which cannot accurately represent many decimal values. Let's see what happens when we work with money directly as floats:
<?php
$price = 0.1;
$quantity = 3;
$total = $price * $quantity;
var_dump($total === 0.3); // false
echo $total; // 0.30000000000000004This isn't theoretical. In production, these tiny errors compound. Calculate taxes, apply discounts, process refunds—and your account balances no longer reconcile. Your finance team stops trusting the system. Your auditors get angry.
The solution sounds simple but requires discipline: never use floats for money. Period.
Store amounts in minor units as integers
Every currency has a minor unit—cents for USD, pence for GBP, fils for AED. Store all monetary values as integers in their minor unit. This eliminates floating-point arithmetic entirely.
USD example: $19.99 becomes 1999 (cents). EUR example: €15.50 becomes 1550 (cents).
Integer arithmetic is deterministic. No rounding surprises. No precision loss.
<?php
// Store as integer cents
$product_price_cents = 1999; // $19.99
$quantity = 3;
$total_cents = $product_price_cents * $quantity;
var_dump($total_cents); // int(5997)
echo "Total: $" . ($total_cents / 100); // Total: $59.97Apply tax or discounts using integer operations:
<?php
$price_cents = 1999;
$tax_rate_basis_points = 875; // 8.75%
$tax_cents = intdiv($price_cents * $tax_rate_basis_points, 10000);
$total_with_tax = $price_cents + $tax_cents;
var_dump($total_with_tax); // Clean integer, no ambiguityUse basis points (1/100th of a percent) for tax and discount rates. This keeps everything integer-based and explicit. A 8.75% tax rate is 875 basis points—store it as an integer.
Use brick/money or moneyphp
Handling integers manually across your codebase is error-prone. Libraries like brick/money and moneyphp encapsulate these patterns and provide type safety.
We typically reach for brick/money in modern projects. It's actively maintained, has excellent precision handling, and supports arbitrary-precision arithmetic through the brick/math library.
<?php
use Brick\Money\Money;
// Create money objects
$price = Money::of('19.99', 'USD');
$quantity = 3;
// Multiply safely
$total = $price->multipliedBy($quantity);
echo $total; // USD 59.97
// Add with another currency (converts or throws)
$tax = Money::of('5.25', 'USD');
$final = $total->plus($tax);
echo $final; // USD 65.22The library enforces currency awareness—you can't accidentally add dollars to euros. It handles rounding consistently using the HALF_UP strategy (the standard for financial software).
<?php
use Brick\Money\Money;
use Brick\Math\RoundingMode;
$price = Money::of('10.00', 'USD');
// Split evenly among 3 people
$per_person = $price->dividedBy(3, RoundingMode::HALF_UP);
echo $per_person; // USD 3.33
echo $per_person->multipliedBy(3); // USD 9.99 (not 10.00)
// The remainder must be handled explicitly
$remainder = $price->minus($per_person->multipliedBy(3));
echo $remainder; // USD 0.01This explicitness is intentional. It forces developers to think about rounding and remainders rather than sweeping them under the rug.
Keep currency with every amount
Never pass bare numbers around your system. Always pair amounts with their currency. This prevents category errors—mixing USD with EUR, multiplying amounts in different currencies, or losing track of what a number actually represents.
In a relational database, store both the amount and currency together:
| Column | Type | Notes |
|---|---|---|
| amount_cents | BIGINT | Always in minor units |
| currency_code | CHAR(3) | ISO 4217 code: USD, EUR, GBP |
At the application level, use value objects or library classes:
<?php
class OrderTotal
{
private Money $amount;
public function __construct(Money $amount)
{
$this->amount = $amount;
}
public function add(Money $other): self
{
return new self($this->amount->plus($other));
}
public function getAmount(): Money
{
return $this->amount;
}
}
// Usage
$total = new OrderTotal(Money::of('100.00', 'USD'));
$tax = Money::of('8.75', 'USD');
$final = $total->add($tax);
// Can't accidentally do $total->add(Money::of('10.00', 'EUR'))Type hints ensure the currency context travels with the value through your domain logic. Refactoring becomes safer. Tests catch mistakes earlier.
Round only at display time
Keep all intermediate calculations in exact precision. Round only when displaying to users or writing to external systems that require a specific format.
<?php
use Brick\Money\Money;
$item_price = Money::of('10.00', 'USD');
$items_count = 3;
$subtotal = $item_price->multipliedBy($items_count);
// Internal: exact calculation
$tax_rate = '0.0875'; // 8.75% as string for precision
$tax = $subtotal->multipliedBy($tax_rate)->to(RoundingMode::HALF_UP, 2);
// Display to user
echo "Subtotal: " . $subtotal->formatTo('en_US'); // USD 30.00
echo "Tax: " . $tax->formatTo('en_US'); // USD 2.63
echo "Total: " . $subtotal->plus($tax)->formatTo('en_US'); // USD 32.63Store the exact (unrounded) values in your database. If a customer disputes a charge, you have the precise calculation history. If regulations change and you need to recalculate taxes, the raw data is there.
Rounding at display time also means your API responses are consistent. A user viewing the same invoice on desktop, mobile, or through a partner integration sees the same numbers—they're all pulling the same unrounded source data and displaying it identically.
Common mistakes
- Storing money as DECIMAL without specifying precision: Use
DECIMAL(19,4)at minimum. Better yet, use BIGINT and store in minor units. - Forgetting to round in application code: Rounding should be explicit and centralized, not scattered throughout calculations.
- Mixing currencies silently: Catch this at type-check time with value objects, not at runtime.
- Converting to float for display: Use the library's formatting methods or manual string concatenation. Don't round and convert to float.
- Trusting JSON serialization: JSON represents floats, not arbitrary precision. Convert Money objects to strings or structured arrays before serializing.
- Ignoring remainder in splits: When dividing money among recipients, the remainder must go somewhere. Handle it explicitly or fail loudly.
Key takeaways
- Never use PHP's native float type for monetary amounts. Use integers (minor units) or a dedicated money library.
- Store all currency amounts in your database as integers representing minor units (cents, pence, fils) alongside an ISO 4217 currency code.
- Use
brick/moneyormoneyphpfor type-safe arithmetic, rounding control, and currency validation. - Always keep currency paired with amount throughout your application. Use value objects or library classes to enforce this.
- Perform all calculations at full precision. Round only when displaying to users or exporting to external systems.
- Make rounding explicit and centralized. Never let implicit rounding hide in intermediate calculations.
- Test your money handling thoroughly—edge cases around tax, discounts, and splits cause real financial bugs.






Comments
Loading comments…