PHPMultibyte & Unicode Strings

Multibyte & Unicode Strings

Earlier pages establish that a PHP string is a sequence of bytes, not a sequence of "characters." That design choice is invisible as long as text stays within plain ASCII, where one character always occupies exactly one byte. The moment text contains accented letters, non-Latin scripts, or emoji encoded as UTF-8, byte-oriented functions like strlen() and substr() start returning answers that don't match what a human sees on screen. This page explains why that happens and how PHP's mbstring extension fixes it.

Why UTF-8 breaks byte-based functions

UTF-8 encodes each character using between one and four bytes. Plain ASCII letters use one byte, but an accented letter like é or an emoji like 🚀 is encoded using multiple bytes glued together to represent a single visible character. strlen() has no concept of "one character" — it just counts bytes — so it reports a length far larger than the number of characters a person would count.

broken-strlen.php

PHP
<?php

$word = 'café';   // é is a multibyte UTF-8 character
$emoji = '🚀';

echo strlen($word) . PHP_EOL;   // bytes, not 4 characters
echo strlen($emoji) . PHP_EOL;  // bytes, not 1 character
5
4

café looks like four characters but reports as five bytes, because é alone takes two bytes in UTF-8. The rocket emoji looks like one character but takes four bytes. Both numbers are byte counts, which is exactly what strlen() promises — but it's rarely what you actually wanted to know.

substr() slicing mid-character

The same byte-vs-character mismatch is worse with substr(), because it can slice a multibyte sequence right down the middle, producing a fragment of invalid UTF-8 that renders as garbled text or a replacement-character symbol instead of the letter you wanted.

broken-substr.php

PHP
<?php

$word = 'café';

// Intending to grab the first 3 "characters"
$slice = substr($word, 0, 3);

var_dump($slice);
echo mb_check_encoding($slice, 'UTF-8') ? 'valid UTF-8' : 'BROKEN UTF-8';
string(3) "caf"
BROKEN UTF-8
Byte-based slicing corrupts multibyte text
Cutting a UTF-8 string at an arbitrary byte offset can land inside a multibyte character instead of between two characters. The result is a string that is no longer valid UTF-8 — it may display as a broken glyph, get rejected by strict JSON encoders, or corrupt further processing down the line.
The mbstring extension

PHP's mbstring extension provides character-aware equivalents of the standard string functions, prefixed with mb_. mb_strlen() counts actual characters (technically, encoded code points) instead of bytes, and mb_substr() slices on character boundaries so it never splits a multibyte sequence.

mbstring-fixed.php

PHP
<?php

$word = 'café';
$emoji = '🚀 launch';

echo mb_strlen($word) . PHP_EOL;    // 4 — actual character count
echo mb_strlen($emoji) . PHP_EOL;   // 8 — rocket + space + "launch"

echo mb_substr($word, 0, 3) . PHP_EOL; // 'caf' still, but safely
echo mb_substr($word, 0, 4);           // 'café' — full word, correct
4
8
caf
café
mb_strtoupper() and locale-aware case conversion

strtoupper() only understands plain ASCII, so it leaves accented letters completely unchanged. mb_strtoupper() (and its lowercase counterpart mb_strtolower()) correctly uppercase or lowercase characters outside the ASCII range, as long as you specify or have configured the right encoding.

mb-case-conversion.php

PHP
<?php

$city = 'québec';

echo strtoupper($city) . PHP_EOL;      // é is left alone
echo mb_strtoupper($city) . PHP_EOL;   // é becomes É correctly
QUéBEC
QUÉBEC
Setting the internal encoding

Most mb_* functions accept an optional encoding argument, but passing it every single call is repetitive. mb_internal_encoding() lets you set a default encoding once — near the top of a script or in a bootstrap file — so every subsequent mb_* call assumes UTF-8 (or whatever encoding your application uses) automatically.

internal-encoding.php

PHP
<?php

mb_internal_encoding('UTF-8');

$name = 'Zoë';

echo mb_strlen($name) . PHP_EOL;
echo mb_strtoupper($name);
3
ZOË
mbstring is usually enabled, but check
`mbstring` ships with most PHP distributions and is enabled in common setups like XAMPP by default, but it is technically an optional extension. If `mb_strlen()` throws a fatal "call to undefined function" error, check `phpinfo()` or `php -m` for `mbstring` and enable it in `php.ini` if it's missing.
Side-by-side: broken vs fixed

Putting the byte-based and multibyte-aware functions next to each other on the same input makes the difference concrete — this is the exact comparison worth running whenever a "string looks cut off weirdly" bug report involves non-English names or emoji.

side-by-side.php

PHP
<?php

$name = 'Renée 🎉';

echo 'strlen:    ' . strlen($name) . PHP_EOL;
echo 'mb_strlen: ' . mb_strlen($name) . PHP_EOL;

echo 'substr:    ' . substr($name, 0, 6) . PHP_EOL;
echo 'mb_substr: ' . mb_substr($name, 0, 6) . PHP_EOL;
strlen:    11
mb_strlen: 7
substr:    Renée
mb_substr: Renée 🎉

Byte-based

Multibyte-aware

Difference

strlen()

mb_strlen()

Bytes vs actual character count

substr()

mb_substr()

May split a character vs always slices on character boundaries

strtoupper() / strtolower()

mb_strtoupper() / mb_strtolower()

ASCII-only vs full Unicode case conversion

strpos()

mb_strpos()

Byte offset vs character offset

  • UTF-8 encodes many characters using more than one byte, so byte-counting functions overcount length.

  • substr() can slice inside a multibyte character, producing invalid UTF-8 output.

  • The mbstring extension provides mb_ prefixed equivalents that operate on characters, not bytes.

  • mb_internal_encoding() sets a script-wide default so you don’t repeat the encoding argument everywhere.

  • Always verify mbstring is enabled in the target environment before relying on it in production.

Tip
As a rule of thumb: if a string might ever contain user-entered names, non-English text, or emoji, default to the `mb_*` functions from the start rather than retrofitting them after a bug report about "weird cut-off text" comes in — it's much cheaper to write it correctly once than to hunt down every byte-based call later.