Internationalization (i18n) is a critical aspect of modern web applications. As PHP continues to power a large portion of the web, PHP 8.5 introduces meaningful internationalization enhancements aimed at improving correctness, developer experience, and global readiness.
This article provides a complete, in-depth guide to PHP 8.5 internationalization improvements, with clear explanations, practical examples, and real-world use cases.
What Is Internationalization in PHP?
Internationalization (often written as i18n) is the process of designing software so it can be easily adapted to different languages, regions, and cultures without code changes.
In PHP, i18n typically involves:
- Locale-aware formatting (dates, numbers, currencies)
- Character encoding (UTF-8)
- Translations and message catalogs
- ICU and intl extension usage
PHP 8.5 builds on this foundation with enhanced consistency, safer APIs, and better Unicode handling.
Problems in Previous PHP Versions (≤ PHP 8.4)
Before PHP 8.5, developers commonly faced:
- Inconsistent locale behavior across environments
- Silent failures when locales were missing
- Limited introspection of locale state
- Unicode edge cases in string comparison and normalization
- Verbose or error-prone intl usage
These issues often surfaced only in production — especially for multilingual or multi-region applications.
What’s New in PHP 8.5 Internationalization
PHP 8.5 introduces incremental but powerful i18n improvements, focused on correctness and observability rather than breaking changes.
Key Highlights
- Improved locale awareness and validation
- \Better Unicode normalization support
- Enhanced intl error reporting
- Safer string comparison for multilingual data
- More predictable formatting for dates, numbers, and currencies
1. Improved Locale Handling & Validation
Before PHP 8.5
Setting an invalid or unavailable locale would often fail silently:
setlocale(LC_ALL, 'fr_FR');
If the locale was not installed, PHP would continue using the default locale — without warning.
PHP 8.5 Improvement
PHP 8.5 improves locale validation and feedback, making it easier to detect configuration issues.
$locale = setlocale(LC_ALL, 'fr_FR.UTF-8');
if ($locale === false) {
throw new RuntimeException('Locale not available on this system');
}
More predictable behavior across environments
2. Enhanced Unicode String Handling
Unicode correctness is essential for multilingual applications.
Problem Before PHP 8.5
String comparisons could behave unexpectedly with accented or composed characters:
var_dump('café' === 'café'); // visually same, binary different
PHP 8.5 Improvement: Unicode Normalization Awareness
PHP 8.5 improves integration with ICU normalization tools:
$normalizedA = Normalizer::normalize('café', Normalizer::FORM_C);
$normalizedB = Normalizer::normalize('café', Normalizer::FORM_C);
var_dump($normalizedA === $normalizedB); // true
Reliable string comparison across languages.
3. Better Error Reporting in the intl Extension
Before PHP 8.5
Errors in intl functions were often vague or difficult to debug:
$formatter = new NumberFormatter('invalid_LOCALE', NumberFormatter::CURRENCY);
PHP 8.5 Improvement
PHP 8.5 improves error propagation and diagnostics:
$formatter = new NumberFormatter('invalid_LOCALE', NumberFormatter::CURRENCY);
if (!$formatter) {
throw new IntlException(intl_get_error_message());
}
Clear feedback for misconfigured locales
4. More Predictable Date & Time Localization
Before PHP 8.5
Date formatting inconsistencies could occur when locale data differed between servers.
echo strftime('%A %d %B %Y');
PHP 8.5 Improvement (Using IntlDateFormatter)
$formatter = new IntlDateFormatter( 'de_DE', IntlDateFormatter::FULL, IntlDateFormatter::NONE ); echo $formatter->format(new DateTime());
Consistent, locale-correct output
5. Currency & Number Formatting Improvements
Formatting numbers correctly across regions is essential for global applications.
Example: Currency Formatting
$formatter = new NumberFormatter('en_IN', NumberFormatter::CURRENCY);
echo $formatter->formatCurrency(123456.78, 'INR');
Output:
₹1,23,456.78
Accurate regional formatting
Real-World Use Cases
1. Multilingual SaaS Applications
- Correct user-facing formatting
- Reduced production locale issues
2. E-commerce Platforms
- Accurate currency display
- Region-specific number formats
3. Enterprise & Government Systems
- Unicode-safe text processing
- Compliance with regional standards
4. CMS & Content Platforms
- Reliable translations
- Language-aware sorting and comparison
Best Practices for PHP 8.5 Internationalization
- Always use UTF-8 locales
- Prefer intl extension over legacy functions
- Normalize user input before comparison
- Validate locale availability at startup
- Log and monitor i18n-related errors
PHP 8.5 vs PHP 8.4 (i18n Comparison)
| Feature | PHP 8.4 | PHP 8.5 |
|---|---|---|
| Locale validation | ⚠️ Limited | ✅ Improved |
| Unicode normalization | ⚠️ Manual | ✅ Safer |
| intl error reporting | ⚠️ Weak | ✅ Better |
| Date localization | ⚠️ Inconsistent | ✅ Predictable |
| Global readiness | ⚠️ Moderate | 🚀 Enhanced |
PHP 8.5’s internationalization enhancements may appear incremental, but they significantly improve correctness, reliability, and global scalability.
If you’re building applications for a worldwide audience, these improvements help ensure your PHP applications behave consistently across languages, regions, and cultures.
PHP 8.5 continues to evolve into a truly global-first programming language.
setlocale() — exists since PHP 4. Normalizer::normalize() — added in PHP 5.3 with the intl extension. NumberFormatter — added in PHP 5.3. IntlDateFormatter — added in PHP 5.3. None of these received meaningful API changes in PHP 8.5. Presenting them as “PHP 8.5 enhancements” misleads developers into thinking they need 8.5 to use them (they don’t) or that their behavior changed (it mostly hasn’t).
PHP 8.5 actually shipped four genuine i18n additions: a new IntlListFormatter class, locale_is_right_to_left() and Locale::isRightToLeft(), Locale::addLikelySubtags() and Locale::minimizeSubtags(), and new Intl formatting constants including compact-decimal support. This guide covers all four in the depth they deserve, plus the full pre-existing intl toolkit that every PHP developer building multilingual applications should actually know.
The Difference Between “New in PHP 8.5” and “Available in PHP”
This distinction matters because it changes what you actually need to do. If IntlDateFormatter has existed since PHP 5.3, you don’t need to wait for a PHP upgrade to use it — you can use it today on any supported PHP version. But IntlListFormatter is genuinely new, and you do need PHP 8.5 (or a polyfill) to use it without writing the logic yourself.
| Feature | Available Since | New in PHP 8.5? |
|---|---|---|
| setlocale() | PHP 4 | ❌ |
| Normalizer::normalize() | PHP 5.3 | ❌ |
| NumberFormatter | PHP 5.3 | ❌ |
| IntlDateFormatter | PHP 5.3 | ❌ |
| Locale class | PHP 5.3 | ❌ (some methods added) |
| IntlListFormatter | PHP 8.5 | ✅ Genuinely new |
| locale_is_right_to_left() | PHP 8.5 | ✅ Genuinely new |
| Locale::isRightToLeft() | PHP 8.5 | ✅ Genuinely new |
| Locale::addLikelySubtags() | PHP 8.5 | ✅ Genuinely new |
| Locale::minimizeSubtags() | PHP 8.5 | ✅ Genuinely new |
| Compact-decimal constants | PHP 8.5 | ✅ Genuinely new |
The ICU/CLDR Foundation — Why This Matters
Before diving into the new features, understanding what powers them explains why they work correctly when hand-rolled PHP implementations don’t.
The intl extension wraps ICU (International Components for Unicode), a mature C/C++ library maintained by Unicode, Inc. and used by virtually every major software platform — Android, iOS, Chrome, Firefox, Java’s JDK, Go’s standard library. When PHP’s IntlListFormatter formats a list for Japanese, it’s using the same underlying rules as every other ICU consumer.
ICU draws its locale data from CLDR (Common Locale Data Repository), a Unicode Consortium project with data for over 900 locales covering list formatting, date/time patterns, number symbols, currency display names, and much more. This is why IntlListFormatter(‘de-DE’) correctly produces “A, B und C” (no Oxford comma, conjunction is “und”) without you having to know the German grammar rules yourself.
The practical implication: any time you write manual i18n logic — if ($lang === ‘de’) { $conjunction = ‘ und ‘; } — you’re duplicating a fraction of CLDR with higher maintenance burden and lower coverage. Using ICU-backed functions gets you correct behavior for hundreds of locales you haven’t specifically tested.
New Feature 1: IntlListFormatter — The Headline Addition
The Intl extension in PHP 8.5 adds a new class named IntlListFormatter. It provides locale-aware formatting for lists into human-readable “and”-lists, “or”-lists, or units.
The Problem It Solves
Formatting a PHP array as a human-readable list for display sounds trivial until you actually try to do it correctly across languages:
// The naive approach — works only for English
function format_list(array $items): string {
if (count($items) === 0) return '';
if (count($items) === 1) return $items[0];
if (count($items) === 2) return $items[0] . ' and ' . $items[1];
$last = array_pop($items);
return implode(', ', $items) . ', and ' . $last; // Oxford comma
}
// Now try for German: "A, B und C" (no Oxford comma, different conjunction)
// Now try for Japanese: completely different structure, no comma at all
// Now try for Arabic: RTL, different conjunction, different punctuation
// Now try for Chinese: no spaces, no commas, different connectors
Each language has different rules for conjunctions, comma usage, Oxford comma presence, and in some cases entirely different syntactic structures for list enumeration. The naive approach grows into an unmaintainable language-by-language conditional chain.
The Full API
The IntlListFormatter is instantiated with a valid locale string and uses ICU data for the actual formatting.
IntlListFormatter::__construct(
string $locale,
int $type = IntlListFormatter::TYPE_AND,
int $width = IntlListFormatter::WIDTH_WIDE
)
Type constants — what kind of list:
IntlListFormatter::TYPE_AND // "A, B, and C" — conjunction list (default) IntlListFormatter::TYPE_OR // "A, B, or C" — disjunction list IntlListFormatter::TYPE_UNITS // "4 hours, 30 minutes" — compound unit list
Width constants — how compact the output should be:
IntlListFormatter::WIDTH_WIDE // Standard: "A, B, and C" (default) IntlListFormatter::WIDTH_SHORT // Shorter form: "A, B & C" (if locale supports it) IntlListFormatter::WIDTH_NARROW // Narrowest possible: may omit separators entirely
Language Comparison — The Core Value Proposition
$cities = ['Zurich', 'Berlin', 'Amsterdam'];
// English — Oxford comma, "and"
$en = new IntlListFormatter('en-US');
echo $en->format($cities);
// Zurich, Berlin, and Amsterdam
// British English — Oxford comma less common but still supported
$enGB = new IntlListFormatter('en-GB');
echo $enGB->format($cities);
// Zurich, Berlin and Amsterdam
// German — no Oxford comma, "und"
$de = new IntlListFormatter('de-DE');
echo $de->format($cities);
// Zürich, Berlin und Amsterdam
// French — "et", no comma before conjunction
$fr = new IntlListFormatter('fr-FR');
echo $fr->format(['Paris', 'Lyon', 'Marseille']);
// Paris, Lyon et Marseille
// Indonesian — "dan"
$id = new IntlListFormatter('id-ID');
echo $id->format($cities);
// Zurich, Berlin, dan Amsterdam
// Arabic — RTL, Arabic conjunction
$ar = new IntlListFormatter('ar-SA');
echo $ar->format(['الرياض', 'جدة', 'الدمام']);
// الرياض وجدة والدمام
// Japanese — no commas, Japanese connector
$ja = new IntlListFormatter('ja-JP');
echo $ja->format(['東京', '大阪', '京都']);
// 東京、大阪、京都
Formatting lists correctly for different languages is surprisingly complex. In English, you often use the Oxford comma for “A, B, and C.” In German, the conjunction for “and” is “und”, resulting in “A, B und C” with no comma before “und”. In Japanese, the structure is completely different. This class uses the comprehensive CLDR rules to produce grammatically correct, localized lists for human consumption.
The Three Types in Action
$payment_methods = ['Credit card', 'PayPal', 'Bank transfer'];
// TYPE_AND — for listing items that all apply
$and = new IntlListFormatter('en-US', IntlListFormatter::TYPE_AND);
echo $and->format($payment_methods);
// "Credit card, PayPal, and Bank transfer"
// Use for: "Required: item1, item2, and item3"
// TYPE_OR — for listing alternatives
$or = new IntlListFormatter('en-US', IntlListFormatter::TYPE_OR);
echo $or->format($payment_methods);
// "Credit card, PayPal, or Bank transfer"
// Use for: "Pay with: credit card, PayPal, or bank transfer"
// TYPE_UNITS — for compound measurements
$dur = new IntlListFormatter('en-US', IntlListFormatter::TYPE_UNITS);
echo $dur->format(['4 hours', '30 minutes', '15 seconds']);
// "4 hours, 30 minutes, 15 seconds"
// Use for: duration displays, dimension lists
Width Variants
$tags = ['PHP', 'Laravel', 'Vue'];
$wide = new IntlListFormatter('en-US', IntlListFormatter::TYPE_AND, IntlListFormatter::WIDTH_WIDE);
$short = new IntlListFormatter('en-US', IntlListFormatter::TYPE_AND, IntlListFormatter::WIDTH_SHORT);
$narrow = new IntlListFormatter('en-US', IntlListFormatter::TYPE_AND, IntlListFormatter::WIDTH_NARROW);
echo $wide->format($tags); // "PHP, Laravel, and Vue"
echo $short->format($tags); // "PHP, Laravel & Vue" (ampersand instead of "and")
echo $narrow->format($tags); // "PHP, Laravel, Vue" (minimal separators)
Width SHORT and NARROW are particularly useful in compact UI components like tag lists, badges, and mobile summaries where every character counts.
Edge Cases the Class Handles Correctly
$fmt = new IntlListFormatter('en-US');
// Empty array — returns empty string, no error
echo $fmt->format([]); // ""
// Single item — returns item unchanged, no conjunction
echo $fmt->format(['PHP']); // "PHP"
// Two items — no Oxford comma for two items
echo $fmt->format(['PHP', 'Laravel']); // "PHP and Laravel"
// Numbers — accepts any stringable items
echo $fmt->format([1, 2, 3]); // "1, 2, and 3"
// Mixed — works with string representations
echo $fmt->format(['100%', '₹1,23,456', 'USD']); // "100%, ₹1,23,456, and USD"
The Polyfill for PHP 7.2–8.4
The Symfony polyfill 1.34.0 provides IntlListFormatter back to PHP 7.2 using the CLDR list patterns.
composer require symfony/polyfill-intl-icu
Or a minimal standalone polyfill for projects without Symfony:
if (!class_exists('IntlListFormatter')) {
class IntlListFormatter {
const TYPE_AND = 0;
const TYPE_OR = 1;
const TYPE_UNITS = 2;
const WIDTH_WIDE = 0;
const WIDTH_SHORT = 1;
const WIDTH_NARROW = 2;
// Basic English-only fallback — use Symfony polyfill for real locale support
public function __construct(
private string $locale,
private int $type = self::TYPE_AND,
private int $width = self::WIDTH_WIDE
) {}
public function format(array $items): string {
$items = array_values(array_map('strval', $items));
$count = count($items);
if ($count === 0) return '';
if ($count === 1) return $items[0];
if ($count === 2) {
$conj = $this->type === self::TYPE_OR ? ' or ' : ' and ';
return $items[0] . $conj . $items[1];
}
$last = array_pop($items);
$conj = $this->type === self::TYPE_OR ? ', or ' : ', and ';
return implode(', ', $items) . $conj . $last;
}
}
}
New Feature 2: locale_is_right_to_left() and Locale::isRightToLeft()
locale_is_right_to_left() and Locale::isRightToLeft() help detect right-to-left scripts.These new functions allow you to determine, based on a given locale, whether its primary script is read right-to-left. Languages like Arabic, Hebrew, Persian/Farsi, and Urdu use RTL scripts.
Why This Mattered Before PHP 8.5
Before this addition, every PHP application needing RTL detection maintained a hardcoded list:
// The pre-8.5 approach — a maintenance burden
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur', 'yi', 'dv', 'ku', 'ps'];
function is_rtl(string $locale): bool {
$lang = strtolower(substr($locale, 0, 2));
return in_array($lang, RTL_LOCALES, true);
}
Problems with this: it misses regional variants (Kurdish ku-Arab is RTL but ku-Latn is LTR — the same language uses both scripts), it has no update path as new RTL scripts gain wider use, and it requires every developer to know the full list of RTL BCP-47 language tags.
The PHP 8.5 API
// Procedural form locale_is_right_to_left(string $locale): bool // OOP form (method on the existing Locale class) Locale::isRightToLeft(string $locale): bool
Both are equivalent. The functions use ICU data to stay up-to-date with global standards. When ICU adds a new locale or script, PHP applications using these functions gain correct RTL detection without code changes.
// RTL languages
var_dump(locale_is_right_to_left('ar')); // bool(true) — Arabic
var_dump(locale_is_right_to_left('ar-SA')); // bool(true) — Arabic (Saudi Arabia)
var_dump(locale_is_right_to_left('he')); // bool(true) — Hebrew
var_dump(locale_is_right_to_left('fa')); // bool(true) — Persian
var_dump(locale_is_right_to_left('ur')); // bool(true) — Urdu
var_dump(locale_is_right_to_left('yi')); // bool(true) — Yiddish
// LTR languages
var_dump(locale_is_right_to_left('en')); // bool(false) — English
var_dump(locale_is_right_to_left('hi')); // bool(false) — Hindi (Devanagari is LTR)
var_dump(locale_is_right_to_left('zh-CN')); // bool(false) — Chinese Simplified
var_dump(locale_is_right_to_left('ja')); // bool(false) — Japanese
// Script-sensitive: same language, different scripts
var_dump(locale_is_right_to_left('ku-Arab')); // bool(true) — Kurdish Arabic script
var_dump(locale_is_right_to_left('ku-Latn')); // bool(false) — Kurdish Latin script
var_dump(locale_is_right_to_left('sr-Cyrl')); // bool(false) — Serbian Cyrillic (LTR)
var_dump(locale_is_right_to_left('sr-Latn')); // bool(false) — Serbian Latin (LTR)
The script-sensitivity is the key advantage over any hardcoded list. There is no simple mapping from language code to directionality — the same language can use multiple scripts with different directions.
Real-World Integration
class LocaleAwareResponse {
private string $locale;
private bool $isRtl;
public function __construct(string $locale) {
$this->locale = $locale;
$this->isRtl = locale_is_right_to_left($locale);
}
public function htmlDir(): string {
return $this->isRtl ? 'rtl' : 'ltr';
}
public function cssTextAlign(): string {
return $this->isRtl ? 'right' : 'left';
}
public function renderLayout(string $content): string {
return sprintf(
'<html dir="%s" lang="%s"><body style="text-align:%s">%s</body></html>',
$this->htmlDir(),
htmlspecialchars($this->locale),
$this->cssTextAlign(),
$content
);
}
}
// Arabic layout
$ar = new LocaleAwareResponse('ar-SA');
echo $ar->htmlDir(); // rtl
echo $ar->renderLayout('مرحباً'); // <html dir="rtl" lang="ar-SA" ...>
// English layout
$en = new LocaleAwareResponse('en-US');
echo $en->htmlDir(); // ltr
In a Framework Middleware Context
// Laravel/Symfony middleware — set document direction from Accept-Language header
class DirectionMiddleware {
public function handle(Request $request, Closure $next): Response {
$locale = $request->getPreferredLanguage(['en', 'ar', 'he', 'fr', 'de', 'hi']);
// PHP 8.5: one line replaces a hardcoded lookup table
$direction = locale_is_right_to_left($locale) ? 'rtl' : 'ltr';
app()->setLocale($locale);
view()->share('direction', $direction);
view()->share('locale', $locale);
return $next($request);
}
}
New Feature 3: Locale::addLikelySubtags() and Locale::minimizeSubtags()
Locale::addLikelySubtags() and Locale::minimizeSubtags() help normalize locale tags. These implement the Unicode likely subtags algorithm — a specification for expanding and minimizing BCP-47 locale identifiers.
The Problem: Incomplete Locale Tags
BCP-47 locale tags can be written at varying levels of specificity:
- en — English (language only)
- en-Latn — English in Latin script (language + script)
- en-US — English in the United States (language + region)
- en-Latn-US — English in Latin script in the United States (fully specified)
All four refer to the same thing. But when you receive en from an Accept-Language header and need to know whether it uses an LTR or RTL script, you need the script subtag — and locale_is_right_to_left(‘en’) needs to infer it.
Locale::addLikelySubtags() expands a minimal tag to its most-likely fully-specified form based on Unicode CLDR data:
// Expand minimal locale tags to fully-specified form
echo Locale::addLikelySubtags('en'); // en-Latn-US
echo Locale::addLikelySubtags('ar'); // ar-Arab-EG (Arabic, Arabic script, Egypt)
echo Locale::addLikelySubtags('zh'); // zh-Hans-CN (Chinese, Simplified, China)
echo Locale::addLikelySubtags('zh-TW'); // zh-Hant-TW (Chinese, Traditional, Taiwan)
echo Locale::addLikelySubtags('sr'); // sr-Cyrl-RS (Serbian, Cyrillic, Serbia)
echo Locale::addLikelySubtags('ku'); // ku-Latn-TR (Kurdish, Latin, Turkey)
echo Locale::addLikelySubtags('hi'); // hi-Deva-IN (Hindi, Devanagari, India)
Locale::minimizeSubtags() goes the other direction — strips redundant subtags that are implied by the primary language tag:
// Minimize — remove subtags that are implied by the language
echo Locale::minimizeSubtags('en-Latn-US'); // en (Latin + US are implied for English)
echo Locale::minimizeSubtags('zh-Hans-CN'); // zh (Simplified + CN implied for base Chinese)
echo Locale::minimizeSubtags('zh-Hant-TW'); // zh-TW (Traditional is needed to distinguish from zh)
echo Locale::minimizeSubtags('sr-Cyrl-RS'); // sr (Cyrillic + RS implied for Serbian)
echo Locale::minimizeSubtags('sr-Latn-RS'); // sr-Latn (Latin needs to be explicit)
Where This Is Used
The primary use case is locale canonicalization — ensuring that locale tags are stored and compared consistently:
function canonicalize_locale(string $userInput): string {
// Step 1: Parse to BCP-47
$parsed = Locale::parseLocale($userInput);
if (!$parsed) {
return 'en'; // fallback
}
$tag = Locale::composeLocale($parsed);
// Step 2: Expand to likely subtags for completeness
$full = Locale::addLikelySubtags($tag);
// Step 3: Minimize to canonical form for storage
return Locale::minimizeSubtags($full);
}
echo canonicalize_locale('zh-TW'); // zh-TW (Traditional Chinese needs region)
echo canonicalize_locale('en_US'); // en (implied)
echo canonicalize_locale('sr-Cyrl'); // sr (Cyrillic is implied for base Serbian)
echo canonicalize_locale('sr-Latn'); // sr-Latn (Latin must be explicit)
A real-world scenario: your database stores user locale preferences. Two users both select “Chinese Simplified” but one stores zh, another stores zh-Hans, another stores zh-Hans-CN. Canonicalization via addLikelySubtags() + minimizeSubtags() normalizes all three to zh — the same minimum canonical form — making equality comparisons and database queries accurate.
New Feature 4: New Intl Formatting Constants (Including Compact Decimal)
PHP 8.5 also adds new Intl formatting constants, including compact-decimal support.
Compact Decimal Formatting
Compact notation (1.2K, 4.5M, 2.3B) is a display convention for large numbers in space-constrained interfaces. Before PHP 8.5, implementing this in a locale-correct way required either a custom function or a JavaScript-side formatter:
// Pre-8.5 — manual, English-only
function compact_number(float $n): string {
if ($n >= 1_000_000_000) return round($n / 1_000_000_000, 1) . 'B';
if ($n >= 1_000_000) return round($n / 1_000_000, 1) . 'M';
if ($n >= 1_000) return round($n / 1_000, 1) . 'K';
return (string) $n;
}
echo compact_number(1234567); // "1.2M" — English only, no locale support
PHP 8.5 adds NumberFormatter::COMPACT_DECIMAL_LONG and NumberFormatter::COMPACT_DECIMAL_SHORT constants:
// PHP 8.5 — locale-aware compact formatting
// Short form — abbreviations
$shortEn = new NumberFormatter('en-US', NumberFormatter::COMPACT_DECIMAL_SHORT);
echo $shortEn->format(1_234); // 1.2K
echo $shortEn->format(1_234_567); // 1.2M
echo $shortEn->format(1_234_567_890); // 1.2B
// Long form — spelled out
$longEn = new NumberFormatter('en-US', NumberFormatter::COMPACT_DECIMAL_LONG);
echo $longEn->format(1_234); // 1.2 thousand
echo $longEn->format(1_234_567); // 1.2 million
echo $longEn->format(1_234_567_890); // 1.2 billion
// Different locales — completely different suffixes
$shortDe = new NumberFormatter('de-DE', NumberFormatter::COMPACT_DECIMAL_SHORT);
echo $shortDe->format(1_234_567); // 1,2 Mio. (German "Millionen")
$shortJa = new NumberFormatter('ja-JP', NumberFormatter::COMPACT_DECIMAL_SHORT);
echo $shortJa->format(10_000); // 1万 (Japanese uses 万 = 10,000 as the base unit)
echo $shortJa->format(100_000_000); // 1億 (億 = 100,000,000)
$shortIn = new NumberFormatter('en-IN', NumberFormatter::COMPACT_DECIMAL_SHORT);
echo $shortIn->format(1_000_000); // 10L (Indian system: L = Lakh = 100,000)
echo $shortIn->format(10_000_000); // 1Cr (Cr = Crore = 10,000,000)
The Indian and Japanese examples illustrate why a manual implementation fails for international use: the base grouping units are completely different. Japanese groups in 万 (10,000) rather than thousands. Indian English groups in Lakh (1,00,000) and Crore (1,00,00,000). No hardcoded English-centric function handles these correctly.
The Pre-existing intl Toolkit: What You Should Already Be Using
NumberFormatter — Locale-Aware Numbers and Currency
// Currency — correct symbol placement and grouping per locale
$usd = new NumberFormatter('en-US', NumberFormatter::CURRENCY);
echo $usd->formatCurrency(1234567.89, 'USD'); // $1,234,567.89
$eur = new NumberFormatter('de-DE', NumberFormatter::CURRENCY);
echo $eur->formatCurrency(1234567.89, 'EUR'); // 1.234.567,89 € (dot thousands, comma decimal)
$inr = new NumberFormatter('en-IN', NumberFormatter::CURRENCY);
echo $inr->formatCurrency(123456.78, 'INR'); // ₹1,23,456.78 (Indian grouping: 2-2-3)
// Percentages
$pct = new NumberFormatter('en-US', NumberFormatter::PERCENT);
echo $pct->format(0.1234); // 12%
// Ordinals (1st, 2nd, 3rd...)
$ord = new NumberFormatter('en-US', NumberFormatter::ORDINAL);
echo $ord->format(1); // 1st
echo $ord->format(2); // 2nd
echo $ord->format(11); // 11th
IntlDateFormatter — Correct Date and Time for Every Locale
strftime() was deprecated in PHP 8.1 and removed in PHP 9 (upcoming). IntlDateFormatter is the correct replacement — locale-aware, timezone-safe, and consistent across environments:
$date = new DateTime('2026-08-01 14:30:00', new DateTimeZone('Asia/Kolkata'));
// Full date — locale-specific format and calendar system
$en = new IntlDateFormatter('en-US', IntlDateFormatter::FULL, IntlDateFormatter::SHORT, 'America/New_York');
echo $en->format($date); // Friday, August 1, 2026 at 5:00 AM EDT (converted from IST)
$de = new IntlDateFormatter('de-DE', IntlDateFormatter::FULL, IntlDateFormatter::SHORT, 'Europe/Berlin');
echo $de->format($date); // Freitag, 1. August 2026 um 11:00 MESZ
$ar = new IntlDateFormatter('ar-SA', IntlDateFormatter::FULL, IntlDateFormatter::SHORT, 'Asia/Riyadh');
echo $ar->format($date); // الجمعة، ١ أغسطس ٢٠٢٦ في ١٢:٣٠ م (Arabic numerals, Arabic month names)
$ja = new IntlDateFormatter('ja-JP', IntlDateFormatter::FULL, IntlDateFormatter::SHORT, 'Asia/Tokyo');
echo $ja->format($date); // 2026年8月1日土曜日 22:30 (Japanese date structure)
Normalizer — Unicode Normalization for Safe Comparisons
This has existed since PHP 5.3 but is worth understanding correctly.
// Two visually identical strings that are binary different
// "café" composed: c-a-f-é (é is a single Unicode codepoint U+00E9)
// "café" decomposed: c-a-f-e + ◌́ (e is U+0065, combining accent is U+0301)
$a = "\u{0063}\u{0061}\u{0066}\u{00E9}"; // café (composed NFC)
$b = "\u{0063}\u{0061}\u{0066}\u{0065}\u{0301}"; // café (decomposed NFD)
var_dump($a === $b); // bool(false) — binary different
var_dump(mb_strlen($a)); // 4
var_dump(mb_strlen($b)); // 5 — the combining accent is a separate codepoint
// Normalize both to NFC before comparing
$normA = Normalizer::normalize($a, Normalizer::FORM_C);
$normB = Normalizer::normalize($b, Normalizer::FORM_C);
var_dump($normA === $normB); // bool(true)
When this matters in practice: user input, file names uploaded from different OS platforms (macOS uses NFD, Windows uses NFC), and database storage where you want “café” and “café” to be treated as the same string regardless of source encoding.
Building a Complete Multilingual PHP Application with PHP 8.5
Combining all the new and existing i18n tools into a realistic application context:
<?php
declare(strict_types=1);
class MultilingualFormatter {
private string $locale;
private bool $isRtl;
private string $canonicalLocale;
public function __construct(string $rawLocale) {
// Canonicalize the incoming locale tag (PHP 8.5)
$expanded = Locale::addLikelySubtags($rawLocale) ?: $rawLocale;
$this->canonicalLocale = Locale::minimizeSubtags($expanded) ?: $rawLocale;
$this->locale = $this->canonicalLocale;
// Detect directionality (PHP 8.5)
$this->isRtl = locale_is_right_to_left($this->locale);
}
public function isRtl(): bool {
return $this->isRtl;
}
public function htmlDirection(): string {
return $this->isRtl ? 'rtl' : 'ltr';
}
// PHP 8.5 — IntlListFormatter
public function formatList(array $items, string $type = 'and'): string {
$intlType = match($type) {
'or' => IntlListFormatter::TYPE_OR,
'units' => IntlListFormatter::TYPE_UNITS,
default => IntlListFormatter::TYPE_AND,
};
$fmt = new IntlListFormatter($this->locale, $intlType);
return $fmt->format($items);
}
// PHP 8.5 — compact decimal
public function formatCompact(float $number, bool $long = false): string {
$style = $long
? NumberFormatter::COMPACT_DECIMAL_LONG
: NumberFormatter::COMPACT_DECIMAL_SHORT;
$fmt = new NumberFormatter($this->locale, $style);
return $fmt->format($number);
}
// Pre-existing — currency
public function formatCurrency(float $amount, string $currency): string {
$fmt = new NumberFormatter($this->locale, NumberFormatter::CURRENCY);
return $fmt->formatCurrency($amount, $currency);
}
// Pre-existing — date
public function formatDate(DateTimeInterface $date, string $tz = 'UTC'): string {
$fmt = new IntlDateFormatter(
$this->locale,
IntlDateFormatter::LONG,
IntlDateFormatter::SHORT,
$tz
);
return $fmt->format($date);
}
// Pre-existing — Unicode-safe comparison
public function normalizeForComparison(string $text): string {
return Normalizer::normalize($text, Normalizer::FORM_C) ?: $text;
}
}
// Usage examples:
$en = new MultilingualFormatter('en-US');
echo $en->formatList(['Paris', 'London', 'Tokyo']); // Paris, London, and Tokyo
echo $en->formatCompact(1_234_567); // 1.2M
echo $en->formatCurrency(99.99, 'USD'); // $99.99
echo $en->htmlDirection(); // ltr
$ar = new MultilingualFormatter('ar');
echo $ar->formatList(['باريس', 'لندن', 'طوكيو']); // باريس ولندن وطوكيو
echo $ar->formatCompact(1_234_567); // ١٫٢ مليون
echo $ar->formatCurrency(99.99, 'SAR'); // ٩٩٫٩٩ ر.س.
echo $ar->htmlDirection(); // rtl
$ja = new MultilingualFormatter('ja-JP');
echo $ja->formatCompact(100_000_000); // 1億
echo $ja->formatDate(new DateTime('2026-08-01'), 'Asia/Tokyo'); // 2026年8月1日 9:00
Ensuring the intl Extension Is Available
All these features require the intl extension, which is not enabled by default in all PHP installations:
# Check if intl is loaded php -m | grep intl # Ubuntu/Debian sudo apt-get install php8.5-intl # CentOS/RHEL sudo yum install php85-php-intl # Enable in php.ini extension=intl # Verify ICU version (affects locale data currency) php -r "echo INTL_ICU_VERSION . PHP_EOL;"
For Docker-based deployments:
FROM php:8.5-fpm RUN docker-php-ext-install intl # Or use a pre-built image with intl FROM php:8.5-fpm-alpine RUN apk add --no-cache icu-dev && docker-php-ext-install intl
In application bootstrap, fail loudly if the extension is missing rather than silently falling back to incorrect output:
if (!extension_loaded('intl')) {
throw new \RuntimeException(
'The intl PHP extension is required for internationalization features. ' .
'Install it with: apt-get install php8.5-intl'
);
}
What PHP 8.5 i18n Summary Actually Looks Like
| What source said | What actually shipped | |
|---|---|---|
| setlocale() “improved” | Presented as PHP 8.5 new | Exists since PHP 4 — unchanged |
| Normalizer::normalize() “safer” | Presented as PHP 8.5 new | Exists since PHP 5.3 — unchanged |
| NumberFormatter “improved” | Presented as PHP 8.5 new | Exists since PHP 5.3 — new compact constants only |
| IntlDateFormatter “predictable” | Presented as PHP 8.5 new | Exists since PHP 5.3 — unchanged |
| IntlListFormatter | Barely mentioned | ✅ Brand new class, most significant addition |
| locale_is_right_to_left() | Not mentioned | ✅ New in PHP 8.5 |
| Locale::addLikelySubtags() | Not mentioned | ✅ New in PHP 8.5 |
| Locale::minimizeSubtags() | Not mentioned | ✅ New in PHP 8.5 |
| Compact decimal constants | Not mentioned | ✅ New in PHP 8.5 |
PHP 8.5’s genuine i18n additions are focused and practical: locale-aware list formatting that previously required framework-level helpers or custom code, RTL detection that previously required hardcoded lookup tables, locale canonicalization that previously required manual BCP-47 parsing, and compact number formatting that previously couldn’t be done correctly across non-English locales without JavaScript. They’re not sweeping API redesigns — they’re exactly the right tools for the jobs they solve.