Multi-Currency Checkout with Stripe and Laravel Cashier

Wire up multi-currency Stripe checkout in Laravel Cashier with per-currency prices, locale detection, webhook handling, and Number::currency() formatting.

Steven Richardson
Steven Richardson
· 9 min read

Going international with a Laravel SaaS app sounds simple until the first French customer hits the pricing page and sees "$29/month". Stripe supports 135+ presentment currencies, but Cashier's docs nudge you toward CASHIER_CURRENCY and a single price ID — which is fine for one currency, useless for ten. Here's the setup I use in production: one Stripe price per plan with currency_options, currency detection that doesn't fight Adaptive Pricing, and webhook handling that respects the amount the customer actually paid.

How Stripe Multi-Currency Works#

Two terms to keep straight from day one:

  • Presentment currency — the currency the customer pays in. Shown on the Checkout page, on the receipt, and on the invoice PDF.
  • Settlement currency — the currency that lands in your Stripe balance and gets paid out to your bank. Set per Stripe account.

If those differ, Stripe runs an FX conversion at charge time. The customer pays €25, your balance accrues a USD amount equivalent (minus FX fees, currently 1% on standard accounts). The presentment side is purely what the customer experiences.

Stripe gives you three ways to do multi-currency:

  1. Adaptive Pricing — ML-driven, automatic conversion at Checkout. Toggle it on, customers in supported locales see localised amounts. Rounding is "psychological" (e.g. €27.99 instead of €28.43), but you have no control over the rate.
  2. Manual currency prices — define exact amounts per currency on a single Price object using currency_options. Full control, predictable pricing.
  3. Multiple price IDs — create one price per currency, branch in your code. Works, but it scales badly past two or three currencies.

For anything beyond a side project, manual currency prices are the right default. They give marketing the round numbers they want and keep the integration to one price ID.

Setting Up Currency-Specific Prices#

Create a base price in your settlement currency, then attach currency_options for each presentment currency you support. This is a one-time setup per plan, ideally scripted so staging and production match.

use Stripe\StripeClient;

$stripe = new StripeClient(config('cashier.secret'));

$price = $stripe->prices->create([
    'product' => 'prod_pro_plan',
    'currency' => 'usd',
    'unit_amount' => 2900, // $29.00 base
    'recurring' => ['interval' => 'month'],
    'currency_options' => [
        'eur' => ['unit_amount' => 2700], // €27.00
        'gbp' => ['unit_amount' => 2400], // £24.00
        'cad' => ['unit_amount' => 3900], // CA$39.00
        'aud' => ['unit_amount' => 4500], // AU$45.00
    ],
]);

// $price->id => "price_1Ox...Pro"  — keep this in config

A few things to know about currency_options:

  • The base currency must be one of the entries — Stripe uses it as the fallback if a customer's currency isn't covered.
  • Amounts are in the smallest unit (cents, pence, yen, etc.). Zero-decimal currencies like JPY want 2900 for ¥2,900, not 290000.
  • The API returns currency_options only when you ask for it. Set expand: ['currency_options'] on retrieves, otherwise the field is hidden.
  • Tax behaviour (inclusive vs exclusive) can be set per-currency too — useful if EU prices need to include VAT but USD prices don't.

Store the resulting price IDs in config/billing.php, not .env. You'll reference them by tier name from a dozen places.

// config/billing.php
return [
    'plans' => [
        'pro' => env('STRIPE_PRICE_PRO', 'price_1Ox...Pro'),
        'team' => env('STRIPE_PRICE_TEAM', 'price_1Ox...Team'),
    ],
];

Detecting Customer Currency#

Stripe's Checkout will pick the presentment currency from the customer's IP automatically — but only if you let it. The cleanest signal stack is: explicit user preference > saved customer setting > Accept-Language header > IP geolocation > settlement currency fallback.

For most apps, persisting the user's choice on first paid action is enough. I use a currency column on the users table cast to a backed enum:

namespace App\Enums;

enum Currency: string
{
    case USD = 'usd';
    case EUR = 'eur';
    case GBP = 'gbp';
    case CAD = 'cad';
    case AUD = 'aud';

    public function symbol(): string
    {
        return match ($this) {
            self::USD, self::CAD, self::AUD => '$',
            self::EUR => '',
            self::GBP => '£',
        };
    }

    public static function fromLocale(?string $locale): self
    {
        return match (substr((string) $locale, 0, 2)) {
            'en' => str_contains((string) $locale, 'GB') ? self::GBP : self::USD,
            'de', 'fr', 'es', 'it', 'nl' => self::EUR,
            default => self::USD,
        };
    }
}

Add a middleware that resolves the currency on every request and stashes it on the session for unauthenticated visitors:

namespace App\Http\Middleware;

use App\Enums\Currency;
use Closure;
use Illuminate\Http\Request;

