The Intl Extension: Locale-Aware Formatting and Sorting

Use PHP's Intl extension (built on ICU) to format numbers, currencies, dates, and pluralized messages correctly for a given locale, sort strings the way a native speaker expects, and normalize/transliterate Unicode text safely.

"Format this number" sounds like a trivial problem until you remember that 1234.5 is written 1,234.5 in the US, 1.234,5 in Germany, and 1 234,5 in France -- and that's before currencies, date orders, pluralization rules, or sorting accented letters even enter the picture. Hand-rolling these rules with string replacement and if/else chains is a losing game: there are hundreds of locales, and each has its own quirks that change over time as language committees update them. PHP's Intl extension wraps ICU (International Components for Unicode) -- the same library that powers locale handling in browsers, Java, and most major operating systems -- so you get battle-tested, continuously updated locale data instead of guesswork. This lesson covers Locale, NumberFormatter, IntlDateFormatter, MessageFormatter, Collator, and Unicode normalization/transliteration.

Locale and NumberFormatter

A locale identifier (like 'en_US', 'de_DE', or 'fr_FR') tells ICU which formatting conventions, calendar system, and language rules to apply. You rarely need to construct these by hand -- Locale::getDefault() reads PHP's configured default, and Locale::acceptFromHttp() can parse a browser's Accept-Language header into the best-matching locale string, which is how most web apps decide which locale to use per request.

NumberFormatter is the workhorse for turning a raw PHP number into locale-correct text. Its constructor takes a locale and a style constant -- NumberFormatter::DECIMAL for plain numbers, NumberFormatter::CURRENCY for money, NumberFormatter::PERCENT for percentages, and several others. Critically, it also handles the reverse direction: NumberFormatter::parse() turns locale-formatted user input (like '1.234,56' typed by a German user) back into a float PHP can do math with, which is something naive str_replace(',', '.', $input) tricks get wrong the moment thousands separators are involved.

For currency specifically, formatCurrency() takes an ISO 4217 currency code ('USD', 'EUR', 'JPY') separately from the locale, because the two are independent: you can format a EUR amount using French conventions, German conventions, or Irish English conventions, and each produces a different-looking (but numerically identical) string. This separation matters for sites that let a user pick a display language independently of which currency a price is actually denominated in.

• Locale identifiers follow language[_REGION] form: 'en_US', 'de_DE', 'ja_JP'. • Locale::acceptFromHttp() turns a browser Accept-Language header into a usable locale string. • NumberFormatter::DECIMAL, ::CURRENCY, ::PERCENT, ::SPELLOUT are common style constants. • format() turns a PHP number into locale text; parse() turns locale text back into a number. • formatCurrency($amount, $currencyCode) takes the ISO currency code separately from the display locale.

IntlDateFormatter and Timezones

IntlDateFormatter formats DateTime/DateTimeImmutable objects (or Unix timestamps) as locale-appropriate date and time strings, controlling date style and time style independently (IntlDateFormatter::FULL, ::LONG, ::MEDIUM, ::SHORT, or ::NONE to omit one entirely). Locale drives far more than word order and month names -- it also determines the calendar system in some locales (e.g., Buddhist or Japanese era calendars) and whether 12-hour or 24-hour time is conventional.

Timezone and locale are deliberately separate concerns in IntlDateFormatter: the locale controls how the date/time text is *worded*, while the timezone (passed as a constructor argument or an explicit DateTimeZone on the underlying DateTime object) controls *which instant* is displayed. A DateTimeImmutable already carries its own timezone, so the cleanest pattern is to build the DateTimeImmutable in the correct timezone first, then hand it to IntlDateFormatter purely for locale-aware text rendering -- letting the formatter also silently shift the timezone is a common source of confusing off-by-some-hours bugs.

