Unicode, mbstring and intl

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5 with the mbstring and intl extensions, checked on 2026-10-05; the intl examples were run against ICU 74. PHP strings are byte arrays (see Strings), so text in any script other than plain ASCII needs extensions that understand encodings and characters. Two are standard: mbstring for character-level string operations, and intl, a wrapper around the ICU library, for locale-aware formatting, collation, normalisation and segmentation. Both are bundled with PHP but optional; check php -m and install them if missing (docker-php-ext-install intl, apt install php8.5-intl).

Bytes, code points and grapheme clusters

The word "character" means three different things:

  • a byte: what strlen() and substr() count;

  • a code point: a Unicode scalar value, such as U+00F3 ó, encoded in UTF-8 as 1 to 4 bytes; what mb_strlen() counts;

  • a grapheme cluster: what a reader perceives as one character, which may be several code points (a base letter plus combining accents, an emoji with a skin-tone modifier, a flag); what grapheme_strlen() counts.

<?php

declare(strict_types=1);

$s = "Łódź 👍🏽 é";

var_dump(strlen($s));           // int(19) bytes
var_dump(mb_strlen($s));        // int(9)  code points: 👍🏽 is two (thumbs up + skin tone)
var_dump(grapheme_strlen($s));  // int(8)  user-perceived characters

var_dump(substr('Łódź', 0, 2));     // string(2) "Ł"  -- two BYTES: exactly one character, by luck
var_dump(mb_substr('Łódź', 0, 2));  // string(4) "Łó"
print_r(grapheme_str_split("👍🏽é"));  // 8.4: ["👍🏽", "é"]

Use the grapheme functions when the result is shown to people (truncating a title, counting characters for a limit), mb_* when working with code points, and the byte functions for protocols, binary data and ASCII identifiers.

Normalisation

The same text can be encoded in different ways: é can be the single code point U+00E9 (precomposed) or e followed by U+0301 COMBINING ACUTE ACCENT (decomposed). They look identical but compare unequal. Normalise text before comparing, hashing or storing it as a key: NFC (composed) is the usual choice for storage, NFKC additionally folds compatibility characters (fi to fi) and suits identifiers.

<?php

declare(strict_types=1);

$composed = "\u{00E9}";        // é as one code point
$decomposed = "e\u{0301}";     // e + combining acute accent

var_dump($composed === $decomposed);                                          // bool(false)
var_dump(Normalizer::normalize($decomposed, Normalizer::FORM_C) === $composed); // bool(true)
var_dump(Normalizer::isNormalized($decomposed, Normalizer::FORM_C));          // bool(false)
var_dump(strlen($decomposed), mb_strlen($decomposed), grapheme_strlen($decomposed));   // 3 2 1

The mbstring extension

mbstring provides multibyte-aware counterparts of most string functions. Pass the encoding explicitly or rely on mbstring.internal_encoding/default_charset, which default to UTF-8.

Byte function mbstring counterpart Since

strlen, substr, strpos, strrpos, substr_count

mb_strlen, mb_substr, mb_strpos, mb_strrpos, mb_substr_count

strtolower, strtoupper, ucwords

mb_strtolower, mb_strtoupper, mb_convert_case (with MB_CASE_TITLE, MB_CASE_FOLD)

str_split

mb_str_split

7.4

str_pad

mb_str_pad

8.3

trim, ltrim, rtrim

mb_trim, mb_ltrim, mb_rtrim

8.4

ucfirst, lcfirst

mb_ucfirst, mb_lcfirst

8.4

(display width)

mb_strwidth, mb_strimwidth: East Asian wide characters count as 2

<?php

declare(strict_types=1);

var_dump(mb_strtoupper('straße'));                            // "STRASSE": full case mapping
var_dump(mb_convert_case('ÉLAN VITAL', MB_CASE_TITLE));       // "Élan Vital"
var_dump(mb_ucfirst('élan'));                                 // "Élan"  (8.4)
var_dump(mb_str_pad('Łódź', 8, '·', STR_PAD_BOTH));           // "··Łódź··" (8.3): pads by characters
var_dump(mb_trim("\u{3000}Łódź\u{3000}"));                    // "Łódź" (8.4): trims Unicode whitespace too
print_r(mb_str_split('Łódź'));                                // ["Ł", "ó", "d", "ź"]
var_dump(mb_strwidth('日本'), mb_strimwidth('Hello World', 0, 8, '…'));   // int(4) "Hello W…"

Validating and converting encodings

Input from outside — forms, files, databases with the wrong connection charset — may not be valid UTF-8. Validate at the boundary, convert legacy encodings explicitly, and never trust a declared charset blindly:

<?php

declare(strict_types=1);

$fromLegacyFile = "\xe9t\xe9";                                  // "été" in ISO-8859-1
var_dump(mb_check_encoding($fromLegacyFile, 'UTF-8'));          // bool(false)
var_dump(mb_convert_encoding($fromLegacyFile, 'UTF-8', 'ISO-8859-1'));   // string(5) "été"

