Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Multi-Currency Setup for International Sites

By Kokil Thapa | Last reviewed: September 2026

International Laravel stores break when prices are stored in every currency you display. A solid Laravel multi-currency setup for international sites keeps one base currency in the database, converts at read time, and locks the charge currency at checkout. I've shipped this pattern on Nepal Gift Card and florist stores with NPR, USD, QAR, and AUD pricing. This guide covers schema design, rate sources, middleware, rounding, tax, and payment rules that survive production traffic.

What Is the Best Database Schema for Laravel Multi-Currency?

Store product and line-item amounts in a single base currency. Do not add a price column per currency unless you have a fixed-price contract with a supplier. That pattern scales poorly and creates reconciliation nightmares.

On a production Laravel application I maintain, NPR is the base currency and USD, QAR, and EUR are display currencies. The schema stays simple.

Core tables

// database/migrations/xxxx_create_currencies_table.php
Schema::create('currencies', function (Blueprint $table) {
    $table->string('code', 3)->primary(); // ISO 4217: NPR, USD, QAR
    $table->string('name');
    $table->string('symbol', 8);
    $table->unsignedTinyInteger('decimal_places')->default(2);
    $table->boolean('is_base')->default(false);
    $table->boolean('is_active')->default(true);
});

Schema::create('exchange_rates', function (Blueprint $table) {
    $table->id();
    $table->string('base_code', 3);
    $table->string('quote_code', 3);
    $table->decimal('rate', 18, 8);
    $table->timestamp('fetched_at');
    $table->unique(['base_code', 'quote_code']);
});

// products.price is ALWAYS in base currency (integer minor units recommended)
Schema::table('products', function (Blueprint $table) {
    $table->unsignedBigInteger('price_minor'); // e.g. 150000 = NPR 1,500.00
});

Store money as integers in minor units (paisa, cents). Floats cause rounding drift at scale. Laravel 13 on PHP 8.3+ works well with this approach. Pair it with a dedicated value object or the brick/money package via Composer 2.10.

Order snapshot fields

Orders must record what the customer actually paid. Never re-convert historical orders when rates change.

Schema::table('orders', function (Blueprint $table) {
    $table->string('display_currency', 3);
    $table->string('charge_currency', 3);
    $table->decimal('exchange_rate', 18, 8);
    $table->unsignedBigInteger('subtotal_minor');
    $table->unsignedBigInteger('total_minor');
    $table->unsignedBigInteger('base_subtotal_minor'); // for reporting
});

This mirrors how Petals Qatar handles QAR checkout while reporting in NPR. Your finance team will thank you at month-end.

Multi-Currency Data Modelproductsprice_minor (base)NPR onlyexchange_ratesNPR → USD/QARcached dailyorderslocked ratecharge currencyRead Path: convert at display timebase_minor × rate → display amountWrite Path: snapshot on order create
Laravel multi-currency database schema: store base prices once, cache rates, snapshot orders at checkout.

How Do You Fetch and Cache Exchange Rates in Laravel?

Live conversion needs a rate source you trust and a cache layer you control. Hitting an external API on every page view will slow your site and burn API quotas.

Rate provider options

ProviderBest forUpdate frequencyCost
ECB daily XMLEU-facing stores, EUR baseDailyFree
Open Exchange Rates / FixerGlobal multi-base setupsHourlyFree tier + paid
Stripe FX quotesCharge currency = USD/EURPer paymentIncluded in fees
Manual admin ratesNepal NPR peg, B2B contractsOn demandFree

For NPR-specific reference rates, cross-check against the Nepal forex rates tool before publishing customer-facing prices. Banks and gateways often differ from mid-market rates by 0.5–2%.

Scheduled rate fetch

Use Laravel's scheduler to pull rates twice daily. Store the fetch timestamp so you can show "Rates updated at …" on the storefront.

// app/Console/Commands/FetchExchangeRates.php
public function handle(ExchangeRateClient $client): int
{
    $base = config('currency.base', 'NPR');
    $quotes = Currency::where('is_active', true)
        ->where('code', '!=', $base)
        ->pluck('code');

    foreach ($quotes as $quote) {
        ExchangeRate::updateOrCreate(
            ['base_code' => $base, 'quote_code' => $quote],
            ['rate' => $client->rate($base, $quote), 'fetched_at' => now()]
        );
    }

    Cache::tags(['currency'])->flush();
    return self::SUCCESS;
}

