PHP 8.5 Internationalization: The Complete, Accurate Guide to What Actually Changed

5483 views
PHP 8.5 in Development Internationalization Enhancements

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.

 

Frequently Asked Questions

+

What is new in PHP 8.5 for internationalization?

PHP 8.5 introduces several genuine internationalization improvements, including the new IntlListFormatter class, RTL locale detection functions, locale likely-subtag methods, and new formatting constants such as compact-decimal support.
+

Is IntlDateFormatter new in PHP 8.5?

No. IntlDateFormatter has been available since PHP 5.3. PHP 8.5 does not introduce it; it is part of the existing intl toolkit.
+

What is IntlListFormatter in PHP 8.5?

IntlListFormatter formats arrays into human-readable, locale-aware lists. For example: $formatter = new IntlListFormatter('en-US'); echo $formatter->format([ 'PHP', 'Laravel', 'MySQL' ]); It can produce: PHP, Laravel, and MySQL The formatting automatically follows the rules of the selected locale.
+

Why is IntlListFormatter useful?

Different languages have different rules for conjunctions, punctuation, and list structures. Instead of manually writing language-specific conditions, IntlListFormatter uses ICU/CLDR locale data to generate appropriate output.
+

Can IntlListFormatter format "and" and "or" lists?

Yes. It supports: TYPE_AND — A, B, and C TYPE_OR — A, B, or C TYPE_UNITS — useful for compound units such as 4 hours, 30 minutes $formatter = new IntlListFormatter( 'en-US', IntlListFormatter::TYPE_OR ); echo $formatter->format(['Credit Card', 'PayPal', 'Bank Transfer']); Output: Credit Card, PayPal, or Bank Transfer
+

Does PHP 8.5 support RTL language detection?

Yes. PHP 8.5 adds: locale_is_right_to_left('ar'); and: Locale::isRightToLeft('ar'); These return whether the locale uses a right-to-left script. This is useful for languages such as Arabic, Hebrew, Persian and Urdu.
+

Can RTL detection distinguish different scripts of the same language?

Yes. This is one of the important advantages of the new API. For example: locale_is_right_to_left('ku-Arab'); // true locale_is_right_to_left('ku-Latn'); // false This is more reliable than maintaining a simple hardcoded list of RTL language codes.
+

What are Locale::addLikelySubtags() and Locale::minimizeSubtags()?

These methods help expand or simplify BCP-47 locale identifiers using Unicode CLDR data. For example: echo Locale::addLikelySubtags('hi'); can produce: hi-Deva-IN while: echo Locale::minimizeSubtags('en-Latn-US'); can produce: en
+

Do I need PHP 8.5 to use NumberFormatter?

No. NumberFormatter has existed since PHP 5.3 through the intl extension. PHP 8.5's internationalization improvements should not be confused with features that already existed in previous PHP versions.
+

Do I need PHP 8.5 to use Unicode normalization?

No. Normalizer::normalize() has been available since PHP 5.3. It is part of the existing intl extension.
+

Does PHP 8.5 replace the intl extension?

No. PHP 8.5 builds on the existing intl extension and its ICU/CLDR foundation. The new APIs extend PHP's ability to handle internationalized applications correctly.
+

What is ICU and why is it important?

ICU (International Components for Unicode) provides the underlying internationalization data and functionality used by PHP's intl extension. It works with CLDR data to handle locale-specific rules for languages, scripts, lists, numbers, currencies and other internationalization requirements.
+

Is PHP 8.5 required for multilingual websites?

No. You can build multilingual applications with earlier supported PHP versions. PHP 8.5 provides additional tools that make certain internationalization tasks easier and more reliable.
+

Should I upgrade to PHP 8.5 just for internationalization?

Not necessarily. If your application doesn't require the new internationalization APIs, there
Previous Article

PHP 8.5 in Development: Fatal Error Stack Traces & Error and Exception Handler Getters

Next Article

The Complete wp_postmeta Performance Guide: Indexing, Bloat, Caching, and WooCommerce HPOS

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