class ResolveCurrency
{
    public function handle(Request $request, Closure $next)
    {
        $currency = $request->user()?->currency
            ?? session('currency')
            ?? Currency::fromLocale($request->getPreferredLanguage());

        session(['currency' => $currency]);
        config(['cashier.currency' => $currency->value]);

        return $next($request);
    }
}

The config(['cashier.currency' => ...]) line is what makes Cashier's invoice PDFs and Number::currency() calls render in the visitor's currency for the rest of the request. It does not affect what Stripe charges — that's controlled by the price ID and the locale-formatted email passed to Checkout.

Cashier Subscription with Multi-Currency#

Cashier 16's newSubscription() flow already supports per-currency prices because Stripe handles the conversion server-side. The trick is telling Stripe which presentment currency to use on the Checkout Session, and that goes through customer_email with a location modifier or, more commonly, through the customer's saved location.

namespace App\Http\Controllers;

use App\Enums\Currency;
use Illuminate\Http\Request;

class SubscriptionController
{
    public function checkout(Request $request)
    {
        $user = $request->user();
        $currency = Currency::from(session('currency', 'usd'));

        $user->createOrGetStripeCustomer();

        $user->updateStripeCustomer([
            'preferred_locales' => [$request->getPreferredLanguage()],
            'address' => [
                'country' => $this->countryFor($currency),
            ],
        ]);

        return $user
            ->newSubscription('default', config('billing.plans.pro'))
            ->checkout([
                'success_url' => route('billing.success'),
                'cancel_url' => route('billing.cancel'),
                'currency' => $currency->value,
                'locale' => 'auto',
                'allow_promotion_codes' => true,
            ]);
    }

    private function countryFor(Currency $currency): string
    {
        return match ($currency) {
            Currency::EUR => 'DE',
            Currency::GBP => 'GB',
            Currency::CAD => 'CA',
            Currency::AUD => 'AU',
            default => 'US',
        };
    }
}

A handful of hard-won notes:

  • currency on checkout() only takes effect when the price has a matching currency_options entry. Pass an unsupported currency and Stripe falls back to the base — silently.
  • locale: 'auto' lets Stripe localise the Checkout UI text; the currency comes from the price + customer combination, not the locale.
  • The Stripe Customer's address.country is the strongest signal. If you collect billing country during signup, mirror it into the customer record immediately — the same pattern I use in the Stripe customer portal Cashier flow keeps the dashboard and your DB in sync.
  • For one-shot payments without a subscription, the same currency rules apply on checkout()->charge() — the Stripe Checkout without Cashier walkthrough shows the lower-level form.

Webhook Handling Across Currencies#

This is where multi-currency apps quietly break in production. The webhook payload reflects what was actually charged — presentment currency and presentment amount. Treating amount_total as cents-of-USD will report wrong revenue numbers the moment a non-USD customer subscribes.

Always read amount_total and currency together, and store both:

namespace App\Http\Controllers;

use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;

class StripeWebhookController extends CashierController
{
    public function handleCheckoutSessionCompleted(array $payload)
    {
        $session = $payload['data']['object'];

        Payment::create([
            'user_id' => $this->resolveUser($session['customer'])->id,
            'stripe_session_id' => $session['id'],
            'amount' => $session['amount_total'],          // smallest unit
            'currency' => $session['currency'],            // 3-letter ISO
            'amount_settlement' => null,                    // filled by charge.succeeded
        ]);

        return $this->successMethod();
    }
}

The settlement amount only shows up on the balance_transaction linked to the underlying charge. If you need the converted USD amount for accounting, retrieve it on the charge.succeeded event and update the row:

public function handleChargeSucceeded(array $payload)
{
    $charge = $payload['data']['object'];

    if (! $balanceTxnId = $charge['balance_transaction']) {
        return $this->successMethod();
    }

    $balanceTxn = $this->stripe->balanceTransactions->retrieve($balanceTxnId);

    Payment::where('stripe_charge_id', $charge['id'])->update([
        'amount_settlement' => $balanceTxn->amount,
        'currency_settlement' => $balanceTxn->currency,
        'fee_settlement' => $balanceTxn->fee,
    ]);

    return $this->successMethod();
}

Two production gotchas worth flagging:

  • Webhook payloads can arrive out of order. charge.succeeded may land before checkout.session.completed. Idempotent inserts and updateOrCreate() are non-negotiable here — the same pattern I work through in the Stripe webhook signature verification guide applies one-for-one.
  • For full subscription lifecycle handling (renewals, currency changes mid-subscription, customer-initiated downgrades), the Stripe subscription lifecycle webhooks article covers the events you'll listen to alongside the multi-currency ones.

Displaying Localised Prices#

Cashier already wraps PHP's NumberFormatter for invoice rendering — but you'll want it on pricing pages too. Don't concatenate strings; let NumberFormatter (or Laravel 13's Number::currency() helper) handle decimals, thousand separators, and symbol placement.

