PHP 货币格式化:使用 NumberFormatter 和 Backed Enums
简介
在不同货币之间正确显示价格是一个看似简单、实则复杂的问题——美国的 $1,234.56 在德国是 1.234,56 €,在日本则是 ¥1,235(没有小数位)。PHP 的 NumberFormatter 类可以处理这些区域特定的规则,但它的文档很少,而且依赖于 intl 扩展——而该扩展并不总是可用的。
本文介绍一种经过生产验证的方案:将 CurrencyEnum backed enum 与 CurrencyUtil 工具类结合使用,实现正确的货币格式化、国家到货币的映射,以及在 intl 不可用时的优雅回退。
前置要求
- PHP 8.1+(用于 backed enums)
intl扩展(推荐,但代码在没有它的情况下也能工作)
检查 intl 是否已安装:
php -m | grep intl如果未安装:
# Debian/Ubuntu
sudo apt-get install php-intl
# macOS 使用 Homebrew
brew install php@8.3 # intl 默认已包含使用 Backed Enum 定义货币
PHP 8.1 引入了 backed enums——每个 case 都有一个标量值的枚举。这非常适合货币代码,因为它们是标准化的 ISO 4217 三字母字符串:
enum CurrencyEnum: string
{
case USD = 'USD'; // 美元
case EUR = 'EUR'; // 欧元
case MXN = 'MXN'; // 墨西哥比索
case COP = 'COP'; // 哥伦比亚比索
case BRL = 'BRL'; // 巴西雷亚尔
case GBP = 'GBP'; // 英镑
case JPY = 'JPY'; // 日元
case INR = 'INR'; // 印度卢比
// ... 共 43 种货币
}每个 case 通过 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 免费提供 tryFrom() 方法——CurrencyEnum::tryFrom('USD') 返回对应的 case,而 CurrencyEnum::tryFrom('INVALID') 返回 null 而不会抛出异常。这对验证来说至关重要。
过滤常用货币
并非所有 43 种货币都需要显示在每个下拉菜单中。common() 方法返回最常用的子集:
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,
];
}国家到货币的映射
当你知道用户的国家(通过 IP 地理定位、用户资料或浏览器区域设置),就可以自动选择他们的货币。PHP 8.0 的 match 表达式使代码简洁:
public static function getCountryCurrency(string $countryCode): CurrencyEnum
{
$countryCode = strtoupper($countryCode);
return match ($countryCode) {
// 北美
'US' => CurrencyEnum::USD,
'CA' => CurrencyEnum::CAD,
'MX' => CurrencyEnum::MXN,
// 欧元区(19个国家,一种货币)
'ES', 'FR', 'DE', 'IT', 'PT', 'BE', 'NL', 'AT',
'GR', 'FI', 'IE', 'LU', 'EE', 'LV', 'LT', 'SK',
'SI', 'MT', 'CY' => CurrencyEnum::EUR,
// 南美
'CO' => CurrencyEnum::COP,
'BR' => CurrencyEnum::BRL,
'AR' => CurrencyEnum::ARS,
'EC' => CurrencyEnum::USD, // 厄瓜多尔使用美元
// 亚洲
'CN' => CurrencyEnum::CNY,
'JP' => CurrencyEnum::JPY,
'IN' => CurrencyEnum::INR,
// ...共覆盖 50 多个国家
default => CurrencyEnum::USD,
};
}几个值得注意的要点:
- 大小写规范化:
strtoupper()确保'us'和'US'都能正常工作。 - 多国匹配:19 个欧元区国家被分组在一个
match分支中。 - 特殊情况:厄瓜多尔虽然在南美但使用美元——
match表达式让这一点一目了然。 - 安全默认值:未知国家代码回退到 USD 而不是抛出异常。
- 错误报告:整个方法包裹在
try/catch中,向错误追踪器报告并仍然返回 USD。
使用 NumberFormatter 格式化
核心格式化方法使用 PHP intl 扩展中的 NumberFormatter 类:
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) {
// 第一层:NumberFormatter 返回 false
$currencyEnum = CurrencyEnum::tryFrom($currency);
$symbol = $currencyEnum ? $currencyEnum->symbol() : '$';
return $symbol . ' ' . number_format($amount, 2);
}
return $formatted;
} catch (Throwable $e) {
// 第二层:intl 扩展缺失或其他错误
$currencyEnum = CurrencyEnum::tryFrom($currency);
$symbol = $currencyEnum ? $currencyEnum->symbol() : '$';
return $symbol . ' ' . number_format($amount, 2);
}
}NumberFormatter 的工作原理
NumberFormatter 是 ICU(International Components for Unicode)的一部分。当你调用 formatCurrency() 时,它会:
- 查找该区域的格式化规则(小数分隔符、千位分组、符号位置)
- 为货币代码应用正确的货币符号
- 设置正确的小数位数(大多数货币为 2 位,JPY 为 0 位)
// 美式格式:符号在前,逗号分隔千位,点号分隔小数
CurrencyUtil::formatCurrency(1234.56, 'USD', 'en_US');
// → "$1,234.56"
// 德式格式:符号在后,点号分隔千位,逗号分隔小数
CurrencyUtil::formatCurrency(1234.56, 'EUR', 'de_DE');
// → "1.234,56 €"
// 日元:没有小数位
CurrencyUtil::formatCurrency(1234.56, 'JPY', 'ja_JP');
// → "¥1,235"重要提示
NumberFormatter 的输出取决于 PHP 编译时所链接的 ICU 库版本,而不是 PHP 版本本身。运行相同 PHP 版本的两台服务器如果 ICU 版本不同,可能会产生不同的输出。请务必在目标环境中进行测试。
双层回退机制
代码有两条回退路径,这是有意为之的:
第一层 — formatCurrency() 返回 false。当 NumberFormatter 无法处理特定的区域和货币代码组合时会发生这种情况。格式化器存在但在特定输入上失败。
第二层 — 捕获 Throwable。当 intl 扩展根本没有安装(NumberFormatter 类不存在),或发生其他意外错误时会触发。
两层都采用相同的策略:从 enum 中查找符号,如果货币代码未知则默认使用 $,并使用 PHP 原生的 number_format() 保留 2 位小数。结果可能不够完美的本地化格式,但始终可读。
货币到区域的映射
为了在不要求调用者知道正确区域的情况下正确格式化货币,工具类提供了货币到区域的映射:
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 种货币
default => 'en_US',
};
}这使你可以链式调用:
$currency = 'BRL';
$locale = CurrencyUtil::getCurrencyLocale($currency);
$formatted = CurrencyUtil::formatCurrency(199.99, $currency, $locale);
// → "R$ 199,99"实际示例:电商商品目录
以下是这些组件在真实电商场景中的组合使用——一个根据客户所在地显示本地货币价格的商品模型:
class CatalogProduct
{
public function getPriceForContact(string $countryCode): string
{
// 1. 根据国家确定货币
$currency = CurrencyUtil::getCountryCurrency($countryCode);
// 2. 查找该货币的价格
$price = $this->getPriceForCurrency($currency->value);
// 3. 获取正确的区域设置
$locale = CurrencyUtil::getCurrencyLocale($currency->value);
// 4. 格式化
return CurrencyUtil::formatCurrency($price, $currency->value, $locale);
}
public function getPriceForCurrency(string $currencyCode): float
{
// 检查是否有该货币的价格
if (CurrencyUtil::isSupported($currencyCode)) {
return $this->prices[$currencyCode] ?? $this->prices['USD'] ?? 0;
}
// 回退到美元价格
return $this->prices['USD'] ?? 0;
}
}使用方式:
$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 €"构建货币选择器
对于管理界面,getSupportedCurrencies() 提供下拉菜单所需的数据:
$currencies = CurrencyUtil::getSupportedCurrencies();
// 返回:
// [
// ['value' => 'USD', 'label' => 'US Dollar (USD)', 'symbol' => '$'],
// ['value' => 'EUR', 'label' => 'Euro (EUR)', 'symbol' => '€'],
// ['value' => 'MXN', 'label' => 'Mexican Peso (MXN)', 'symbol' => '$'],
// ...
// ]测试货币格式化
货币格式化有足够多的边界情况,因此测试是必不可少的。以下是需要覆盖的关键场景:
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
{
// 不应抛出异常——优雅回退
$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
测试断言使用 assertStringContainsString 而不是精确相等,因为 NumberFormatter 的输出在不同 ICU 版本之间会有差异。检查金额和符号是否存在比匹配精确字符串更可靠。
总结
货币格式化不仅仅是 number_format() 加一个美元符号。通过将 PHP 的 NumberFormatter 与 backed enums 和国家映射层结合,你可以获得:
- 区域感知的格式化,自动处理小数分隔符、千位分组和符号位置
- 类型安全,backed enums 防止无效的货币代码在系统中传播
- 优雅降级,当
intl扩展不可用时,你的应用程序永远不会因为格式化问题而崩溃 - 简洁的 API,调用者只需传入国家代码或货币代码,就能获得正确格式化的字符串
关键的设计决策是双层回退:始终产生可读的内容而不是抛出异常。在生产环境中,以 $ 99.99(回退格式)显示的价格比错误页面好无数倍。

