Formateo de Monedas en PHP: Usando NumberFormatter y Backed Enums
Introducción
Mostrar precios correctamente en diferentes monedas es uno de esos problemas que parece simple hasta que te das cuenta de que $1,234.56 en EE.UU. es 1.234,56 € en Alemania y ¥1,235 en Japón (sin decimales). La clase NumberFormatter de PHP maneja estas reglas específicas de cada localidad, pero su documentación es escasa y depende de la extensión intl—que no siempre está disponible.
Este artículo presenta un enfoque probado en producción: un CurrencyEnum con backed enum combinado con una clase CurrencyUtil que formatea monedas correctamente, mapea países a sus monedas y hace fallback de forma elegante cuando intl no está presente.
Prerrequisitos
- PHP 8.1+ (para backed enums)
- La extensión
intl(recomendada, pero el código funciona sin ella)
Verifica si intl está instalada:
php -m | grep intlSi no está instalada:
# Debian/Ubuntu
sudo apt-get install php-intl
# macOS con Homebrew
brew install php@8.3 # intl viene incluida por defectoDefiniendo Monedas con un Backed Enum
PHP 8.1 introdujo backed enums—enums donde cada caso tiene un valor escalar. Esto es ideal para códigos de moneda, que son strings estandarizados de tres letras ISO 4217:
enum CurrencyEnum: string
{
case USD = 'USD'; // Dólar Estadounidense
case EUR = 'EUR'; // Euro
case MXN = 'MXN'; // Peso Mexicano
case COP = 'COP'; // Peso Colombiano
case BRL = 'BRL'; // Real Brasileño
case GBP = 'GBP'; // Libra Esterlina
case JPY = 'JPY'; // Yen Japonés
case INR = 'INR'; // Rupia India
// ... 43 monedas en total
}Cada caso lleva su símbolo y nombre legible a través de expresiones 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
Los backed enums te dan tryFrom() gratis—CurrencyEnum::tryFrom('USD') retorna el caso, mientras que CurrencyEnum::tryFrom('INVALID') retorna null sin lanzar excepción. Esto es esencial para validación.
Filtrando Monedas Comunes
No todas las 43 monedas necesitan aparecer en cada dropdown. Un método common() retorna el subconjunto más frecuentemente utilizado:
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 a Monedas
Cuando conoces el país del usuario (por geolocalización de IP, datos de perfil o localidad del navegador), puedes seleccionar automáticamente su moneda. La expresión match de PHP 8.0 lo hace conciso:
public static function getCountryCurrency(string $countryCode): CurrencyEnum
{
$countryCode = strtoupper($countryCode);
return match ($countryCode) {
// Norteamérica
'US' => CurrencyEnum::USD,
'CA' => CurrencyEnum::CAD,
'MX' => CurrencyEnum::MXN,
// Zona Euro (19 países, una moneda)
'ES', 'FR', 'DE', 'IT', 'PT', 'BE', 'NL', 'AT',
'GR', 'FI', 'IE', 'LU', 'EE', 'LV', 'LT', 'SK',
'SI', 'MT', 'CY' => CurrencyEnum::EUR,
// Sudamérica
'CO' => CurrencyEnum::COP,
'BR' => CurrencyEnum::BRL,
'AR' => CurrencyEnum::ARS,
'EC' => CurrencyEnum::USD, // Ecuador usa USD
// Asia
'CN' => CurrencyEnum::CNY,
'JP' => CurrencyEnum::JPY,
'IN' => CurrencyEnum::INR,
// ...más de 50 países en total
default => CurrencyEnum::USD,
};
}Algunos puntos a notar:
- Normalización de mayúsculas:
strtoupper()asegura que'us'y'US'funcionen. - Coincidencia multi-país: Los 19 países de la Zona Euro se agrupan en un solo brazo del
match. - Casos especiales: Ecuador usa USD a pesar de estar en Sudamérica—la expresión
matchlo hace explícito. - Default seguro: Códigos de país desconocidos retornan USD en vez de lanzar excepción.
- Reporte de errores: Todo el método está envuelto en un
try/catchque reporta a tu rastreador de errores y aún retorna USD.
Formateando con NumberFormatter
El método principal de formateo usa la clase NumberFormatter de PHP, parte de la extensión 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) {
// Capa 1: NumberFormatter retornó false
$currencyEnum = CurrencyEnum::tryFrom($currency);
$symbol = $currencyEnum ? $currencyEnum->symbol() : '$';
return $symbol . ' ' . number_format($amount, 2);
}
return $formatted;
} catch (Throwable $e) {
// Capa 2: extensión intl ausente u otro error
$currencyEnum = CurrencyEnum::tryFrom($currency);
$symbol = $currencyEnum ? $currencyEnum->symbol() : '$';
return $symbol . ' ' . number_format($amount, 2);
}
}Cómo Funciona NumberFormatter
NumberFormatter es parte de ICU (International Components for Unicode). Cuando llamas a formatCurrency(), este:
- Consulta las reglas de formateo de la localidad (separador decimal, agrupación de miles, posición del símbolo)
- Aplica el símbolo de moneda correcto para el código de moneda
- Establece el número correcto de decimales (2 para la mayoría de monedas, 0 para JPY)
// Formato de EE.UU.: símbolo antes, miles con coma, decimal con punto
CurrencyUtil::formatCurrency(1234.56, 'USD', 'en_US');
// → "$1,234.56"
// Formato alemán: símbolo después, miles con punto, decimal con coma
CurrencyUtil::formatCurrency(1234.56, 'EUR', 'de_DE');
// → "1.234,56 €"
// Yen japonés: sin decimales
CurrencyUtil::formatCurrency(1234.56, 'JPY', 'ja_JP');
// → "¥1,235"Importante
La salida de NumberFormatter depende de la versión de la biblioteca ICU contra la que tu PHP fue compilado, no de la versión de PHP en sí. Dos servidores corriendo la misma versión de PHP pueden producir salidas diferentes si sus versiones de ICU difieren. Siempre prueba en tu entorno de destino.
El Fallback en Dos Capas
El código tiene dos rutas de fallback, y esto es intencional:
Capa 1 — formatCurrency() retorna false. Esto sucede cuando NumberFormatter no puede procesar la combinación de localidad y código de moneda. El formateador existe pero falla con la entrada específica.
Capa 2 — Se captura un Throwable. Esto sucede cuando la extensión intl no está instalada (la clase NumberFormatter no existe), o cuando ocurre algún otro error inesperado.
Ambas capas recurren a la misma estrategia: buscar el símbolo en el enum, usar $ como valor por defecto si el código de moneda es desconocido, y usar el number_format() nativo de PHP con 2 decimales. El resultado no será perfecto en términos de localidad, pero siempre será legible.
Mapeando Monedas a Localidades
Para formatear una moneda correctamente sin requerir que el llamador sepa la localidad correcta, el utilitario provee un mapeo de moneda a localidad:
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 monedas mapeadas
default => 'en_US',
};
}Esto te permite encadenar llamadas:
$currency = 'BRL';
$locale = CurrencyUtil::getCurrencyLocale($currency);
$formatted = CurrencyUtil::formatCurrency(199.99, $currency, $locale);
// → "R$ 199,99"Ejemplo Práctico: Catálogo E-Commerce
Así es como estas piezas se combinan en un escenario real de e-commerce—un modelo de producto que muestra precios en la moneda local del cliente:
class CatalogProduct
{
public function getPriceForContact(string $countryCode): string
{
// 1. Determinar moneda por país
$currency = CurrencyUtil::getCountryCurrency($countryCode);
// 2. Buscar el precio para esta moneda
$price = $this->getPriceForCurrency($currency->value);
// 3. Obtener la localidad correcta
$locale = CurrencyUtil::getCurrencyLocale($currency->value);
// 4. Formatear
return CurrencyUtil::formatCurrency($price, $currency->value, $locale);
}
public function getPriceForCurrency(string $currencyCode): float
{
// Verificar si tenemos un precio definido para esta moneda
if (CurrencyUtil::isSupported($currencyCode)) {
return $this->prices[$currencyCode] ?? $this->prices['USD'] ?? 0;
}
// Retornar precio en 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 €"Construyendo un Selector de Monedas
Para interfaces administrativas, getSupportedCurrencies() provee los datos para un 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' => '$'],
// ...
// ]Probando el Formateo de Monedas
El formateo de monedas tiene suficientes casos límite como para que las pruebas sean esenciales. Estos son los escenarios clave a cubrir:
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
{
// No debe lanzar excepción—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
Las aserciones de los tests usan assertStringContainsString en lugar de igualdad exacta porque la salida de NumberFormatter varía entre versiones de ICU. Verificar la presencia del monto y el símbolo es más confiable que comparar la cadena exacta.
Conclusión
El formateo de monedas requiere más que number_format() y un signo de dólar. Al combinar el NumberFormatter de PHP con backed enums y una capa de mapeo de países, obtienes:
- Formateo sensible a la localidad que maneja separadores decimales, agrupación de miles y posicionamiento de símbolos automáticamente
- Seguridad de tipos con backed enums que impiden que códigos de moneda inválidos se propaguen por tu sistema
- Degradación elegante cuando la extensión
intlno está disponible, para que tu aplicación nunca se rompa por un problema de formateo - API limpia donde los llamadores solo pasan un código de país o moneda y reciben un string formateado correctamente
La decisión de diseño clave es el fallback en dos capas: siempre producir algo legible en vez de lanzar excepción. En producción, un precio mostrado como $ 99.99 (el formato de fallback) es infinitamente mejor que una página de error.