use Illuminate\Support\Number;

// Pricing page
$amount = $stripe->prices->retrieve(
    config('billing.plans.pro'),
    ['expand' => ['currency_options']]
);

$option = $amount->currency_options[$currency->value] ?? null;
$display = Number::currency(
    ($option->unit_amount ?? $amount->unit_amount) / 100,
    in: $currency->value,
    locale: app()->getLocale(),
);

// "€27.00", "£24.00", "$29.00"

A reusable Blade component keeps the pricing UI clean:

{{-- resources/views/components/price.blade.php --}}
@props(['priceId', 'currency'])

@php
    $price = cache()->remember(
        "stripe.price.{$priceId}",
        now()->addHour(),
        fn () => app(\Stripe\StripeClient::class)
            ->prices
            ->retrieve($priceId, ['expand' => ['currency_options']])
    );
    $option = $price->currency_options[$currency] ?? null;
    $amount = ($option->unit_amount ?? $price->unit_amount) / 100;
@endphp

<span class="text-2xl font-bold">
    {{ \Illuminate\Support\Number::currency($amount, in: $currency, locale: app()->getLocale()) }}
</span>
<span class="text-sm text-gray-500">/ month</span>

Cache aggressively. The price object only changes when you push a new tier, and Stripe rate-limits price retrieves alongside everything else.

Gotchas and Edge Cases#

A short list of the things that have bitten me or my clients:

  • Zero-decimal currencies (JPY, KRW, VND) — unit_amount is the whole-yen value, not yen × 100. Mixing them in a currency_options block with regular currencies works, but display logic that divides by 100 will break. Branch on Stripe\Util\Util::isZeroDecimalCurrency($currency) before formatting.
  • Currency switching mid-subscription — Stripe doesn't let you change the currency of an active subscription. You have to cancel and recreate. Bake this into the customer-portal UX or you'll get refund requests.
  • Tax inclusivity — EU customers expect prices to include VAT; US customers expect them exclusive. Set tax_behavior per currency_options entry, or your displayed price won't match the final charge.
  • Refund amounts — refunds are denominated in the presentment currency. A €27 charge that you refund in full credits €27, but your USD ledger may show a different number due to FX shifts. The Stripe disputes and chargebacks article digs into how to reconcile that side of the ledger.
  • Adaptive Pricing collisions — if Adaptive Pricing is on at the account level, it overrides currencies you haven't manually defined. That's usually what you want, but if you're seeing surprise rates on a currency you do support, double-check currency_options actually has an entry for it.
  • Test mode quirks — Stripe's test FX rates aren't real. Live rates can be 0.5–2% off what you saw in test, so don't pre-print prices on physical materials based on test calculations.

Wrapping Up#

The single-Price-with-currency_options approach is the configuration that scales: one price ID per plan, marketing controls the round numbers, your code stays clean. The hard parts are downstream — webhook reconciliation, refund accounting, currency-aware displays — and they all reward storing both presentment and settlement amounts on every transaction row.

Once this is in, the natural next steps are tightening up subscription lifecycle handling (covered in the subscription lifecycle webhooks guide) and giving customers a way to manage their own billing without you in the middle (the customer portal walkthrough sets that up in about twenty minutes).

FAQ#

How do I charge in different currencies with Stripe?

Create a Price object with currency_options containing per-currency unit_amount values, then pass that single price ID into a Checkout Session along with the customer's currency. Stripe shows the right amount based on the customer's location or the explicit currency parameter, and converts to your settlement currency at payout time. You don't need separate price IDs per country.

Does Laravel Cashier support multi-currency?

Yes — Cashier 16 works with Stripe's multi-currency primitives because the heavy lifting happens server-side in Stripe. The CASHIER_CURRENCY env variable only sets the default for invoice formatting; real multi-currency flows are driven by Stripe Price currency_options and the Customer's address country. You pass the same price ID into newSubscription()->checkout() for every customer, and Stripe picks the presentment currency.

How do I detect a customer's currency in Laravel?

Layer the signals: explicit user preference first, then saved Stripe Customer address country, then Accept-Language via $request->getPreferredLanguage(), then IP geolocation as a fallback. Persist the chosen currency as a backed enum on the users table and resolve it once per request in middleware. Don't lean on IP alone — VPNs and travelling users will trip it.

What is the difference between presentment and settlement currency?

The presentment currency is what the customer pays in and sees on Stripe Checkout, on receipts, and on invoices. The settlement currency is what your Stripe balance accrues and what gets paid out to your bank. When they differ, Stripe runs an FX conversion at the time of the charge and applies a small conversion fee (typically around 1% on standard accounts). Your accounting needs to track both — the presentment amount for customer-facing records, the settlement amount for revenue.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.