var_dump(mb_scrub("ok\xff"));                                   // "ok?": invalid bytes replaced
var_dump(iconv('UTF-8', 'ASCII//TRANSLIT', 'Łódź'));            // "Lodz" (result depends on the iconv library)

iconv is a second, POSIX-based conversion extension; its //TRANSLIT behaviour differs between glibc and musl (Alpine), so for portable transliteration prefer Transliterator (below). utf8_encode() and utf8_decode(), which only ever converted ISO-8859-1, were deprecated in PHP 8.2.

The intl extension

intl exposes ICU, the Unicode Consortium’s reference library, so its results follow the Unicode CLDR locale data: the same rules browsers, Java and Android use. Its classes take a locale identifier such as en_US, es_ES or de_CH.

Locales

<?php

declare(strict_types=1);

var_dump(Locale::acceptFromHttp('es-ES,es;q=0.9,en;q=0.8'));          // string(5) "es_ES"
var_dump(Locale::getDisplayName('es_ES', 'en'));                      // string(15) "Spanish (Spain)"
var_dump(Locale::lookup(['en', 'es', 'de'], 'es-MX', true, 'en'));    // string(2) "es": best match
Locale::setDefault('es_ES');                                          // default for intl classes

Locale::setDefault() affects only intl. Unlike setlocale(), it is not process-wide, which matters for multi-threaded servers and long-running workers.

Collation: sorting text correctly

Byte-wise sort() puts every accented letter after z. Collator sorts according to a language’s rules: Swedish puts Å and Ö after Z, German sorts them with A and O:

<?php

declare(strict_types=1);

$places = ['Öland', 'Zebra', 'Apple', 'Åland'];

$bytes = $places;
sort($bytes);
echo implode(', ', $bytes), "\n";                  // Apple, Zebra, Åland, Öland

$de = $places;
(new Collator('de_DE'))->sort($de);
echo implode(', ', $de), "\n";                     // Åland, Apple, Öland, Zebra

$sv = $places;
(new Collator('sv_SE'))->sort($sv);
echo implode(', ', $sv), "\n";                     // Apple, Zebra, Åland, Öland -- correct in Swedish

$c = new Collator('en_US');
$c->setStrength(Collator::PRIMARY);                // ignore case and accents
var_dump($c->compare('resume', 'Résumé'));         // int(0)

Numbers and currencies: NumberFormatter

<?php

declare(strict_types=1);

foreach (['en_US', 'de_DE', 'hi_IN'] as $locale) {
    $decimal = new NumberFormatter($locale, NumberFormatter::DECIMAL);
    $money = new NumberFormatter($locale, NumberFormatter::CURRENCY);
    printf("%-6s %-15s %s\n", $locale, $decimal->format(1234567.891), $money->formatCurrency(1234.5, 'EUR'));
}

echo (new NumberFormatter('en_US', NumberFormatter::PERCENT))->format(0.256), "\n";   // 26%
echo (new NumberFormatter('en_US', NumberFormatter::SPELLOUT))->format(1979), "\n";   // one thousand nine hundred seventy-nine
echo (new NumberFormatter('en_US', NumberFormatter::ORDINAL))->format(3), "\n";       // 3rd

$parser = new NumberFormatter('de_DE', NumberFormatter::DECIMAL);
var_dump($parser->parse('1.234,5'));                                                  // float(1234.5)
en_US  1,234,567.891   €1,234.50
de_DE  1.234.567,891   1.234,50 €
hi_IN  12,34,567.891   €1,234.50
26%
one thousand nine hundred seventy-nine
3rd
float(1234.5)

Note the Indian digit grouping (12,34,567) and the position of the currency symbol: hand-written number_format() calls get these wrong.

Dates: IntlDateFormatter, replacing strftime

strftime() and gmstrftime(), the traditional way of formatting dates in the language of setlocale(), were deprecated in PHP 8.1 (they will be removed in PHP 9): they depended on the operating system’s locales, were not thread-safe and behaved differently on every platform. IntlDateFormatter replaces them, and plain date() / DateTimeInterface::format() remain the tool for locale-independent formats.

<?php

declare(strict_types=1);

$loanDue = new DateTimeImmutable('2026-10-05 14:30', new DateTimeZone('Europe/Madrid'));

foreach (['en_GB', 'es_ES', 'de_DE', 'ja_JP'] as $locale) {
    $f = new IntlDateFormatter($locale, IntlDateFormatter::FULL, IntlDateFormatter::SHORT, 'Europe/Madrid');
    echo $locale, ': ', $f->format($loanDue), "\n";
}

// A custom ICU pattern (not the date() letters!)
$f = new IntlDateFormatter('es_ES', pattern: "EEEE d 'de' MMMM 'de' y");
echo $f->format($loanDue), "\n";
en_GB: Monday 5 October 2026 at 14:30
es_ES: lunes, 5 de octubre de 2026, 14:30
de_DE: Montag, 5. Oktober 2026 um 14:30
ja_JP: 2026年10月5日月曜日 14:30
lunes 5 de octubre de 2026

