
September 07, 2026
12 min read
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.
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
| Provider | Best for | Update frequency | Cost |
|---|---|---|---|
| ECB daily XML | EU-facing stores, EUR base | Daily | Free |
| Open Exchange Rates / Fixer | Global multi-base setups | Hourly | Free tier + paid |
| Stripe FX quotes | Charge currency = USD/EUR | Per payment | Included in fees |
| Manual admin rates | Nepal NPR peg, B2B contracts | On demand | Free |
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.
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
| Scenario | Display | Charge | Gateway |
|---|---|---|---|
| Nepal store, local payments | USD/EUR (converted) | NPR | Khalti, eSewa, ConnectIPS |
| Global SaaS | Customer locale | Same as display | Stripe, PayPal |
| Qatar florist | QAR | QAR | Local card gateway |
| Australia grocery | AUD | AUD | Stripe 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.
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.
- Convert each line item, then sum — most accurate per-item display.
- Sum in base currency, convert once at cart level — fewer rounding steps.
- 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.
Testing checklist
- Switch currency mid-cart — items re-price correctly.
- Complete checkout in each supported charge currency.
- Verify webhook amount matches
order.total_minorexactly. - 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
priceCurrencywith the visible price, and test webhook amounts againstorder.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
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.

