Currency Formatting in PHP: Using NumberFormatter and Backed Enums
Introduction
Displaying prices correctly across different currencies is one of those problems that seems simple until you realize that $1,234.56 in the US is 1.234,56 € in Germany and ¥1,235 in Japan (no decimal places). PHP's NumberFormatter class handles these locale-specific rules, but its documentation is sparse and it depends on the intl extension—which isn't always available.
This article walks through a production-tested approach: a CurrencyEnum backed enum paired with a CurrencyUtil class that formats currencies correctly, maps countries to their currencies, and falls back gracefully when intl is missing.
Prerequisites
- PHP 8.1+ (for backed enums)
- The
intlextension (recommended, but the code works without it)
Check if intl is installed:
php -m | grep intlIf missing, install it:
# Debian/Ubuntu
sudo apt-get install php-intl
# macOS with Homebrew
brew install php@8.3 # intl is included by defaultDefining Currencies with a Backed Enum
PHP 8.1 introduced backed enums—enums where each case has a scalar value. This is a natural fit for currency codes, which are standardized ISO 4217 three-letter strings:
enum CurrencyEnum: string
{
case USD = 'USD'; // US Dollar
case EUR = 'EUR'; // Euro
case MXN = 'MXN'; // Mexican Peso
case COP = 'COP'; // Colombian Peso
case BRL = 'BRL'; // Brazilian Real
case GBP = 'GBP'; // British Pound
case JPY = 'JPY'; // Japanese Yen
case INR = 'INR'; // Indian Rupee
// ... 43 currencies total
}Each case carries its symbol and human-readable name through match expressions:
public function symbol(): string
{
return match ($this) {
self::USD => '$',
self::EUR => '€',
self::BRL => 'R$',
self::GBP => '£',
self::JPY => '¥',
self::INR => '₹',
self::PEN => 'S/',
self::CRC => '₡',
self::KRW => '₩',
self::TRY => '₺',
// ...
};
}
public function name(): string
{
return match ($this) {
self::USD => 'US Dollar',
self::EUR => 'Euro',
self::BRL => 'Brazilian Real',
self::GBP => 'British Pound',
// ...
};
}TIP
Backed enums give you tryFrom() for free—CurrencyEnum::tryFrom('USD') returns the case, while CurrencyEnum::tryFrom('INVALID') returns null without throwing. This is essential for validation.
Filtering Common Currencies
Not all 43 currencies need to be shown in every dropdown. A common() method returns the subset most frequently used:
public static function common(): array
{
return [
self::USD, self::EUR, self::MXN,
self::COP, self::BRL, self::ARS,
self::CLP, self::PEN, self::CAD,
self::GBP,
];
}Mapping Countries to Currencies
When you know a user's country (from IP geolocation, profile data, or browser locale), you can automatically select their currency. PHP 8.0's match expression makes this concise:
public static function getCountryCurrency(string $countryCode): CurrencyEnum
{
$countryCode = strtoupper($countryCode);
return match ($countryCode) {
// North America
'US' => CurrencyEnum::USD,
'CA' => CurrencyEnum::CAD,
'MX' => CurrencyEnum::MXN,
// Eurozone (19 countries, one currency)
'ES', 'FR', 'DE', 'IT', 'PT', 'BE', 'NL', 'AT',
'GR', 'FI', 'IE', 'LU', 'EE', 'LV', 'LT', 'SK',
'SI', 'MT', 'CY' => CurrencyEnum::EUR,
// South America
'CO' => CurrencyEnum::COP,
'BR' => CurrencyEnum::BRL,
'AR' => CurrencyEnum::ARS,
'EC' => CurrencyEnum::USD, // Ecuador uses USD
// Asia
'CN' => CurrencyEnum::CNY,
'JP' => CurrencyEnum::JPY,
'IN' => CurrencyEnum::INR,
// ...50+ countries total
default => CurrencyEnum::USD,
};
}A few things to note:
- Case normalization:
strtoupper()ensures'us'and'US'both work. - Multi-country matching: The 19 Eurozone countries are grouped in a single
matcharm. - Special cases: Ecuador uses USD despite being in South America—the
matchexpression makes this explicit. - Safe default: Unknown country codes fall back to USD rather than throwing.
- Error reporting: The entire method is wrapped in a
try/catchthat reports to your error tracker and still returns USD.
Formatting with NumberFormatter
The core formatting method uses PHP's NumberFormatter class from the intl extension:
public static function formatCurrency(
float $amount,
string $currency,
?string $locale = null
): string {
try {
$locale = $locale ?: 'en_US';
$formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
$formatted = $formatter->formatCurrency($amount, $currency);
if ($formatted === false) {
// Layer 1: NumberFormatter returned false
$currencyEnum = CurrencyEnum::tryFrom($currency);
$symbol = $currencyEnum ? $currencyEnum->symbol() : '$';
return $symbol . ' ' . number_format($amount, 2);
}
return $formatted;
} catch (Throwable $e) {
// Layer 2: intl extension missing or other error
$currencyEnum = CurrencyEnum::tryFrom($currency);
$symbol = $currencyEnum ? $currencyEnum->symbol() : '$';
return $symbol . ' ' . number_format($amount, 2);
}
}How NumberFormatter Works
NumberFormatter is part of ICU (International Components for Unicode). When you call formatCurrency(), it:
- Looks up the locale's formatting rules (decimal separator, thousands grouping, symbol placement)
- Applies the correct currency symbol for the currency code
- Sets the right number of decimal places (2 for most currencies, 0 for JPY)
// US formatting: symbol before, comma thousands, dot decimal
CurrencyUtil::formatCurrency(1234.56, 'USD', 'en_US');
// → "$1,234.56"
// German formatting: symbol after, dot thousands, comma decimal
CurrencyUtil::formatCurrency(1234.56, 'EUR', 'de_DE');
// → "1.234,56 €"
// Japanese Yen: no decimal places
CurrencyUtil::formatCurrency(1234.56, 'JPY', 'ja_JP');
// → "¥1,235"Important
NumberFormatter output depends on the ICU library version your PHP is compiled against, not the PHP version itself. Two servers running the same PHP version may produce different output if their ICU versions differ. Always test in your target environment.
The Two-Layer Fallback
The code has two fallback paths, and this is deliberate:
Layer 1 — formatCurrency() returns false. This happens when NumberFormatter can't process the combination of locale and currency code. The formatter exists but fails on the specific input.
Layer 2 — A Throwable is caught. This happens when the intl extension isn't installed at all (the NumberFormatter class doesn't exist), or when some other unexpected error occurs.
Both layers fall back to the same strategy: look up the symbol from the enum, default to $ if the currency code is unknown, and use PHP's native number_format() with 2 decimal places. The result won't be locale-perfect, but it's always readable.
Mapping Currencies to Locales
To format a currency correctly without requiring the caller to know the right locale, the utility provides a currency-to-locale mapping:
public static function getCurrencyLocale(string $currency): string
{
return match ($currency) {
'USD' => 'en_US',
'EUR' => 'en_EU',
'MXN' => 'es_MX',
'COP' => 'es_CO',
'BRL' => 'pt_BR',
'GBP' => 'en_GB',
'CNY' => 'zh_CN',
'JPY' => 'ja_JP',
'INR' => 'hi_IN',
'CAD' => 'en_CA',
'AUD' => 'en_AU',
// ...27 currencies mapped
default => 'en_US',
};
}This lets you chain calls:
$currency = 'BRL';
$locale = CurrencyUtil::getCurrencyLocale($currency);
$formatted = CurrencyUtil::formatCurrency(199.99, $currency, $locale);
// → "R$ 199,99"Practical Example: E-Commerce Catalog
Here's how these pieces combine in a real e-commerce scenario—a product model that shows prices in the customer's local currency:
class CatalogProduct
{
public function getPriceForContact(string $countryCode): string
{
// 1. Determine currency from country
$currency = CurrencyUtil::getCountryCurrency($countryCode);
// 2. Look up the price for this currency
$price = $this->getPriceForCurrency($currency->value);
// 3. Get the right locale
$locale = CurrencyUtil::getCurrencyLocale($currency->value);
// 4. Format it
return CurrencyUtil::formatCurrency($price, $currency->value, $locale);
}
public function getPriceForCurrency(string $currencyCode): float
{
// Check if we have a price set for this currency
if (CurrencyUtil::isSupported($currencyCode)) {
return $this->prices[$currencyCode] ?? $this->prices['USD'] ?? 0;
}
// Fall back to USD price
return $this->prices['USD'] ?? 0;
}
}Usage:
$product = new CatalogProduct();
echo $product->getPriceForContact('BR'); // "R$ 49,90"
echo $product->getPriceForContact('MX'); // "$999.00"
echo $product->getPriceForContact('JP'); // "¥1,500"
echo $product->getPriceForContact('DE'); // "29,90 €"Building a Currency Selector
For admin interfaces, getSupportedCurrencies() provides the data for a dropdown:
$currencies = CurrencyUtil::getSupportedCurrencies();
// Returns:
// [
// ['value' => 'USD', 'label' => 'US Dollar (USD)', 'symbol' => '$'],
// ['value' => 'EUR', 'label' => 'Euro (EUR)', 'symbol' => '€'],
// ['value' => 'MXN', 'label' => 'Mexican Peso (MXN)', 'symbol' => '$'],
// ...
// ]Testing Currency Formatting
Currency formatting has enough edge cases that tests are essential. Here are the key scenarios to cover:
class CurrencyUtilTest extends TestCase
{
public function test_format_currency_formats_correctly(): void
{
$formatted = CurrencyUtil::formatCurrency(99.99, 'USD');
$this->assertStringContainsString('99.99', $formatted);
$this->assertStringContainsString('$', $formatted);
}
public function test_format_currency_with_custom_locale(): void
{
$formatted = CurrencyUtil::formatCurrency(199.99, 'BRL', 'pt_BR');
$this->assertStringContainsString('R$', $formatted);
}
public function test_format_currency_with_invalid_currency(): void
{
// Should not throw—graceful fallback
$formatted = CurrencyUtil::formatCurrency(100, 'INVALID');
$this->assertStringContainsString('100', $formatted);
}
public function test_country_currency_handles_lowercase(): void
{
$this->assertEquals(
CurrencyEnum::USD,
CurrencyUtil::getCountryCurrency('us')
);
}
public function test_country_currency_returns_usd_for_unknown(): void
{
$this->assertEquals(
CurrencyEnum::USD,
CurrencyUtil::getCountryCurrency('XX')
);
}
}TIP
Test assertions use assertStringContainsString rather than exact equality because NumberFormatter output varies across ICU versions. Checking for the presence of the amount and symbol is more reliable than matching the exact string.
Conclusion
Currency formatting requires more than number_format() and a dollar sign. By combining PHP's NumberFormatter with backed enums and a country mapping layer, you get:
- Locale-aware formatting that handles decimal separators, thousands grouping, and symbol placement automatically
- Type safety from backed enums that prevent invalid currency codes from propagating through your system
- Graceful degradation when the
intlextension isn't available, so your app never crashes over a formatting issue - Clean API where callers just pass a country code or currency code and get a properly formatted string back
The key design decision is the two-layer fallback: always produce something readable rather than throwing. In production, a price displayed as $ 99.99 (the fallback format) is infinitely better than an error page.

