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:
php -m | grep intlSe não estiver instalada:
# Debian/Ubuntu
sudo apt-get install php-intl
# macOS com Homebrew
brew install php@8.3 # intl já vem incluída por padrãoDefinindo 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:
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:
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:
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:
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
matchtorna 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/catchque 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:
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:
- Consulta as regras de formatação da localidade (separador decimal, agrupamento de milhares, posição do símbolo)
- Aplica o símbolo de moeda correto para o código de moeda
- Define o número correto de casas decimais (2 para a maioria das moedas, 0 para JPY)
// 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 1 — formatCurrency() 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:
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:
$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:
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:
$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:
$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:
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
intlnã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.