Because IntlDateFormatter is locale-aware, relative or long-form output like weekday and month names comes out already translated -- 'Monday, January 5, 2026' in en_US becomes 'lundi 5 janvier 2026' in fr_FR with zero manual translation tables.

• IntlDateFormatter combines a date style, a time style, a locale, and a timezone. • Date style/time style (FULL/LONG/MEDIUM/SHORT/NONE) are set independently. • Locale controls wording (month/weekday names, order); timezone controls the actual instant shown. • Best practice: build the DateTimeImmutable in the right timezone first, then format for locale text only.

MessageFormatter and Pluralization

Pluralization rules differ wildly between languages -- English has two forms (1 item / 2 items), but Polish has three, Arabic has six, and some languages have effectively one. MessageFormatter lets you write a single ICU MessageFormat pattern per locale that encodes the plural rule declaratively, instead of writing '$count == 1 ? "item" : "items"' by hand and getting it wrong for every language that isn't English.

An ICU MessageFormat pattern looks like '{count, plural, one {# item} other {# items}}' -- the 'one' and 'other' keywords are plural *categories* ICU defines per locale (not literally 'equals 1'), so the same pattern text, compiled against a different locale, automatically applies that language's actual plural category rules. MessageFormat also supports select (branching on an arbitrary string, e.g. gendered phrasing) and nested patterns for combining several locale-sensitive choices in one message.

Because format() takes named/positional arguments and substitutes them safely, MessageFormatter also sidesteps a common i18n bug: naive string concatenation of translated fragments around a number often produces grammatically broken sentences once word order changes between languages, whereas a single MessageFormat pattern per locale is translated as one coherent unit.

