Laravel Magazine
Building a Multi-Currency Pricing System With Value Objects in Laravel

Building a Multi-Currency Pricing System With Value Objects in Laravel

Eric Van Johnson ·

If you've ever stored a price as a float, you already know how this story ends: 19.99 + 5.00 occasionally doesn't equal 24.99 because of how floating point numbers represent decimals, and now your invoice totals are off by a cent in a way nobody can reproduce reliably. The fix everyone eventually lands on is storing money as integer cents. The fix almost nobody does next is wrapping that integer in a real object so currency mismatches become impossible instead of just "unlikely."

This tutorial builds a Money value object for a fictional store, PHP Architect's merch shop, that handles multiple currencies correctly, casts cleanly on Eloquent models, and makes it a compile-time-ish error to accidentally add USD cents to EUR cents.

Step 1: The Money value object

namespace App\ValueObjects;

final class Money
{
    private function __construct(
        public readonly int $cents,
        public readonly string $currency,
    ) {}

    public static function of(int $cents, string $currency): self
    {
        return new self($cents, strtoupper($currency));
    }

    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->cents + $other->cents, $this->currency);
    }

    public function subtract(Money $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->cents - $other->cents, $this->currency);
    }

    public function multiply(float $factor): self
    {
        return new self((int) round($this->cents * $factor), $this->currency);
    }

    public function isGreaterThan(Money $other): bool
    {
        $this->assertSameCurrency($other);

        return $this->cents > $other->cents;
    }

    public function format(): string
    {
        $decimal = number_format($this->cents / 100, 2);

        return match ($this->currency) {
            'USD' => "\${$decimal}",
            'EUR' => "{$decimal}€",
            'GBP' => "£{$decimal}",
            default => "{$decimal} {$this->currency}",
        };
    }

    private function assertSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException(
                "Cannot operate on {$this->currency} and {$other->currency}."
            );
        }
    }
}

The constructor is private and the of() static method is the only way in. That's intentional: it keeps every Money instance in the same shape and gives you one obvious spot to add validation later, like rejecting unsupported currency codes.

Every arithmetic method returns a new Money instance instead of mutating $this. Value objects should be immutable. If add() mutated the object in place, you'd get bugs where two parts of your code hold references to what they think are two different totals but are actually the same object.

Step 2: A custom cast so Eloquent speaks Money natively

Rather than manually converting cents to Money and back on every model, write a Castable so it happens automatically.

namespace App\Casts;

use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class MoneyCast implements CastsAttributes
{
    public function __construct(private string $currencyColumn = 'currency') {}

    public function get($model, string $key, $value, array $attributes): ?Money
    {
        if ($value === null) {
            return null;
        }

        return Money::of((int) $value, $attributes[$this->currencyColumn] ?? 'USD');
    }

    public function set($model, string $key, $value, array $attributes): array
    {
        if ($value instanceof Money) {
            return [
                $key => $value->cents,
                $this->currencyColumn => $value->currency,
            ];
        }

        return [$key => $value];
    }
}

Then wire it up on the model:

namespace App\Models;

use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $fillable = ['name', 'price_cents', 'currency'];

    protected function casts(): array
    {
        return [
            'price_cents' => MoneyCast::class,
        ];
    }
}

Note the cast targets price_cents even though the underlying database column stores the same value. Eloquent's custom casts let you name the accessor whatever reads best in your code, the cast's get()/set() methods handle mapping it back to the actual columns.

Step 3: Using it

$product = Product::create([
    'name' => 'PHP Architect Hoodie',
    'price_cents' => 4999,
    'currency' => 'USD',
]);

echo $product->price_cents->format(); // "$49.99"

$discounted = $product->price_cents->multiply(0.8);
echo $discounted->format(); // "$39.99"

And critically, this now throws instead of silently producing a wrong number:

$usdPrice = Money::of(2000, 'USD');
$eurPrice = Money::of(1800, 'EUR');

$usdPrice->add($eurPrice); // InvalidArgumentException: Cannot operate on USD and EUR.

That exception is the entire point of this exercise. Before, adding two prices in different currencies would have "worked" and produced a meaningless number that looked plausible enough to ship. Now it fails loudly in your test suite the first time someone tries it.

Step 4: Converting between currencies deliberately

Notice Money has no convertTo() method. That's on purpose. Currency conversion needs an exchange rate from somewhere, an API, a cached daily rate table, whatever your business uses, and baking that into the value object couples it to infrastructure it shouldn't know about. Handle conversion as a separate step using a rate you fetch explicitly:

function convert(Money $money, string $targetCurrency, float $rate): Money
{
    return Money::of((int) round($money->cents * $rate), $targetCurrency);
}

$eurPrice = convert($usdPrice, 'EUR', rate: 0.92);

Keeping conversion outside the value object means Money stays trivially testable with no HTTP calls or database lookups involved, and your conversion logic lives in one auditable place instead of being scattered across every place someone needed a EUR price.

Where this pays off

The moment your app needs to display prices in more than one currency, or needs to guarantee an invoice total is mathematically exact down to the cent, this pattern earns its keep. It's a small amount of upfront code that turns an entire category of "off by a cent" and "wait, why did we charge them in the wrong currency" bugs into things that simply can't compile past your test suite.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.