IntlDateFormatter uses ICU (CLDR) patterns, in which y is the year, MMMM the month name and EEEE the weekday name, unlike the single letters of date(). Date arithmetic and time zones are covered in Date and Time.

Source: The IntlDateFormatter class and strftime (deprecation notice)

Messages with plurals: MessageFormatter

Building sentences by concatenation breaks as soon as a language has different word order or plural forms (Polish has three plural categories, Arabic six). ICU MessageFormat puts the whole sentence, with its plural and select rules, in one translatable string:

<?php

declare(strict_types=1);

$pattern = '{0, plural, =0{No copies} one{# copy} other{# copies}} of {1} available';
foreach ([0, 1, 3] as $n) {
    echo MessageFormatter::formatMessage('en_US', $pattern, [$n, 'Kindred']), "\n";
}

$due = new DateTimeImmutable('2026-10-05');
echo MessageFormatter::formatMessage(
    'es_ES',
    '{n, plural, one{# ejemplar} other{# ejemplares}}, devolución el {d, date, long}',
    ['n' => 2, 'd' => $due],
), "\n";
No copies of Kindred available
1 copy of Kindred available
3 copies of Kindred available
2 ejemplares, devolución el 5 de octubre de 2026

Frameworks build on this: Symfony’s translator uses ICU MessageFormat, and Laravel’s trans_choice() has its own simpler pluralisation syntax (see Localization).

Lists: IntlListFormatter (PHP 8.5)

IntlListFormatter, new in PHP 8.5, joins items with the locale’s conjunction or disjunction rules, including the Oxford comma in American English:

<?php

declare(strict_types=1);

$titles = ['Solaris', 'Kindred', 'Dawn'];

echo (new IntlListFormatter('en_US'))->format($titles), "\n";                             // Solaris, Kindred, and Dawn
echo (new IntlListFormatter('es_ES', IntlListFormatter::TYPE_OR))->format($titles), "\n";  // Solaris, Kindred o Dawn

Transliteration and slugs

Transliterator converts between scripts and applies Unicode transforms; a chain such as Any-Latin; Latin-ASCII; Lower() produces ASCII slugs from titles in any script:

<?php

declare(strict_types=1);

$slugger = Transliterator::create('Any-Latin; Latin-ASCII; Lower()');

function slug(Transliterator $t, string $title): string
{
    return trim(preg_replace('/[^a-z0-9]+/', '-', $t->transliterate($title)), '-');
}

echo slug($slugger, 'Łódź Ñandú Москва'), "\n";      // lodz-nandu-moskva
echo slug($slugger, 'The Left Hand of Darkness'), "\n";

Grapheme functions

The grapheme_* functions mirror the string functions at the grapheme-cluster level: grapheme_strlen, grapheme_substr, grapheme_strpos and grapheme_stripos, grapheme_extract, grapheme_str_split (8.4) and grapheme_levenshtein (8.5), which computes edit distance in user-perceived characters:

<?php

declare(strict_types=1);

var_dump(levenshtein('café', 'cafe'));            // int(2): two byte edits
var_dump(grapheme_levenshtein('café', 'cafe'));   // int(1): one character edit (8.5)
var_dump(grapheme_substr('👍🏽 Kindred', 0, 1));    // the whole emoji, not half of it

IntlChar gives access to Unicode character properties (IntlChar::charName('👍') is "THUMBS UP SIGN", IntlChar::isalpha()), and IntlBreakIterator segments text into words and sentences.

Replacing setlocale

setlocale() changes C-library locale state for the whole process, which is shared by all requests a PHP-FPM or FrankenPHP worker handles, and by every thread in a threaded server. It also depends on which locales the operating system has installed. Since PHP 8.0 it no longer affects float-to-string conversion, and since 8.2 it no longer affects strtolower() and friends. Prefer the explicit, per-call intl classes:

Instead of Use

setlocale(LC_TIME, …) + strftime()

IntlDateFormatter

setlocale(LC_MONETARY, …) + money_format() (removed in 8.0)

NumberFormatter::formatCurrency()

setlocale(LC_NUMERIC, …) + number_format()

NumberFormatter

setlocale(LC_COLLATE, …) + strcoll()

Collator

ucfirst()/strtoupper() on non-ASCII text

mb_ucfirst()/mb_strtoupper()

Where older books and tutorials are outdated

  • strftime() with setlocale() (Beak ch. 3, Powers ch. 16, Kromann ch. 12) is deprecated since 8.1; use IntlDateFormatter.

  • money_format() was removed in 8.0.

  • utf8_encode()/utf8_decode() were deprecated in 8.2.

  • Missing functions: mb_str_split() (7.4), mb_str_pad() (8.3), mb_trim(), mb_ucfirst(), mb_lcfirst(), grapheme_str_split() (8.4), IntlListFormatter and grapheme_levenshtein() (8.5) are absent from older books, which often fall back to regular expressions or hand-written loops.

  • Mixing mb_internal_encoding() calls through the code is unnecessary: UTF-8 is the default encoding.