// routes/console.php — Laravel 13
Schedule::command('currency:fetch-rates')->twiceDaily(6, 18);

Wrap the fetch in a queued job if the provider is slow. Failed fetches should keep the previous rate and log a warning. Never zero out rates on API failure.

How Do You Build a Currency Service and Middleware in Laravel?

Centralise conversion logic in one service class. Controllers, Blade views, API resources, and mail classes should all call the same method. Duplicated math is where bugs hide.

CurrencyService

// app/Services/CurrencyService.php
final class CurrencyService
{
    public function current(): string
    {
        return session('currency', config('currency.default', 'NPR'));
    }

    public function convert(int $minorUnits, ?string $to = null): int
    {
        $to ??= $this->current();
        $base = config('currency.base');

        if ($to === $base) {
            return $minorUnits;
        }

        $rate = Cache::tags(['currency'])->remember(
            "rate:{$base}:{$to}",
            now()->addHours(12),
            fn () => ExchangeRate::where('base_code', $base)
                ->where('quote_code', $to)
                ->value('rate')
        );

        return (int) round($minorUnits * $rate, 0, PHP_ROUND_HALF_UP);
    }

    public function format(int $minorUnits, ?string $code = null): string
    {
        $code ??= $this->current();
        $currency = Currency::findOrFail($code);
        $amount = $minorUnits / (10 ** $currency->decimal_places);

        return $currency->symbol . number_format($amount, $currency->decimal_places);
    }
}

Middleware and route switching

Detect currency from session, cookie, URL prefix, or Accept-Language plus geo-IP. Session works for most eCommerce builds. URL prefixes like /en-us/ help multi-region SEO structure.

// app/Http/Middleware/SetCurrency.php
public function handle(Request $request, Closure $next)
{
    $allowed = Currency::where('is_active', true)->pluck('code')->all();
    $requested = $request->query('currency')
        ?? $request->cookie('currency')
        ?? $this->detectFromLocale($request);

    if (in_array($requested, $allowed, true)) {
        session(['currency' => $requested]);
        Cookie::queue('currency', $requested, 60 * 24 * 30);
    }

    View::share('currentCurrency', app(CurrencyService::class)->current());
    return $next($request);
}

Register the middleware in bootstrap/app.php for Laravel 13. Apply it to web routes, not API routes that return base-currency JSON.

Currency Request PipelineBrowserSetCurrencymiddlewareControllerloads productCurrencyServiceRedis / DB rate cache12-hour TTL, tagged flush on fetchBlade: format(convert(price_minor))Customer sees local currency
Laravel multi-currency middleware resolves display currency before controllers convert base prices for the view.

How Should Display Currency Differ From Charge Currency at Checkout?

This is the step most tutorials skip. Display currency is what the customer sees. Charge currency is what the payment gateway settles. They are not always the same.

Stripe accepts 135+ presentment currencies but settles to your account currency. Khalti and eSewa charge in NPR only. A customer browsing in USD on a Nepal store must still pay NPR at checkout unless you use an international gateway.

Decision matrix

ScenarioDisplayChargeGateway
Nepal store, local paymentsUSD/EUR (converted)NPRKhalti, eSewa, ConnectIPS
Global SaaSCustomer localeSame as displayStripe, PayPal
Qatar floristQARQARLocal card gateway
Australia groceryAUDAUDStripe AU

On Quick And Easy Nepalese Grocery, delivery zones and AUD pricing align. Charge currency always matches the zone. Mixed-currency carts are rejected server-side.

Lock rate at checkout

