How I Deal with Money in PHP
As a developer we often have to deal with monetary values. And over the years I have seen many approaches to dealing with money - some better, some worse.
So, here is how I deal with money in my applications. Specifically, I'll be talking about Laravel.
Why not Money PHP?
Very good question! Money PHP is a great package, and for most people it will fit the bill. However, for me there are a two reasons why I don't usually use it when working with money in Laravel:
- I want tight integration with Laravel, and particularly with my
Currency
model, which allows me to do cross-currency maths directly in SQL. - To some extent the Money PHP package is just a bit complicated: My simplistic setup makes currency conversion, and dealing with multiple currencies a lot simpler in my opinion.
Currencies
The first thing I do, whenever I need to deal with money, is to think about currencies. Most projects start out by thinking that they don't need multiple currencies, and thus just store the numerical value in the database, without any currency information, and that's the first mistake: Almost always these projects will require multi-currency-support somewhere down the line. As such I always start with multiple currencies in mind.
So, one of the very first models I create in most of my projects is a Currency
model. Apart from a primary key, this model has only a few attributes:
-
rate
is the exchange rate, relative to my base currency. -
code
is the 3 letter ISO code. -
symbol
is the currency symbol as shown on the app, e.g.£
,$
, etc.
It also has two static
methods:
-
::find(int $id)
overrides the Query Builder's::find
to enable caching: We need to get currencies all the time, and this will prevent us having to hit the database every time. -
::fromCode(string $code)
allows us to get a single currency from its 3-letter code, again using caching, so as not to hit the database every time.
Below is the code for that model:
(NB: I'm omitting namespaces throughout this post for brevity)
/**
* @property string $code
* @property string $rate
* @property string|null $symbol
* @property-read string $prefix
*/
class Currency extends Model
{
protected $casts = [
'rate' => 'float',
];
public function prefix(): Attribute
{
return Attribute::make(
get: fn() => $this->symbol ?? $this->code
);
}
public static function find($id, $columns = ['*']): self
{
return Cache::remember(
'currency-id' . $id . '-cols-' . (is_string($columns) ? $columns : json_encode($columns)),
now()->addHours(2),
fn() => self::query()->findOrFail($id, $columns),
);
}
public static function fromCode(string $code): self
{
return Cache::remember(
'currency-code-' . $code,
now()->addHours(2),
fn() => self::where('code', $code)->firstOrFail(),
);
}
}
Now, if you currently only need a single currency in your app, simply insert your base currency as the only record, with a rate of 1.0
.
If (or when) you need multi currency support, you can use any of the many currency exchange rate providers to get a list of supported currencies and their rates, and update those on an hourly / daily / weekly basis, depending on your requirements - no further architectural changes will be needed.
One advantage of having currencies with their exchange rates in my database, rather than in memory, is that I can do operations like adding up prices, sorting, etc in SQL, even if this involves items in different currencies, simply by join
ing against the currencies
table, and multiplying figures with their currency's rate
. That's pretty neat.
One gotcha here: Make sure you give the rate
column plenty of decimals in your database: I had to increase mine recently to decimal(18,6)
, when the Venezuelan currency crashed. Hyperinflation is a thing ...
You'll also want to give your code
column a unique
constraint.
Money
Secondly, I'll create a Money
class. This class is really the meat of it all. It has just two properties:
-
amount
as aninteger
. This is really important: Never represent money values as decimals / floats in PHP. Doing so will cause nasty rounding issues, and you might end up adding up prices wrong, and getting overall weird results. (This is nothing to do with PHP, really: the same is true for floats in all languages.) Always use integers representing minor units (such as cents, pence, etc). -
currency
is the currency, and can be supplied either as as aCurrency
object, or a three letter code.
Additionally I add a number of methods:
-
__toString()
allows me to easilyecho
aMoney
object nicely formatted. -
convertTo(Currency|string $currency)
allows me to easily convertMoney
into another currency (again, supplied either as three letter code, or asCurrency
object). - various maths functions for arithmetics, comparison, rounding, etc that all work cross currency (using the
convertTo
method mention above).
Here is just some of the stuff this allows me to do:
$fivePounds = new Money(amount: 500, currency: 'GBP');
$fiveEuros = new Money(amount: 500, currency: 'EUR');
$fiveDollars = new Money(amount: 500, currency: 'USD');
echo $fivePounds;
// £5.00
$fivePoundsInEuros = $fivePounds->convertTo('EUR');
echo $fivePoundsInEuros;
// €5.69
$fivePounds->eq($fivePoundsInEuros);
// true
$fivePounds->isCurrency('EUR');
// false
$fivePounds->gt($fiveEuros);
// true
echo Money::max(
$fiveDollars,
$fivePounds,
$fiveEuros,
);
// £5.00
echo $fivePounds->add($fiveEuros);
// £8.53
echo (new Money(amount: 51212123, currency: 'GBP'))->roundTo100s();
// £512,100.00
And this is the whole class:
class Money {
public readonly Currency $currency;
public readonly int $amount;
public function __construct(
int $amount,
Currency|string $currency,
)
{
$this->amount = $amount;
if(is_string($currency)) {
$currency = Currency::fromCode($currency);
}
$this->currency = $currency;
return $this;
}
public function __toString(): string
{
return $this->currency->prefix() . number_format($this->amount / 100, 2);
}
public function convertTo(Currency|string $currency): self
{
if ($this->isCurrency($currency)) {
return $this;
}
if (is_string($currency)) {
$currency = Currency::fromCode($currency);
}
return new self(
round($this->amount * $currency->rate / $this->currency->rate),
$currency,
);
}
public function add(self $money): self
{
return new self(
$this->amount + $money->convertTo($this->currency)->amount,
$this->currency,
);
}
public function sub(self $money): self
{
return new self(
$this->amount - $money->convertTo($this->currency)->amount,
$this->currency
);
}
public function multiply(int|float $multiplier): self
{
return new self(
$multiplier * $this->amount,
$this->currency,
);
}
public function divide(int|float $divisor): self
{
return new self(
round($this->amount / $divisor),
$this->currency,
);
}
public function ratio(self $divisor): float
{
return $this->amount / $divisor->convertTo($this->currency)->amount;
}
public function abs(): self
{
return new self(
abs($this->amount),
$this->currency,
);
}
public function roundTo10s(): self
{
return $this->roundSignificantDigits(2);
}
public function roundTo100s(): self
{
return $this->roundSignificantDigits(3);
}
public function roundSignificantDigits(int $digits): self
{
return new self(
round($this->amount / (pow(10, $digits - 1) * 100)) * pow(10, $digits - 1) * 100,
$this->currency,
);
}
public static function max(...$args): self
{
$max = $args[0];
foreach ($args as $i => $arg) {
if (!($arg instanceof self)) {
throw new InvalidArgumentException(
sprintf('Argument [%s] is not of type [%s]', $i, self::class)
);
}
if ($arg->gt($max)) {
$max = $arg;
}
}
return $max;
}
public static function min(...$args): self
{
$min = $args[0];
foreach ($args as $i => $arg) {
if (!($arg instanceof self)) {
throw new InvalidArgumentException(
sprintf('Argument [%s] is not of type [%s]', $i, self::class)
);
}
if ($arg->lt($min)) {
$min = $arg;
}
}
return $min;
}
public function gt(self $comparison): bool
{
return $this->amount > $comparison->convertTo($this->currency)->amount;
}
public function lt(self $comparison): bool
{
return $this->amount < $comparison->convertTo($this->currency)->amount;
}
public function gte(self $comparison): bool
{
return $this->amount >= $comparison->convertTo($this->currency)->amount;
}
public function lte(self $comparison): bool
{
return $this->amount <= $comparison->convertTo($this->currency)->amount;
}
public function eq(self $comparison): bool
{
return $this->amount === $comparison->convertTo($this->currency)->amount;
}
public function neq(self $comparison): bool
{
return !$this->eq($comparison);
}
public function isCurrency(string|Currency $currency): bool
{
if ($currency instanceof Currency) {
return $this->currency->code === $currency->code;
}
return $this->currency->code === $currency;
}
}
Prices
Finally, how do I store prices in the database? For each item with a price I'll need two columns: An integer type column holding the price
in minor units (cents/pence/etc), and another column for the currency_id
, referencing the id
column of the currencies
table.
I am then using an attribute cast, to cast the combination of price
integer, and currency_id
foreign key into a Money
object:
class MoneyCast implements CastsAttributes
{
public function __construct(
private readonly string $currencyField = 'currency_id',
)
{
}
public function get($model, $key, $value, $attributes): Money
{
return new Money(
amount: $value,
currency: Currency::find($attributes[$this->currencyField]),
);
}
public function set($model, $key, $value, $attributes): array
{
if (!($value instanceof Money)) {
throw new InvalidArgumentException(
sprintf('value must be of type %s', Money::class)
);
}
return [
$key => $value->amount,
$this->currencyField => $value->currency->id,
];
}
}
It's quite straightforward, really, but now we can transparently get and set and get an item's price as Money
objects. Money amounts will be stored as integers in the database, and the currency as a foreign key referencing the currencies
table, which leads me to this sample Product
model:
/**
* @property int $id
* @property int $currency_id
* @property MoneyDto $price
* @property string $sku
* @property string $name
* @property-read Currency $currency
*/
class Product extends Model
{
protected $casts = [
'price' => MoneyCast::class,
];
public function currency(): BelongsTo
{
return $this->belongsTo(Currency::class);
}
}
Here is how I'd use that in practice:
$product = new Product([
'sku' => '123',
'name' => 'Cool Product',
'price' => new Money(amount: 12310, currency: 'GBP'),
]);
echo $product->name . ' - ' . $product->price;
// Cool Product - £123.10
or, maybe you want to display prices in a different currency:
$product = Product::where('sku', '123')->first();
echo $product->name . ' - ' . $product->price->convert('EUR');
// Cool Product - €140.05
Market Spread
One major caveat to this method: Currency markets almost always have a different buy and sell rate for any given currency pair (known as spread). I'm ignoring this here. The moment you need to deal with these, caching your exchange rate in your database will be unlikely to provide you with sufficiently accurate and up-to-date rates anyway, so you will not be able to use this approach.
I'm instead assuming some sort of 'mid-market-rate' that will allow me to simply compare prices cross currencies using a single rate.
Addendum: Maths in SQL
I mentioned that storing monetary values in the database like this, with their currencies and exchange rates, allows me to do maths and sorting in SQL.
E.g. here is sorting by price:
SELECT products.*
FROM products
JOIN currencies ON products.currency_id = currencies.id
ORDER BY 1/currencies.rate * products.price;
Or getting a total:
SELECT ROUND(SUM(1/currencies.rate * products.price)) as total_price
FROM products
JOIN currencies ON products.currency_id = currencies.id;