• ICU plural categories (zero/one/two/few/many/other) vary per locale -- 'one' does not always mean literally 1. • {count, plural, one {# item} other {# items}} is a self-contained, locale-driven pluralization pattern. • select handles non-numeric branching (e.g., gendered or categorical phrasing). • One MessageFormat pattern per locale keeps translated sentences grammatically coherent, unlike concatenated fragments.

Collator, Normalization, and Transliteration

Sorting strings with PHP's default sort() compares raw byte values, which is wrong for human text: in byte order, 'Zebra' sorts before 'apple' (uppercase bytes are lower than lowercase), and accented letters like 'é' sort in essentially arbitrary positions relative to unaccented letters, which is not how a French or Spanish reader expects a word list ordered. Collator implements locale-aware string comparison: Collator::create('fr_FR')->sort($words) orders 'é' immediately after 'e' the way French dictionaries do, and can be configured to be case-insensitive or accent-insensitive via setStrength().

Unicode normalization matters because the same visible character can be encoded multiple ways -- 'é' can be one precomposed codepoint (U+00E9) or an 'e' followed by a combining accent (U+0065 U+0301). These look identical on screen but compare as different byte strings, silently breaking equality checks, deduplication, and search. Normalizer::normalize($str, Normalizer::FORM_C) converts to the canonical composed form (NFC), which is the recommended default for storage and comparison; always normalizing user input to the same form before storing or comparing it prevents this whole class of invisible-duplicate bug.

Transliteration converts text between scripts or strips diacritics -- for example turning 'Zürich' into 'Zurich', or Cyrillic text into a Latin approximation -- using Transliterator::create() with an ICU transform identifier like 'Any-Latin; Latin-ASCII'. This is commonly used to generate URL slugs or ASCII-safe filenames from arbitrary-language input without maintaining a manual character-substitution table.

• Collator sorts strings using real locale-specific ordering rules, not raw byte comparison. • setStrength() controls whether case/accent differences count as equal or different during comparison. • The same character can have multiple Unicode encodings (precomposed vs combining) -- normalize before comparing/storing. • Normalizer::FORM_C (NFC) is the standard canonical form for stored text. • Transliterator converts scripts or strips diacritics -- useful for slugs, filenames, and search fallbacks.

Core Intl Classes at a Glance

NumberFormatter, IntlDateFormatter, and Collator all take a locale string as their first argument. IntlDateFormatter separates date style and time style so you can mix, e.g., LONG date with SHORT time. Collator::sort() mutates the array in place using locale collation rules, not byte comparison.

$nf = new NumberFormatter('de_DE', NumberFormatter::CURRENCY);
echo $nf->formatCurrency(1234.5, 'EUR'); // "1.234,50 €"

$fmt = new IntlDateFormatter(
    'fr_FR',
    IntlDateFormatter::LONG,
    IntlDateFormatter::SHORT,
    'Europe/Paris'
);
echo $fmt->format(new DateTimeImmutable('2026-01-05 14:30:00'));

$collator = Collator::create('fr_FR');
$words = ['pêche', 'pec he', 'abricot'];
$collator->sort($words);

Code Examples

The same numeric amount is rendered three different, correct ways depending purely on the locale argument -- decimal separator, thousands separator, currency symbol placement, and even the number of decimal places (JPY has no minor currency unit) are all handled by ICU's locale data instead of manual logic. Expected result: $1,234.50 1.234,50 € ¥1,235

$formatter->formatCurrency($amount, $currency);

All three lines describe the exact same instant in time -- only the wording (weekday/month names, word order, hour format) changes with the locale, while the timezone argument stays fixed. Mixing up locale and timezone is a common bug: locale never shifts the clock, only the language of the text. If you instead passed a different timezone per locale by mistake, you'd silently show the wrong instant to some users while their formatted string still looked plausible.

new IntlDateFormatter($locale, IntlDateFormatter::FULL, IntlDateFormatter::SHORT, 'America/New_York')

A single ICU pattern per locale encodes the entire plural rule for that language. Swapping the locale swaps which plural category ICU selects for a given count -- English only ever picks 'one' or 'other', while Polish picks between four categories depending on the exact count, all handled by ICU rather than a hand-written switch statement.

$formatter->format(['count' => $count]);

Common mistake: Writing $count === 1 ? "item" : "items" directly in application code -- this is correct only for English and silently produces grammatically wrong text the moment the app supports any other language.

Collator produces the ordering a native reader actually expects, unlike byte-order sort(). The normalization example shows why comparing or deduplicating user-submitted Unicode text without normalizing first can silently treat identical-looking strings as different; Transliterator then strips accents/scripts down to a plain-ASCII slug. Expected result: false true Cafe Muller - Zurich

Normalizer::normalize($precomposed, Normalizer::FORM_C)

<?php
declare(strict_types=1);

function formatPrice(float $amount, string $currency, string $locale): string
{
    $formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
    $result = $formatter->formatCurrency($amount, $currency);

    if ($result === false) {
        throw new RuntimeException('Failed to format currency: ' . $formatter->getErrorMessage());
    }

    return $result;
}

$price = 1234.5;
echo formatPrice($price, 'USD', 'en_US') . "\n"; // $1,234.50
echo formatPrice($price, 'EUR', 'de_DE') . "\n"; // 1.234,50 €
echo formatPrice($price, 'JPY', 'ja_JP') . "\n"; // ¥1,235 (JPY has no minor unit)

// Parsing locale-formatted user input back into a number.
$parser = new NumberFormatter('de_DE', NumberFormatter::DECIMAL);
$value = $parser->parse('1.234,56'); // float(1234.56), correctly reading '.' as a thousands separator
<?php
declare(strict_types=1);

$meeting = new DateTimeImmutable('2026-03-10 09:00:00', new DateTimeZone('America/New_York'));

$locales = ['en_US', 'fr_FR', 'ja_JP'];
foreach ($locales as $locale) {
    $formatter = new IntlDateFormatter(
        $locale,
        IntlDateFormatter::FULL,
        IntlDateFormatter::SHORT,
        'America/New_York', // same instant, viewer's requested locale for wording only
    );
    echo $formatter->format($meeting) . "\n";
}
// en_US: Tuesday, March 10, 2026 at 9:00 AM
// fr_FR: mardi 10 mars 2026 à 09:00
// ja_JP: 2026年3月10日火曜日 9:00
<?php
declare(strict_types=1);

function cartSummary(int $count, string $locale): string
{
    $pattern = '{count, plural, one {You have # item in your cart} other {You have # items in your cart}}';
    $formatter = new MessageFormatter($locale, $pattern);

    $result = $formatter->format(['count' => $count]);
    if ($result === false) {
        throw new RuntimeException('MessageFormatter failed: ' . $formatter->getErrorMessage());
    }
    return $result;
}

foreach ([0, 1, 2, 5] as $n) {
    echo cartSummary($n, 'en_US') . "\n";
}
// You have 0 items in your cart
// You have 1 item in your cart
// You have 2 items in your cart
// You have 5 items in your cart

// Polish has three relevant plural categories (one/few/many) -- same pattern shape, different rule engine.
$plPattern = '{count, plural, one {Masz # przedmiot} few {Masz # przedmioty} many {Masz # przedmiotów} other {Masz # przedmiotu}}';
echo (new MessageFormatter('pl_PL', $plPattern))->format(['count' => 3]) . "\n"; // 'few' category
<?php
declare(strict_types=1);

$names = ['Étienne', 'etienne', 'Zoe', 'abel'];

// Byte-order sort: wrong for human reading (uppercase < lowercase, accents unordered).
$byteSorted = $names;
sort($byteSorted);
print_r($byteSorted); // ['Zoe', 'Étienne', 'abel', 'etienne'] -- not alphabetical to a reader

// Locale-aware collation: orders the way a French reader expects.
$collator = Collator::create('fr_FR');
$collator->setStrength(Collator::SECONDARY); // ignore case differences during comparison
$localeSorted = $names;
$collator->sort($localeSorted);
print_r($localeSorted); // ['abel', 'Étienne', 'etienne', 'Zoe']

// Normalization: two different byte encodings of the same visible character.
$precomposed = "caf\u{00E9}";        // 'é' as one codepoint (NFC)
$decomposed  = "cafe\u{0301}";       // 'e' + combining acute accent (NFD)
var_dump($precomposed === $decomposed); // false -- different bytes, same rendering

$normalized1 = Normalizer::normalize($precomposed, Normalizer::FORM_C);
$normalized2 = Normalizer::normalize($decomposed, Normalizer::FORM_C);
var_dump($normalized1 === $normalized2); // true -- both normalize to the same NFC bytes

// Transliteration for a URL-safe slug
…

Intl Formatting Pipeline

Every Intl class takes the same locale string and defers all the actual language/region rules to ICU's CLDR data, not to application code.

PHP value (number / DateTime / count)
        |
        v
  Locale ('de_DE', 'fr_FR', 'ja_JP', ...)
        |
        +--> NumberFormatter -----> "1.234,50 €"
        +--> IntlDateFormatter ---> "mardi 10 mars 2026"
        +--> MessageFormatter ----> "Masz 3 przedmioty"
        +--> Collator ------------> locale-ordered array
                |
                v
          ICU locale database (CLDR rules)

Important Rules

Never hand-roll thousands/decimal separators, plural forms, or date word order -- use NumberFormatter, MessageFormatter, and IntlDateFormatter.

Keep locale (wording) and timezone (the actual instant) as separate parameters; never let one silently drive the other.

Normalize Unicode input to NFC (Normalizer::FORM_C) before storing or comparing user-submitted text.

Use Collator, not sort()/strcmp(), whenever strings will be shown to or ordered for a human reader.

Always check formatter methods for a false return and read getErrorMessage() -- Intl calls can fail on malformed input or bad locale data.

Why This Matters in Production

Any product that ships to more than one country hits Intl-shaped bugs fast: a price displayed with the wrong decimal separator looks like a 1000x pricing error to a confused user, a pluralization bug reads as broken or unprofessional translation, and a directory search that can't find 'Müller' because it was typed as 'Muller' costs real support tickets. Because ICU's locale data is maintained and updated independently of your application (new currencies, calendar reforms, updated plural rules), leaning on the Intl extension means those updates arrive for free with a PHP/ICU version bump instead of requiring you to track locale trivia by hand.

echo $count === 1 ? "$count item": "$count items"; // hardcoded English plural logic echo (new MessageFormatter($locale, '{count, plural, one {# item} other {# items}}'))->format(['count' => $count]);

English happens to have only two plural forms, so the ternary looks fine until the app supports a language with three, four, or six plural categories -- MessageFormatter encodes the correct rule per locale instead of assuming English's rule everywhere.

sort($names); // byte-order sort on user-facing names containing accented characters

Collator::create($locale)->sort($names); // locale-aware collation sort() compares raw bytes, so accented letters and case don't order the way a human reader of that language expects; Collator applies the actual collation rules ICU maintains per locale.

[multiple-choice] Why does formatCurrency() take both a locale and a separate ISO currency code? A. Because the locale alone cannot determine the currency symbol B. Because the displayed formatting conventions (locale) and the actual currency being expressed are independent -- you can show a EUR amount using German, French, or Irish-English formatting conventions C. Because ICU requires two arguments for all methods D. Because currency codes replaced locale codes in modern ICU versions

B. Because the displayed formatting conventions (locale) and the actual currency being expressed are independent -- you can show a EUR amount using German, French, or Irish-English formatting conventions — A price denominated in EUR can legitimately be displayed to users in different locales, each with its own separator, symbol placement, and wording conventions -- the two concerns are deliberately decoupled.

[multiple-choice] In IntlDateFormatter, what does the locale argument control that the timezone argument does not? A. The locale shifts which instant in time is displayed B. The locale controls the wording (weekday/month names, word order, calendar conventions) while the timezone controls which actual instant is shown C. The locale and timezone control the exact same thing and can be used interchangeably D. The locale only affects currency, not dates

B. The locale controls the wording (weekday/month names, word order, calendar conventions) while the timezone controls which actual instant is shown — Locale never changes the underlying instant being formatted -- only how that instant's text is worded. The timezone is what determines which wall-clock moment is displayed.

[multiple-choice] Why can two visually identical strings like 'é' (precomposed) and 'e'+combining-accent (decomposed) compare as unequal in PHP? A. PHP string comparison ignores accented characters B. They are different byte sequences encoding the same visible character, and Unicode normalization has not been applied to bring them to the same form C. This can never happen -- Unicode guarantees one encoding per visible character D. It only happens with multi-byte encodings other than UTF-8

B. They are different byte sequences encoding the same visible character, and Unicode normalization has not been applied to bring them to the same form — Unicode allows multiple valid encodings for visually identical text; without normalizing both strings to the same form (typically NFC via Normalizer::FORM_C), byte-for-byte comparison sees them as different.

Write a function formatNotification(int $unreadCount, string $locale): string that returns a locale-correct sentence like 'You have 3 unread messages' using MessageFormatter, correctly pluralized for at least English and one language with a different plural rule (e.g. Polish or Arabic).

function formatNotification(int $unreadCount, string $locale): string { $pattern = '{count, plural, one {You have # unread message} other {You have # unread messages}}'; $mf = new MessageFormatter($locale, $pattern); $result = $mf->format(['count' => $unreadCount]); if ($result === false) { throw new RuntimeException($mf->getErrorMessage()); } return $result; } -- for pl_PL, swap in a pattern with one/few/many/other branches; ICU picks the right branch per locale automatically.

Build the ICU pattern string with a {count, plural, ...} clause and pass it to a per-locale MessageFormatter instance; don't branch on $unreadCount yourself.