// app/Actions/CreateOrder.php
public function execute(Cart $cart, CurrencyService $currency): Order
{
    $display = $currency->current();
    $charge  = config('currency.charge', 'NPR');
    $rate    = $currency->rateBetween(config('currency.base'), $charge);

    return DB::transaction(function () use ($cart, $display, $charge, $rate, $currency) {
        $order = Order::create([
            'display_currency'     => $display,
            'charge_currency'      => $charge,
            'exchange_rate'        => $rate,
            'base_subtotal_minor'  => $cart->subtotalMinor(),
            'total_minor'          => $currency->convert($cart->totalMinor(), $charge),
        ]);

        foreach ($cart->items as $item) {
            $order->items()->create([
                'product_id'           => $item->product_id,
                'base_unit_minor'      => $item->unitMinor(),
                'display_unit_minor'   => $currency->convert($item->unitMinor(), $display),
                'charge_unit_minor'    => $currency->convert($item->unitMinor(), $charge),
                'quantity'             => $item->quantity,
            ]);
        }

        return $order;
    });
}

See Laravel payment integrations and the Khalti integration guide for gateway-specific amount formatting. Khalti expects NPR in rupees with two decimals, not paisa integers.

Display vs Charge CurrencyCustomer at checkoutGatewaysupports FX?NoShow USD, charge NPRLock NPR amount on orderYesCharge = displayStripe multi-currencyAlways snapshot rate + amounts on order row
Laravel multi-currency checkout: choose charge currency based on gateway capability, not browser display alone.

What Rounding, Tax, and SEO Rules Apply to Multi-Currency Laravel Sites?

Small rounding differences between line items and cart total cause support tickets. Tax adds another layer. SEO needs hreflang and currency signals aligned.

Rounding policy

Pick one rule and document it in your checkout terms.

  1. Convert each line item, then sum — most accurate per-item display.
  2. Sum in base currency, convert once at cart level — fewer rounding steps.
  3. Round to nearest "psychological" unit (e.g. QAR 0.25) for retail.

PHP's PHP_ROUND_HALF_UP matches most card gateways. Never use floor() on customer-facing totals unless your terms say so.

Tax and VAT

Calculate tax in the charge currency jurisdiction, not the display currency. Nepal VAT is 13% on taxable goods. Apply it after currency conversion to NPR. Store tax_minor on the order row alongside total_minor.

For EU B2C sales, OSS rules may require EUR reporting regardless of display currency. Consult your accountant before hard-coding tax logic. The Nepal EMI calculator pattern — single source of truth for a formula — applies here too.

SEO and structured data

Google expects priceCurrency in Product schema to match the currency shown on the page. If you switch currency via JavaScript without updating schema, rich results break.

// resources/views/components/product-schema.blade.php
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "{{ $product->name }}",
  "offers": {
    "@type": "Offer",
    "price": "{{ $displayPrice }}",
    "priceCurrency": "{{ $currentCurrency }}",
    "availability": "https://schema.org/InStock"
  }
}
</script>

Pair this with SEO for Laravel sites and multi-region deployment guidance. Serve currency-specific URLs where possible instead of cookie-only switching.

API and mobile clients

REST APIs should return base currency amounts plus a display object when the client sends Accept-Currency: USD. Document this in your OpenAPI spec. Follow Laravel API best practices and building RESTful APIs with Laravel for versioning.

// app/Http/Resources/ProductResource.php
public function toArray($request): array
{
    $currency = app(CurrencyService::class);
    $code = $request->header('Accept-Currency', config('currency.base'));

    return [
        'id'    => $this->id,
        'name'  => $this->name,
        'price' => [
            'base'    => ['amount' => $this->price_minor, 'currency' => config('currency.base')],
            'display' => [
                'amount'   => $currency->convert($this->price_minor, $code),
                'currency' => $code,
                'formatted' => $currency->format($currency->convert($this->price_minor, $code), $code),
            ],
        ],
    ];
}

For high-traffic catalogues, cache converted prices per currency in Redis 8.10. Invalidate on rate fetch or product update. Read Laravel session configuration for multi-server if currency lives in session across nodes.

Common Production GotchasBefore (broken)Float prices in 4 columnsRe-convert old ordersSchema currency mismatchAfter (correct)Minor units, one baseOrder snapshot immutablepriceCurrency = displayMonitoring checklistRate fetch failed → alert, keep stale rateCart total ≠ sum of lines → log rounding eventGateway amount ≠ order.total_minor → block capture
Laravel multi-currency production fixes: integer storage, immutable order snapshots, and schema currency alignment.

