Unicode and UTF-8 in PHP: Bytes vs. Characters, Quickly

Understand at a glance why PHP's native string functions count bytes rather than characters, why that matters for UTF-8 text, and where mbstring and normalization fit in — before going deeper in the dedicated Unicode and mbstring lesson.

PHP strings are, at the core-language level, just sequences of bytes with no built-in concept of 'character' or encoding. That's fine for ASCII, where one byte is one character, but it breaks down the moment text contains anything outside ASCII — accented letters, CJK text, emoji — because UTF-8 encodes those as multiple bytes per character. This lesson is a short orientation to that byte-vs-character distinction; the full treatment of mbstring functions, string-length pitfalls, and Unicode normalization lives in the 'Unicode and mbstring' lesson.

Bytes vs. characters, in one example

strlen() and substr() operate on bytes. Given the UTF-8 string "café", strlen('café') returns 5, not 4, because the é is encoded as two bytes in UTF-8. Slicing with substr() at a byte offset that lands in the middle of a multi-byte character corrupts that character, producing invalid UTF-8 output. This single fact — PHP's core string functions are byte-oriented, not character-oriented — is the root cause of the large majority of 'Unicode bugs' in PHP applications.

The fix is the mbstring extension's parallel set of functions — mb_strlen(), mb_substr(), mb_strtoupper(), and dozens more — which take an explicit (or mb_internal_encoding()-configured) character encoding and operate character-by-character instead of byte-by-byte.

Where the full depth lives

Which core functions are byte-unsafe and their mbstring equivalents, configuring mb_internal_encoding() and mb_string.func_overload history, detecting and converting between encodings with mb_detect_encoding()/mb_convert_encoding(), and Unicode normalization forms (NFC/NFD/NFKC/NFKD) for comparing visually-identical strings that are byte-different are all covered in the 'Unicode and mbstring' lesson. That is the lesson to work through for anything beyond 'my string length looks wrong.'

• Full byte-vs-character function comparison table: see Unicode and mbstring. • Encoding detection/conversion (mb_detect_encoding, mb_convert_encoding): see Unicode and mbstring. • Normalization forms (NFC/NFD/NFKC/NFKD) via the Normalizer class: see Unicode and mbstring.

Code Examples

strlen() reports the raw byte count of the UTF-8 encoding, while mb_strlen() (given the correct encoding, either passed explicitly or via mb_internal_encoding()) correctly counts four visible characters. Expected result: 5 4 echo mb_strlen($name), "\n"; // 4 -- counts characters

<?php

declare(strict_types=1);

$name = 'café'; // UTF-8 source file: é is 2 bytes (0xC3 0xA9)

echo strlen($name), "\n";     // 5 -- counts bytes
echo mb_strlen($name), "\n";  // 4 -- counts characters

Why this comes up in almost every PHP app

Any field that accepts free-form user text — names, bios, comments — will eventually contain non-ASCII characters, so byte-vs-character bugs surface in ordinary features like 'truncate this to 100 characters for a preview' rather than only in exotic internationalization work. Defaulting to the mb_* function for any string operation on user-facing text is cheap insurance against a whole class of subtle corruption bugs.

$preview = substr($userBio, 0, 50); // byte offset -- may cut a multi-byte character in half

$preview = mb_substr($userBio, 0, 50); // character offset -- always cuts on a character boundary substr() slices at a byte position with no awareness of UTF-8 character boundaries, so truncating user-supplied text with it can split a multi-byte character and produce an invalid UTF-8 fragment; mb_substr() slices on character boundaries instead.

Why does strlen('café') return 5 instead of 4 for the UTF-8-encoded string? A. strlen() has a bug with non-ASCII input B. strlen() counts bytes, and the é character is encoded as 2 bytes in UTF-8, while the other 3 characters are 1 byte each C. The string literal includes a trailing null byte D. café is actually 5 characters long in every encoding

B. strlen() counts bytes, and the é character is encoded as 2 bytes in UTF-8, while the other 3 characters are 1 byte each — PHP's core strlen() counts bytes, not characters. In UTF-8, é is a 2-byte sequence, so 'café' (c-a-f-é) is 1+1+1+2 = 5 bytes even though it displays as 4 characters — mb_strlen() reports the character count of 4 instead.