Skip to main content

Multi-currency Pricing in Laravel: A Practical Guide

· XRates Team

You're building a Laravel app and the brief now includes "show prices in the user's currency." On the surface this is a one-liner — fetch a rate, multiply, format. In practice this is one of the spots where a naive implementation works for six months and then produces a bug report titled "why does this product cost €0.00".

Here's the version that survives production. Code samples use the XRates exchange rate API for live rates, but the architecture is the same regardless of provider.

Store one canonical currency

The most common mistake is storing prices in whatever currency the merchant typed. Two months later you have rows where price=199 could mean USD, EUR or RUB and there's no way to tell.

Pick one base currency and store everything in it. Use integer minor units, never floats:

Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->unsignedInteger('price_cents');     // cents in the base currency
    $table->char('base_currency', 3)->default('USD');
    $table->timestamps();
});

19900 cents is unambiguous. 199.0 is a floating-point representation that drifts the moment you do enough arithmetic. PHP's BCMath solves the precision but doesn't solve the "199 of what?" question. Integers + a known base currency does.

If you need historical pricing — what the price was when the order was placed — store the converted amount alongside the order, not just the product:

Schema::create('order_items', function (Blueprint $table) {
    $table->foreignId('order_id');
    $table->foreignId('product_id');
    $table->unsignedInteger('unit_price_cents'); // base currency at order time
    $table->unsignedInteger('display_price_cents'); // converted at order time
    $table->char('display_currency', 3);
    $table->decimal('rate_used', 18, 8);          // for audit
});

Five years from now when the customer disputes the price, you can still reproduce exactly what they saw.

Convert at request time, not at storage time

Don't write a job that "updates all product prices in EUR every morning". You'll have stale prices, race conditions during the update, and you'll need a separate row per currency per product.

Convert on read, cache the rate. Here's a service backed by XRates:

class CurrencyConverter
{
    public function __construct(
        private Http $http,
        private CacheRepository $cache
    ) {}

    public function convert(int $cents, string $from, string $to): int
    {
        if ($from === $to) {
            return $cents;
        }

        $rate = $this->rate($from, $to);
        return (int) round($cents * $rate);
    }

    private function rate(string $from, string $to): float
    {
        return $this->cache->remember(
            "fx:$from:$to",
            now()->addHours(4),
            fn () => $this->fetchRate($from, $to)
        );
    }

    private function fetchRate(string $from, string $to): float
    {
        $response = Http::withToken(config('services.xrates.key'))
            ->get('https://xratesapi.com/api/v1/latest', [
                'base' => $from,
                'symbols' => $to,
            ]);

        return (float) $response->json("rates.$to");
    }
}

Cache TTL should match your provider's refresh rate. XRates updates every 4 hours, so anything shorter is wasted requests; anything longer than ~12 hours and your prices drift out of sync with the source.

Always round to whole minor units

Floating-point arithmetic on prices breaks the moment two operations cancel out:

// Looks fine
$priceCents = 19900;     // $199.00
$rate = 0.92;            // USD → EUR
$converted = $priceCents * $rate;  // 18308.0
echo $converted / 100;   // 183.08

Looks fine. Now do it 10,000 times in a batch invoice job and you'll have a small number of rows that are off by one cent. Sometimes more. Always (int) round($result) before storing or displaying:

$converted = (int) round($priceCents * $rate);

The round() matters: PHP's (int) cast truncates, which is asymmetric — €0.999 becomes €0.00. Round, then cast.

Display formatting is a separate concern. Don't number_format the integer; convert to a Money object first. The moneyphp/money library is the canonical choice and integrates cleanly with Laravel. Skip if you're shipping a side project; reach for it the moment your app handles tax.

Don't trust the user's locale for currency

Browsers send Accept-Language: en-DE more often than you'd think. That tells you the user reads English but lives in Germany — it does not tell you they want euros. Maybe they're an expat paying in USD.

Make currency an explicit user preference:

$displayCurrency = $user->preferred_currency
    ?? $request->cookie('display_currency')
    ?? config('app.default_currency');

If you must guess, geo-IP is more reliable than Accept-Language (and usable from a GeoIP database you control rather than an API call), but always show what currency you've assumed and let the user change it. A small dropdown in the header beats a silent guess every time.

Handle missing rates gracefully

Pairs you don't expect will break. MDL → BBD is a real pair somebody on the internet wants. Most APIs return a 200 with the rate missing rather than a 4xx, which means your code does this:

$rate = (float) $response->json("rates.$to");  // 0.0 if missing
$converted = $priceCents * 0.0;                // ouch

Always guard:

$rate = $response->json("rates.$to");
if ($rate === null) {
    throw new RateUnavailableException("$from → $to");
}
return (float) $rate;

XRates returns 200 with a rates object that includes only the symbols actually available. If you ask for MDL → BBD and one of them isn't in the data set on a given day, the key is missing — your code should refuse to multiply by an undefined rate. Same applies to every provider in this space, see Free Exchange Rate API: How to Choose One in 2026.

Show the rate, log the rate, archive the rate

Three things customers will ask, in this order:

  1. "Why is this price different from yesterday?" — Log the rate used per request. A subscription_history-style audit table is fine.
  2. "Why does the dashboard show 1.08 but my invoice says 1.0814?" — Decide once whether you display rounded or full-precision rates and stay consistent. Internal calculations always full precision; display always rounded.
  3. "Can I see the FX rate that was applied?" — Surface it in the UI. A small italicised "1 USD = 0.864 EUR — rate as of 2026-05-15" under the total goes a long way for trust.

Putting it together

A working bare-minimum flow for a Laravel multi-currency checkout:

public function show(Product $product, Request $request)
{
    $displayCurrency = session('currency', 'USD');

    $displayPriceCents = app(CurrencyConverter::class)->convert(
        $product->price_cents,
        $product->base_currency,
        $displayCurrency
    );

    return view('product', [
        'product' => $product,
        'displayPrice' => Money::ofMinor($displayPriceCents, $displayCurrency),
        'fxRate' => app(CurrencyConverter::class)->rate(
            $product->base_currency,
            $displayCurrency
        ),
    ]);
}

That's the architecture. Storage layer is one canonical price + base currency. Read layer converts on demand with a cached rate. Display layer formats to whole minor units in a Money object. Audit layer records what rate was applied to which order.

For the rate provider, anything in the free exchange rate API comparison works as long as it's consistent. If you want multi-source fallback so a single upstream outage doesn't break checkout, XRates does that for you with a free tier of 100 requests per month — start there and upgrade only when you actually need the throughput.

TL;DR

  • Store one canonical currency, in integer minor units.
  • Convert at request time. Cache rates to match the provider's refresh.
  • Always (int) round($cents * $rate).
  • Make display currency an explicit user choice.
  • Guard against missing rates with an exception, never a silent zero.
  • Log the rate used per order so you can answer "why" later.