Testing checklist

  • Switch currency mid-cart — items re-price correctly.
  • Complete checkout in each supported charge currency.
  • Verify webhook amount matches order.total_minor exactly.
  • Run reports in base currency — totals match finance exports.
  • Load-test rate cache — no N+1 queries on category pages.

Use MySQL 9.7 or PostgreSQL 18 for transactional integrity. I've seen slow category pages fixed by eager-loading products and caching converted price arrays per currency key. See PostgreSQL for Laravel developers if you split read replicas for catalogue traffic.

For asset delivery across regions, pair currency logic with CloudFront CDN setup or Cloudflare CDN best practices. Currency is business logic; CDN is delivery. Keep them separate.

If you need fixed campaign prices in USD regardless of daily rates, add an optional currency_prices override table. Use it sparingly for promotions only. The base column remains the fallback.

International legal-tech portals I have built rarely need multi-currency checkout. They still benefit from NPR/USD display for diaspora visitors. Lead forms and document fees can show converted estimates while invoicing stays in NPR.

Key Takeaways

  • Store all catalog prices in one base currency as integer minor units — never as floats or per-currency columns.
  • Fetch exchange rates on a schedule, cache them in Redis, and snapshot the rate on every order at checkout.
  • Separate display currency from charge currency based on what your payment gateway actually supports.
  • Centralise conversion in a CurrencyService; never duplicate math in Blade, API resources, or mail classes.
  • Align Product schema priceCurrency with the visible price, and test webhook amounts against order.total_minor.
  • Monitor rate-fetch failures and rounding drift — both cause real money disputes in production.

People Also Ask

Should I store prices in multiple currencies in Laravel?

No, not by default. Store one base price and convert at runtime with cached exchange rates. Add per-currency override rows only for fixed promotional or contract pricing. This keeps reporting simple and prevents drift between currencies.

How often should exchange rates update in an eCommerce app?

Twice daily is enough for most retail stores. Hourly makes sense for forex-sensitive products or volatile currencies. Always show the last update timestamp and keep the previous rate if a fetch fails.

Can I use Khalti or eSewa with USD display prices?

You can display USD but must charge NPR through Khalti or eSewa. Convert and lock the NPR total before redirecting to the gateway. Show both amounts at checkout so the customer knows the exact NPR charge.

What Laravel packages help with multi-currency?

brick/money handles minor units and rounding cleanly. laravel-money wraps it for Laravel. For exchange rates, a thin custom client plus the scheduler beats a heavy package you cannot debug at 2 a.m.

Ship Multi-Currency Laravel Stores That Reconcile on Monday Morning

A correct Laravel multi-currency setup for international sites is boring infrastructure. One base currency, cached rates, a single service class, and immutable order snapshots. That boring stack survives Dashain traffic spikes and month-end accounting without surprises.

If you are planning a cross-border store, NPR diaspora pricing, or a full multi-gateway checkout, I can architect and build it end to end. See Petals Nepal and other portfolio projects, or read more on the blog. For professional implementation, explore eCommerce development services or API development if you need mobile clients too.

Contact us with your target currencies, gateways, and base currency. We will map display versus charge rules before writing a migration.

Frequently Asked Questions

One base currency stored in the database, cached exchange rates, runtime conversion for display, and locked charge currency plus amount at checkout before calling the payment gateway.

No, not by default. Store one base price in integer minor units and convert at read time with cached rates. Per-currency price columns scale poorly and create reconciliation nightmares unless you have fixed supplier contracts. Add an optional currency_prices override table only for short promotions, with the base column as fallback.

Create currencies and exchange_rates tables, store product prices as price_minor integers in one base currency, and snapshot orders with display_currency, charge_currency, exchange_rate, subtotal_minor, total_minor, and base_subtotal_minor. Never use floats. On production apps I maintain, NPR is base while USD, QAR, and EUR are display currencies. Pair integer storage with brick/money via Composer 2.10 on Laravel 13 and PHP 8.3+.

