Skip to content

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 intl extension (recommended, but the code works without it)

Check if intl is installed:

bash
php -m | grep intl

If missing, install it:

bash
# Debian/Ubuntu
sudo apt-get install php-intl

# macOS with Homebrew
brew install php@8.3 # intl is included by default

Defining 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:

php
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:

php
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:

php
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:

php
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 match arm.
  • Special cases: Ecuador uses USD despite being in South America—the match expression 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/catch that 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:

php
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:

  1. Looks up the locale's formatting rules (decimal separator, thousands grouping, symbol placement)
  2. Applies the correct currency symbol for the currency code
  3. Sets the right number of decimal places (2 for most currencies, 0 for JPY)
php
// 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 1formatCurrency() 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:

php
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:

php
$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:

php
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:

php
$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:

php
$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:

php
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 intl extension 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.