PHPのintl拡張で金額・日付・並び順をロケールに任せる
intl拡張は、金額・日付・数値の表記や文字列の並び順を、ロケール(言語と地域)のルールに従って処理するPHPの標準拡張です。NumberFormatterやIntlDateFormatter、Collatorといったクラスを提供します。
多言語対応の案件で、いちばん最初に壊れるのは翻訳ファイルではなく金額の表示だったりします。「1,234円」を英語圏向けに出したら「1,234 yen」になっていて、桁区切りはそのまま、みたいなやつですね。桁区切りも小数点も通貨記号の位置も、全部ロケールごとに違います。それを自前のif文で書き始めると、だいたい途中で心が折れます。
その面倒を全部引き受けてくれるのがintlです。中身はICU(International Components for Unicode)で、CLDRという世界中のロケールデータを持っています。今日はその実務での使いどころと、ハマりやすいところを書きます。
まず、intlは入っているのか
intlはPHPにバンドルされていますが、デフォルトで有効とは限りません。ディストリビューションによっては別パッケージですし、共有レンタルサーバーだと無効なこともあります。ライブラリ側で使うなら、composer.jsonに ext-intl を書いておくのが安全です。
<?php
if (!extension_loaded('intl')) {
throw new RuntimeException('ext-intl が必要です');
}
// ICU本体のバージョン。出力の細部はこれに依存する
echo INTL_ICU_VERSION, PHP_EOL;
このバージョン確認、地味ですが大事です。ICUのバージョンが上がると、記号や区切りの細部がしれっと変わることがあります。後で触れますが、intlの出力を「固定の文字列」として期待するとテストが壊れます。
金額はどう書く?
通貨表示は NumberFormatter::CURRENCY スタイルと formatCurrency() の組み合わせが基本です。シグネチャは formatCurrency(float $amount, string $currency): string|false で、第2引数はISO 4217の3文字コードです。
<?php
$fmt = new NumberFormatter('ja_JP', NumberFormatter::CURRENCY);
echo $fmt->formatCurrency(1234, 'JPY'), PHP_EOL; // 円記号 + 1,234
echo $fmt->formatCurrency(1234, 'USD'), PHP_EOL; // $1,234.00
$en = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
echo $en->formatCurrency(1234, 'JPY'), PHP_EOL; // ¥1,234 のような表記
ここが気持ちいいところで、小数桁数を自分で決めなくていいんです。JPYなら小数なし、USDなら2桁、というのはロケールではなく通貨側の性質で、ICUがちゃんと知っています。number_format($v, 2) をハードコードして日本円に小数がついた、という事故が消えます。
ロケールと通貨コードは別物、という点も押さえておきたいところです。「日本語で表示するが、金額はドル」は普通にあります。第1引数がロケール(誰に見せるか)、formatCurrency() の第2引数が通貨(何のお金か)です。
丸めは自分の想像と違うかもしれない
NumberFormatterのデフォルトの丸めモードは、いわゆる四捨五入ではなくbanker’s rounding(偶数丸め)です。0.5ちょうどのとき、近い偶数側に丸めます。会計系だとこれで金額がずれた、という報告が出がちです。
<?php
$fmt = new NumberFormatter('ja_JP', NumberFormatter::DECIMAL);
$fmt->setAttribute(NumberFormatter::FRACTION_DIGITS, 0);
// デフォルト(HALFEVEN)だと 0.5 は偶数側へ
echo $fmt->format(2.5), PHP_EOL; // 2
echo $fmt->format(3.5), PHP_EOL; // 4
// 一般的な四捨五入にしたいとき
$fmt->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_HALFUP);
echo $fmt->format(2.5), PHP_EOL; // 3
個人的には、金額の丸めは表示層でやるべきではないと思っています。丸めた値を保存したいならBCMathなどで計算層で確定させて、NumberFormatterには「もう決まった値の見た目」だけを任せる。表示のついでに数字が変わる仕組みは、後から必ず揉めます。
日付はDateTimeImmutableをそのまま渡せる
日付は IntlDateFormatter です。コンストラクタの引数が多いので身構えますが、実務では IntlDateFormatter::formatObject() の方が出番が多いかもしれません。DateTimeInterfaceを直接受け取れます。
<?php
$dt = new DateTimeImmutable('2026-08-27 14:05:00', new DateTimeZone('Asia/Tokyo'));
// 日付スタイルと時刻スタイルを配列で指定
echo IntlDateFormatter::formatObject(
$dt,
[IntlDateFormatter::LONG, IntlDateFormatter::SHORT],
'ja_JP'
), PHP_EOL;
// 文字列を渡すとICUのパターンとして解釈される
echo IntlDateFormatter::formatObject($dt, 'yyyy年M月d日(E)', 'ja_JP'), PHP_EOL;
第2引数が配列なら[日付スタイル, 時刻スタイル]、intなら日付と時刻に同じスタイル、文字列ならICUのパターン、という3通りの解釈をします。便利ですが、読み手には分かりにくいので、プロジェクト内でどれを使うか決めておくといいと思います。
細かく制御したいときは素直にインスタンスを作ります。create() の引数順は locale、dateType、timeType、timezone、calendar、pattern です。timezoneを省略すると date_default_timezone_get() の値が使われるので、サーバー設定に結果が引きずられます。明示しておくのが安全ですね。
<?php
$fmt = new IntlDateFormatter(
'ja_JP',
IntlDateFormatter::LONG,
IntlDateFormatter::NONE,
'Asia/Tokyo'
);
echo $fmt->format(new DateTimeImmutable('2026-08-27')), PHP_EOL;
和暦が要るときは、ロケール文字列にICUのキーワードを足して 'ja_JP@calendar=japanese' とすると和暦カレンダーで出力できます。元号の切り替わりをアプリ側で持たなくて済むのは、日本の業務システムでは地味に効きます。
なぜstrcmpで日本語を並べてはいけないのか
ソートも国際化の対象です。sort() や strcmp() はバイト列の比較なので、日本語では期待通りになりません。「あ」と「ア」は別物として扱われますし、ドイツ語の「ä」はアルファベットのaの隣ではなく、ずっと後ろに飛びます。
<?php
$names = ['さくら', 'あんず', 'ザクロ', 'あんこ'];
$collator = new Collator('ja_JP');
$collator->sort($names); // 参照渡しで $names 自体が並び替わる
print_r($names);
Collator::sort() は配列を参照で受け取って直接並び替える点に注意してください。戻り値は成否のboolで、並んだ配列ではありません。ここは usort() と同じ感覚で、返り値を代入すると true が入ります。
比較関数として使いたいときは $collator->compare($a, $b) が使えます。usortのコールバックとしてそのまま渡せる形なので、オブジェクトの配列を名前順に並べるときはこちらですね。
<?php
$collator = new Collator('ja_JP');
usort($users, static fn($a, $b) => $collator->compare($a->name, $b->name));
テストでハマるところ
intlを使った出力を assertSame('¥1,234', ...) のように固定文字列で検証するのは、おすすめしません。理由は2つあります。ひとつはICUのバージョン差で記号や空白が変わること。もうひとつが厄介で、通貨記号と数字の間にノーブレークスペース(U+00A0)やナローノーブレークスペース(U+202F)が入るロケールがあることです。見た目は半角スペースなのに、テストは落ちます。
<?php
// 比較前に空白類を通常のスペースへ寄せてしまう
$normalized = preg_replace('/[\x{00A0}\x{202F}\x{2009}]/u', ' ', $formatted);
そもそも「フォーマッタが正しく呼ばれたか」だけを検証して、出力文字列そのものはICUを信じる、という割り切りでもいいと思います。CLDRのデータを自分のテストで再実装しても得るものは少ないので。
もうひとつ。NumberFormatter や Collator のインスタンス生成はそれなりに重い処理です。ループの中で毎回 new するのではなく、ロケールごとに1つ作って使い回すのが定石です。テーブルの1万行を整形するようなページだと、体感でわかるくらい違います。
まとめ
intlは「ロケールごとの正解」を自分で持たなくていい、というのが最大の価値だと思っています。桁区切りも通貨の小数桁も元号も、CLDRという巨大な辞書がすでに持っているものを、わざわざアプリ側で持ち直す理由はあまりありません。
ただし出力は環境依存です。ICUのバージョン、ロケール、そして見えない空白。この3つを意識しておけば、あとはかなり素直に働いてくれる拡張だという気がしています。
よくある質問
Q. number_format() とNumberFormatterはどう使い分けますか?
A. 表示相手が日本語固定で、単純な桁区切りだけなら number_format() で十分です。多言語対応や通貨表示が絡むなら NumberFormatter を使ってください。通貨ごとの小数桁数を自動で扱えるのが決定的な差です。
Q. Collator::sort() の戻り値を代入したら true になりました。
A. Collator::sort() は第1引数を参照で受け取り、その配列自体を並び替えます。戻り値は成否のboolなので、代入せずに元の変数を使ってください。並び替え済みの配列が返るわけではありません。
Q. intl拡張が有効にできないサーバーではどうすればいいですか?
A. symfony/intl や symfony/polyfill-intl-* などのPHP実装で一部の機能を代替できます。ただしICU本体ほど完全ではないので、通貨や和暦の要件が厳しい案件では、intlが使える実行環境を選ぶ方が結果的に安上がりだと思います。