Schedule a currency:fetch-rates command twice daily via Laravel 13 scheduler, pull from ECB XML, Open Exchange Rates, Fixer, Stripe FX, or manual admin rates, and store rates with fetched_at timestamps. Cache with tags and flush on update. Wrap slow providers in queued jobs. On API failure, keep the previous rate and log a warning — never zero out rates. Show customers when rates were last updated.

ECB daily XML is free for EUR-base setups. Open Exchange Rates and Fixer offer free tiers plus paid hourly plans. Stripe FX quotes are included in payment fees. Manual admin rates cost nothing and suit NPR peg or B2B contracts.

Centralise convert and format methods in one CurrencyService class so controllers, Blade, API resources, and mail all share the same math. Register SetCurrency middleware in bootstrap/app.php to resolve currency from session, cookie, query string, URL prefix, or locale detection. Apply it to web routes, not base-currency JSON API routes. Share currentCurrency to views via View::share.

Display currency is what the customer sees on product pages and cart totals. Charge currency is what the payment gateway actually settles. They are not always the same. A customer browsing in USD on a Nepal store may still pay NPR at checkout unless you use an international gateway. Lock both currencies and the exchange rate on the order row before initiating payment.

Stripe accepts 135+ presentment currencies but settles to your account currency. PayPal suits global SaaS where display and charge match. Khalti, eSewa, and ConnectIPS charge NPR only. Local card gateways handle QAR on Qatar florist stores. On Quick And Easy Nepalese Grocery, charge currency always matches the delivery zone AUD pricing. Reject mixed-currency carts server-side.

Pick one policy and document it in checkout terms. Convert each line item then sum for accurate per-item display, sum in base currency then convert once for fewer steps, or round to psychological units like QAR 0.25 for retail. Use PHP_ROUND_HALF_UP to match most card gateways. Never use floor on customer-facing totals unless your terms explicitly allow it. Rounding drift between line items and cart total causes support tickets.

Calculate tax in the charge currency jurisdiction, not the display currency. Nepal VAT is 13% on taxable goods, applied after conversion to NPR. Store tax_minor on the order alongside total_minor. For EU B2C sales, OSS rules may require EUR reporting regardless of display currency. Consult your accountant before hard-coding tax logic. Treat tax formulas as a single source of truth, similar to an EMI calculator pattern.

Google expects priceCurrency in Product schema to match the visible price on the page. If you switch currency via JavaScript without updating schema, rich results break. Render schema server-side with the current display price and currency code. Pair currency-specific URLs with hreflang for multi-region SEO instead of cookie-only switching. See SEO for Laravel sites guidance for canonical and indexation rules.

Return base currency amounts plus a display object when the client sends an Accept-Currency header. Document this in your OpenAPI spec. ProductResource should include base amount and currency, plus converted display amount, currency code, and formatted string. For high-traffic catalogues, cache converted prices per currency in Redis 8.10 and invalidate on rate fetch or product update. API routes should not use display-currency web middleware.

Exchange rates change daily or hourly. Re-converting historical orders when rates move breaks finance reporting and causes disputes with customers and payment gateways. Snapshot display_currency, charge_currency, exchange_rate, and all minor-unit totals on the order and line items at creation time. Store base_subtotal_minor separately for month-end reporting in your base currency. This mirrors how Petals Qatar handles QAR checkout while reporting in NPR.

Cross-check NPR reference rates against the Nepal forex rates tool before publishing customer-facing prices. Banks and gateways often differ from mid-market rates by 0.5 to 2 percent. For EU-facing stores, ECB daily XML works well with EUR base. Global multi-base setups suit Open Exchange Rates or Fixer hourly feeds. Use manual admin rates when NPR is pegged or you have fixed B2B contract pricing.

Switch currency mid-cart and verify items re-price correctly. Complete checkout in each supported charge currency. Confirm webhook amounts match order.total_minor exactly — Khalti expects NPR in rupees with two decimals, not paisa integers. Run base-currency reports and compare totals to finance exports. Load-test rate cache on category pages to catch N+1 queries. Use MySQL 9.7 or PostgreSQL 18 for transactional integrity during concurrent checkout.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: