Implementation of Multi-Currency Support on Website
Multi-currency is not just price multiplied by rate. It's a system that determines which currency to display prices in, how to store them in database, how to process payments in multiple currencies, and how to reconcile financial reports. Poorly implemented multi-currency causes accounting discrepancies, rounding bugs, and VAT issues.
Price Storage Strategies
Two fundamentally different approaches:
Strategy 1: Store in Base Currency, Convert on Display
All prices stored in one currency (USD, EUR or local), displayed price multiplied by current rate. Simple to implement, but rate changes — customer sees different prices on each visit. Good for B2B and info sites.
Strategy 2: Explicit Prices in Each Currency
Database stores price separately for each currency. Manager controls prices manually or with auto-update by rate. Customer sees fixed "nice" price (999 rubles not 997.34). Good for retail.
For e-commerce, almost always second approach with semi-automatic update:
CREATE TABLE currencies (
code CHAR(3) PRIMARY KEY, -- ISO 4217: RUB, USD, EUR, BYN
name VARCHAR(100) NOT NULL,
symbol VARCHAR(10) NOT NULL,
symbol_pos VARCHAR(10) NOT NULL DEFAULT 'after', -- before | after
decimals SMALLINT NOT NULL DEFAULT 2,
is_active BOOLEAN NOT NULL DEFAULT true,
is_default BOOLEAN NOT NULL DEFAULT false,
rate_to_base NUMERIC(15,6) NOT NULL DEFAULT 1.0 -- to base currency
);
CREATE TABLE product_prices (
id BIGSERIAL PRIMARY KEY,
variant_id BIGINT NOT NULL REFERENCES product_variants(id),
currency CHAR(3) NOT NULL REFERENCES currencies(code),
price NUMERIC(12,2) NOT NULL,
compare_at NUMERIC(12,2), -- strikethrough price
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (variant_id, currency)
);
Auto-Update Exchange Rates
Rates updated on schedule from public sources:
class ExchangeRateUpdater
{
private array $providers = [
CbrExchangeRateProvider::class, // CBR RF
NbrbExchangeRateProvider::class, // NBRB (Belarus)
EcbExchangeRateProvider::class, // ECB
];
public function update(): void
{
foreach ($this->providers as $providerClass) {
$provider = app($providerClass);
$rates = $provider->fetchRates();
foreach ($rates as $code => $rate) {
Currency::where('code', $code)->update([
'rate_to_base' => $rate,
]);
}
}
Cache::tags(['currencies'])->flush();
}
}
CBR RF publishes XML at https://www.cbr.ru/scripts/XML_daily.asp. NBRB — JSON API: https://api.nbrb.by/exrates/rates?periodicity=0.
Auto-update rates doesn't mean auto-recalculate prices in product_prices. Separate step — either manual (manager clicks "Recalculate by rate") or automatic with threshold (only recalculate if rate changed 2%+).
User Currency Selection
User selects currency in site header. Choice saved as:
- For guests —
preferred_currencycookie (90 days) - For logged in —
users.preferred_currency
Middleware determines current currency on each request:
class ResolveCurrency
{
public function handle(Request $request, Closure $next): Response
{
$currency = $this->detectCurrency($request);
app()->instance('current_currency', Currency::find($currency));
$request->merge(['currency' => $currency]);
return $next($request);
}
private function detectCurrency(Request $request): string
{
// 1. Explicit parameter in request (switcher in header)
if ($request->has('currency') && $this->isValid($request->currency)) {
$this->persistChoice($request, $request->currency);
return $request->currency;
}
// 2. Saved user choice
if ($request->user()?->preferred_currency) {
return $request->user()->preferred_currency;
}
// 3. Cookie
if ($cookie = $request->cookie('preferred_currency')) {
return $cookie;
}
// 4. GeoIP (if enabled)
return $this->geoipCurrency->detect($request->ip())
?? config('shop.default_currency', 'RUB');
}
}
Price Formatting
Formatting is non-trivial: different currencies have different separators and symbol positions:
class PriceFormatter
{
public function format(float $amount, Currency $currency): string
{
$formatted = number_format(
$amount,
$currency->decimals,
',', // decimal separator
' ' // thousands separator
);
return match($currency->symbol_pos) {
'before' => $currency->symbol . $formatted,
'after' => $formatted . ' ' . $currency->symbol,
};
}
}
// 1 499,00 ₽
// $1,499.00
// 1.499,00 €
Payments in Multiple Currencies
Payment gateway must support multi-currency. Stripe — ideal: accepts payment in any currency, converts on processor side. Yandex.Kassa — RUB only, conversion on store side. CloudPayments — BYN, RUB, USD, EUR.
On payment, order's currency and exchange rate at payment time are fixed:
ALTER TABLE orders ADD COLUMN currency CHAR(3) NOT NULL DEFAULT 'RUB';
ALTER TABLE orders ADD COLUMN exchange_rate NUMERIC(15,6) NOT NULL DEFAULT 1.0;
ALTER TABLE orders ADD COLUMN base_currency_total NUMERIC(12,2); -- for reporting
Allows later reconciling reports in single currency regardless of customer's payment currency.
Rounding and Anti-Patterns
Never store money in FLOAT — precision loss in math. Always NUMERIC(12,2) or DECIMAL.
Rounding on conversion:
// WRONG: 9.994999... → 9.99 but 9.995000 → 10.00 (PHP_ROUND_HALF_UP)
round($price * $rate, 2);
// RIGHT: banker's rounding, error doesn't accumulate
round($price * $rate, 2, PHP_ROUND_HALF_EVEN);
On summing order items — sum first, then round, not vice versa.
Catalog Display
When indexing catalog via Elasticsearch or Meilisearch — index prices in all active currencies as separate fields for filtering:
{
"id": 123,
"price_rub": 1499.00,
"price_usd": 16.50,
"price_eur": 15.20,
"price_byn": 52.10
}
Allows filtering by price in user's current currency without recalculation on each request.
Timeline
- Basic system: price storage + currency switcher + formatting: 3–4 days
- Auto-update rates (CBR RF / NBRB): 1 day
- Auto-recalculate prices with threshold: 1–2 days
- Multi-currency payments (depends on gateway): 2–4 days
- Financial reporting in base currency: 1–2 days
Full implementation for store with 3–5 currencies: 1–2 weeks.







