Skip to content

Formatação de Moedas em PHP: Usando NumberFormatter e Backed Enums

Introdução

Exibir preços corretamente em diferentes moedas é um daqueles problemas que parece simples até você perceber que $1,234.56 nos EUA é 1.234,56 € na Alemanha e ¥1,235 no Japão (sem casas decimais). A classe NumberFormatter do PHP lida com essas regras específicas de cada localidade, mas sua documentação é escassa e depende da extensão intl—que nem sempre está disponível.

Este artigo apresenta uma abordagem testada em produção: um CurrencyEnum com backed enum combinado com uma classe CurrencyUtil que formata moedas corretamente, mapeia países para suas moedas e faz fallback de forma elegante quando a extensão intl não está presente.

Pré-requisitos

  • PHP 8.1+ (para backed enums)
  • A extensão intl (recomendada, mas o código funciona sem ela)

Verifique se a intl está instalada:

bash
php -m | grep intl

Se não estiver instalada:

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

# macOS com Homebrew
brew install php@8.3 # intl já vem incluída por padrão

Definindo Moedas com um Backed Enum

O PHP 8.1 introduziu backed enums—enums onde cada caso tem um valor escalar. Isso é ideal para códigos de moeda, que são strings padronizadas de três letras ISO 4217:

php
enum CurrencyEnum: string
{
    case USD = 'USD'; // Dólar Americano
    case EUR = 'EUR'; // Euro
    case MXN = 'MXN'; // Peso Mexicano
    case COP = 'COP'; // Peso Colombiano
    case BRL = 'BRL'; // Real Brasileiro
    case GBP = 'GBP'; // Libra Esterlina
    case JPY = 'JPY'; // Iene Japonês
    case INR = 'INR'; // Rúpia Indiana
    // ... 43 moedas no total
}

Cada caso carrega seu símbolo e nome legível através de expressões match:

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 fornecem tryFrom() gratuitamente—CurrencyEnum::tryFrom('USD') retorna o caso, enquanto CurrencyEnum::tryFrom('INVALID') retorna null sem lançar exceção. Isso é essencial para validação.

Filtrando Moedas Comuns

Nem todas as 43 moedas precisam aparecer em cada dropdown. Um método common() retorna o subconjunto mais frequentemente usado:

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,
    ];
}

Mapeando Países para Moedas

Quando você sabe o país do usuário (por geolocalização de IP, dados de perfil ou localidade do navegador), pode selecionar automaticamente a moeda dele. A expressão match do PHP 8.0 torna isso conciso:

php
public static function getCountryCurrency(string $countryCode): CurrencyEnum
{
    $countryCode = strtoupper($countryCode);

    return match ($countryCode) {
        // América do Norte
        'US' => CurrencyEnum::USD,
        'CA' => CurrencyEnum::CAD,
        'MX' => CurrencyEnum::MXN,

        // Zona Euro (19 países, uma moeda)
        'ES', 'FR', 'DE', 'IT', 'PT', 'BE', 'NL', 'AT',
        'GR', 'FI', 'IE', 'LU', 'EE', 'LV', 'LT', 'SK',
        'SI', 'MT', 'CY' => CurrencyEnum::EUR,

        // América do Sul
        'CO' => CurrencyEnum::COP,
        'BR' => CurrencyEnum::BRL,
        'AR' => CurrencyEnum::ARS,
        'EC' => CurrencyEnum::USD, // Equador usa USD

        // Ásia
        'CN' => CurrencyEnum::CNY,
        'JP' => CurrencyEnum::JPY,
        'IN' => CurrencyEnum::INR,

        // ...mais de 50 países no total

        default => CurrencyEnum::USD,
    };
}

Alguns pontos a observar:

  • Normalização de maiúsculas: strtoupper() garante que 'us' e 'US' funcionem.
  • Correspondência multi-país: Os 19 países da Zona Euro são agrupados em um único braço do match.
  • Casos especiais: O Equador usa USD apesar de estar na América do Sul—a expressão match torna isso explícito.
  • Default seguro: Códigos de país desconhecidos retornam USD em vez de lançar exceção.
  • Relatório de erros: Todo o método é envolvido em um try/catch que reporta ao seu rastreador de erros e ainda retorna USD.

Formatando com NumberFormatter

O método principal de formatação usa a classe NumberFormatter do PHP, parte da extensão intl:

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) {
            // Camada 1: NumberFormatter retornou false
            $currencyEnum = CurrencyEnum::tryFrom($currency);
            $symbol = $currencyEnum ? $currencyEnum->symbol() : '$';

            return $symbol . ' ' . number_format($amount, 2);
        }

        return $formatted;
    } catch (Throwable $e) {
        // Camada 2: extensão intl ausente ou outro erro
        $currencyEnum = CurrencyEnum::tryFrom($currency);
        $symbol = $currencyEnum ? $currencyEnum->symbol() : '$';

        return $symbol . ' ' . number_format($amount, 2);
    }
}

Como o NumberFormatter Funciona

NumberFormatter faz parte do ICU (International Components for Unicode). Quando você chama formatCurrency(), ele:

  1. Consulta as regras de formatação da localidade (separador decimal, agrupamento de milhares, posição do símbolo)
  2. Aplica o símbolo de moeda correto para o código de moeda
  3. Define o número correto de casas decimais (2 para a maioria das moedas, 0 para JPY)
