blog.thms.uk

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:

  1. I want tight integration with Laravel, and particularly with my Currency model, which allows me to do cross-currency maths directly in SQL.
  2. 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:

It also has two static methods:

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 joining 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:

Additionally I add a number of methods:

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;