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()andsubstr()count; -
a code point: a Unicode scalar value, such as U+00F3
ó, encoded in UTF-8 as 1 to 4 bytes; whatmb_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.
Source: mb_strlen, grapheme_strlen and grapheme_str_split
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
Source: The Normalizer class
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 |
|---|---|---|
|
|
|
|
|
|
|
|
7.4 |
|
|
8.3 |
|
|
8.4 |
|
|
8.4 |
(display width) |
|
<?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…"
Source: Multibyte String, mb_str_pad, mb_trim and mb_ucfirst
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.
Source: mb_check_encoding, mb_convert_encoding and iconv
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.
Source: The Locale class
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)
Source: The Collator class
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.
Source: The NumberFormatter class
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).
Source: The MessageFormatter class
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
Source: The IntlListFormatter class
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";
Source: The Transliterator class
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.
Source: grapheme_levenshtein and The IntlChar class
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
Where older books and tutorials are outdated
-
strftime()withsetlocale()(Beak ch. 3, Powers ch. 16, Kromann ch. 12) is deprecated since 8.1; useIntlDateFormatter. -
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),IntlListFormatterandgrapheme_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.
References
-
PHP Manual — Multibyte String: mb_strlen, mb_substr, mb_convert_case, mb_str_pad, mb_trim, mb_ucfirst, mb_str_split, mb_strwidth, mb_check_encoding, mb_convert_encoding
-
PHP Manual — Internationalization Functions (intl): Locale, Normalizer, Collator, NumberFormatter, IntlDateFormatter, MessageFormatter, IntlListFormatter, Transliterator, IntlChar