php
// Formatação dos EUA: símbolo antes, milhares com vírgula, decimal com ponto
CurrencyUtil::formatCurrency(1234.56, 'USD', 'en_US');
// → "$1,234.56"

// Formatação alemã: símbolo depois, milhares com ponto, decimal com vírgula
CurrencyUtil::formatCurrency(1234.56, 'EUR', 'de_DE');
// → "1.234,56 €"

// Iene japonês: sem casas decimais
CurrencyUtil::formatCurrency(1234.56, 'JPY', 'ja_JP');
// → "¥1,235"

Importante

A saída do NumberFormatter depende da versão da biblioteca ICU contra a qual seu PHP foi compilado, não da versão do PHP em si. Dois servidores rodando a mesma versão do PHP podem produzir saídas diferentes se suas versões do ICU forem diferentes. Sempre teste no seu ambiente de destino.

O Fallback em Dois Níveis

O código tem dois caminhos de fallback, e isso é proposital:

Camada 1formatCurrency() retorna false. Isso acontece quando o NumberFormatter não consegue processar a combinação de localidade e código de moeda. O formatador existe mas falha na entrada específica.

Camada 2 — Um Throwable é capturado. Isso acontece quando a extensão intl não está instalada (a classe NumberFormatter não existe), ou quando algum outro erro inesperado ocorre.

Ambas as camadas recorrem à mesma estratégia: buscar o símbolo no enum, usar $ como padrão se o código de moeda for desconhecido, e usar o number_format() nativo do PHP com 2 casas decimais. O resultado não será perfeito em termos de localidade, mas será sempre legível.

Mapeando Moedas para Localidades

Para formatar uma moeda corretamente sem exigir que o chamador saiba a localidade correta, o utilitário fornece um mapeamento de moeda para localidade:

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 moedas mapeadas
        default => 'en_US',
    };
}

Isso permite encadear chamadas:

php
$currency = 'BRL';
$locale = CurrencyUtil::getCurrencyLocale($currency);
$formatted = CurrencyUtil::formatCurrency(199.99, $currency, $locale);
// → "R$ 199,99"

Exemplo Prático: Catálogo E-Commerce

Veja como essas peças se combinam em um cenário real de e-commerce—um modelo de produto que exibe preços na moeda local do cliente:

php
class CatalogProduct
{
    public function getPriceForContact(string $countryCode): string
    {
        // 1. Determinar moeda pelo país
        $currency = CurrencyUtil::getCountryCurrency($countryCode);

        // 2. Buscar o preço para esta moeda
        $price = $this->getPriceForCurrency($currency->value);

        // 3. Obter a localidade correta
        $locale = CurrencyUtil::getCurrencyLocale($currency->value);

        // 4. Formatar
        return CurrencyUtil::formatCurrency($price, $currency->value, $locale);
    }

    public function getPriceForCurrency(string $currencyCode): float
    {
        // Verificar se temos um preço definido para esta moeda
        if (CurrencyUtil::isSupported($currencyCode)) {
            return $this->prices[$currencyCode] ?? $this->prices['USD'] ?? 0;
        }

        // Retornar preço em USD como fallback
        return $this->prices['USD'] ?? 0;
    }
}

Uso:

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 €"

Construindo um Seletor de Moedas

Para interfaces administrativas, getSupportedCurrencies() fornece os dados para um dropdown:

php
$currencies = CurrencyUtil::getSupportedCurrencies();

// Retorna:
// [
//     ['value' => 'USD', 'label' => 'US Dollar (USD)', 'symbol' => '$'],
//     ['value' => 'EUR', 'label' => 'Euro (EUR)', 'symbol' => '€'],
//     ['value' => 'MXN', 'label' => 'Mexican Peso (MXN)', 'symbol' => '$'],
//     ...
// ]

Testando a Formatação de Moedas

A formatação de moedas tem casos limite suficientes para que testes sejam essenciais. Aqui estão os cenários principais a cobrir:

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
    {
        // Não deve lançar exceção—fallback elegante
        $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

As asserções dos testes usam assertStringContainsString em vez de igualdade exata porque a saída do NumberFormatter varia entre versões do ICU. Verificar a presença do valor e do símbolo é mais confiável do que comparar a string exata.

Conclusão

A formatação de moedas requer mais do que number_format() e um cifrão. Ao combinar o NumberFormatter do PHP com backed enums e uma camada de mapeamento de países, você obtém:

  • Formatação sensível à localidade que lida com separadores decimais, agrupamento de milhares e posicionamento de símbolos automaticamente
  • Segurança de tipos com backed enums que impedem códigos de moeda inválidos de se propagarem pelo seu sistema
  • Degradação elegante quando a extensão intl não está disponível, para que sua aplicação nunca quebre por causa de uma formatação
  • API limpa onde os chamadores apenas passam um código de país ou moeda e recebem uma string formatada corretamente

A decisão de design principal é o fallback em dois níveis: sempre produzir algo legível em vez de lançar exceção. Em produção, um preço exibido como $ 99.99 (o formato de fallback) é infinitamente melhor do que uma página de erro.