Skip to content

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 是否已安装:

bash
php -m | grep intl

如果未安装:

bash
# 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 三字母字符串:

php
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 表达式携带其符号和可读名称:

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 免费提供 tryFrom() 方法——CurrencyEnum::tryFrom('USD') 返回对应的 case,而 CurrencyEnum::tryFrom('INVALID') 返回 null 而不会抛出异常。这对验证来说至关重要。

过滤常用货币

并非所有 43 种货币都需要显示在每个下拉菜单中。common() 方法返回最常用的子集:

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

国家到货币的映射

当你知道用户的国家(通过 IP 地理定位、用户资料或浏览器区域设置),就可以自动选择他们的货币。PHP 8.0 的 match 表达式使代码简洁:

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

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) {
            // 第一层: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() 时,它会:

  1. 查找该区域的格式化规则(小数分隔符、千位分组、符号位置)
  2. 为货币代码应用正确的货币符号
  3. 设置正确的小数位数(大多数货币为 2 位,JPY 为 0 位)
php
// 美式格式:符号在前,逗号分隔千位,点号分隔小数
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 位小数。结果可能不够完美的本地化格式,但始终可读。

货币到区域的映射

为了在不要求调用者知道正确区域的情况下正确格式化货币,工具类提供了货币到区域的映射:

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 种货币
        default => 'en_US',
    };
}

这使你可以链式调用:

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

实际示例:电商商品目录

以下是这些组件在真实电商场景中的组合使用——一个根据客户所在地显示本地货币价格的商品模型:

php
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;
    }
}

使用方式:

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

构建货币选择器

对于管理界面,getSupportedCurrencies() 提供下拉菜单所需的数据:

php
$currencies = CurrencyUtil::getSupportedCurrencies();

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

测试货币格式化

货币格式化有足够多的边界情况,因此测试是必不可少的。以下是需要覆盖的关键场景:

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
    {
        // 不应抛出异常——优雅回退
        $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(回退格式)显示的价格比错误页面好无数倍。