Why strlen and substr fail on real Unicode text and how the String component fixes that structurally
Once an application handles names, comments, or bio texts that might contain emoji, accented characters, or non-Latin scripts, the classic PHP functions strlen() and substr() routinely produce wrong results, because they operate byte by byte instead of character by character. Even the improved mb_* functions still fail on complex emoji made up of several combined Unicode code points that visually render as a single character. The Symfony String component solves this with three clearly separated, immutable string classes that make explicit which level you are actually working on, and this article shows exactly when each one is the right choice.
Table of Contents
- 1. The problem with strlen, substr, and friends on Unicode text
- 2. UnicodeString, ByteString, and CodePointString at a glance
- 3. Using UnicodeString in practice: length, slice, and more
- 4. Unicode normalization: NFC, NFD, and why it matters
- 5. When ByteString and CodePointString make sense instead of UnicodeString
- 6. The slugify use case with AsciiSlugger
- 7. Performance considerations and the immutability of the string classes
- 8. Comparison with the native mb_* functions
- 9. Practical recommendation: which class to use where in new projects
- 10. Summary
- 11. FAQ
1. The problem with strlen, substr, and friends on Unicode text
PHP's core functions strlen() and substr() historically operate byte by byte rather than character by character, which is harmless for plain ASCII but produces systematically wrong results on UTF-8 encoded multibyte text. A single accented character like 'e with an acute accent' as a precomposed character takes up two bytes in UTF-8, a Chinese character typically three, and a complex emoji can take up four bytes or more, so strlen() returns a count far too high for a short text containing such characters.
It gets even worse with substr(): cutting at the wrong byte position in the middle of a multibyte character produces a broken, invalid UTF-8 sequence that renders as a question mark or replacement glyph. The improved mb_* functions like mb_strlen() solve this at the code point level, but still fail on grapheme clusters, meaning characters made up of several code points (say, an emoji with a skin tone modifier, or a letter with a separate combining accent) that are perceived visually as a single character.
2. UnicodeString, ByteString, and CodePointString at a glance
The Symfony String component offers ByteString, CodePointString, and UnicodeString as three specialized, immutable string classes for exactly those three processing levels. ByteString works at the raw byte level and suits binary data or guaranteed ASCII, CodePointString works at the Unicode code point level and already solves the classic mb_* problems, while UnicodeString additionally recognizes grapheme clusters correctly, making it the safest choice for user-facing text.
All three classes implement a shared, fluent API with chainable method calls like ->trim()->lower()->replace(...), and since every method returns a new object instead of mutating the original, chains can safely be reused in multiple places. The global helper function u() from the Symfony\Component\String namespace conveniently produces a UnicodeString instance from a regular PHP string, without needing to import and instantiate the class explicitly every time.
3. Using UnicodeString in practice: length, slice, and more
The length() method on a UnicodeString instance actually counts visible grapheme clusters instead of bytes or code points, so a complex emoji with a skin tone or gender modifier is correctly counted as a single character, even though it internally consists of multiple code points. Likewise, slice() only cuts at grapheme boundaries, so a character is never split apart and corrupted in the middle, which can definitely happen with substr().
The example below shows a service that truncates user bio texts to a maximum display length in a Unicode-safe way, never destroying a character mid-way through, regardless of whether the text contains plain ASCII, accented characters, or complex emoji.
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\String\UnicodeString;
/**
* Truncates user texts to a maximum display length in a Unicode-safe
* way, never cutting a grapheme cluster in half.
*/
final class BioTextTruncator
{
/**
* Truncates the given text to the specified maximum length.
*
* @param string $text The source text to truncate
* @param int $maxLength The maximum number of visible characters
* @return string The truncated text, with an ellipsis if shortened
*/
public function truncate(string $text, int $maxLength = 160): string
{
$unicodeString = new UnicodeString($text);
if ($unicodeString->length() <= $maxLength) {
return $text;
}
return (string) $unicodeString->slice(0, $maxLength)->trimEnd()->append('...');
}
}
4. Unicode normalization: NFC, NFD, and why it matters
The same visible character can be encoded in Unicode in several different ways: an accented 'e' can be represented either as a single, precomposed code point (normal form NFC), or as the base character 'e' followed by a separate combining accent code point (normal form NFD). Both representations look identical to a human, but are completely different byte sequences, so a naive string comparison can incorrectly return 'not equal' even though the text is visually identical.
The normalize() method on UnicodeString, called with a constant such as UnicodeString::NFC, unifies the representation before comparisons, storage, or full text search. This matters especially for user input coming from different sources, since macOS, for instance, normalizes file names in its file system to NFD by default, while most web forms and databases expect NFC, which without explicit normalization can lead to hard-to-diagnose duplicates or failed comparisons.
5. When ByteString and CodePointString make sense instead of UnicodeString
ByteString is the right choice whenever you are actually dealing with binary data or guaranteed ASCII, for example hash values, base64-encoded data, or internal identifiers, where the extra cost of grapheme cluster detection would be unnecessary overhead without any practical benefit.
CodePointString represents a middle ground: it fits when code point accuracy is entirely sufficient, for example simple Latin text without complex combined characters, but the full grapheme cluster detection cost of UnicodeString should be avoided. In practice, UnicodeString still remains the right default for most use cases involving user-facing text, since the performance difference is rarely noticeable in typical web applications.
6. The slugify use case with AsciiSlugger
For the common task of turning an arbitrary Unicode title into a URL-safe string, the component offers the class Symfony\Component\String\Slugger\AsciiSlugger. It automatically transliterates accented characters into their closest ASCII equivalent, depending on the given locale, for instance rendering a German umlaut as an added 'e' following convention, and replaces every remaining non-alphanumeric character with a configurable separator.
A typical call looks like this: (new AsciiSlugger())->slug($title)->lower()->toString(), where the locale parameter passed to the constructor influences the concrete transliteration rules, for example different handling of accented characters between German and French. The result works directly as a URL segment for a blog post, a file name for an upload, or a unique, readable identifier inside log files.
7. Performance considerations and the immutability of the string classes
Since every operation on the string classes returns a new object instead of mutating the original, intermediate results of a method chain can safely be reused in multiple places without a change in one spot unexpectedly showing up somewhere else, similar to the principle behind \DateTimeImmutable. This approach costs slightly more memory than in-place mutation, which is entirely negligible for typical text handling in a web application.
For very large volumes of text, say batch processing several million lines in an import script, it is worth taking a closer look at ByteString or even native functions, since the grapheme cluster detection cost of UnicodeString can become measurably noticeable in such bulk processing scenarios.
8. Comparison with the native mb_* functions
The mb_* functions like mb_strlen() and mb_substr() are code point aware and therefore already handle many everyday cases correctly, but they offer no fluent, object oriented API, no unified interface across the three processing levels of byte, code point, and grapheme, and no built in normalization or slugify functionality, so those tasks each require additional, separate libraries.
The Symfony String component unifies all of these capabilities in a consistent, well testable, object oriented API and makes it immediately clear, through the explicit choice of class, which level is actually being worked on, instead of leaving that implicit in a function name, which considerably eases code review and later maintenance.
9. Practical recommendation: which class to use where in new projects
For any user-facing text, such as comments, names, bio texts, or product descriptions, UnicodeString, or the convenient u() helper, is the sensible default choice, ruling out grapheme cluster bugs structurally from the start rather than discovering them through a bug report from a user with a complex emoji in their name.
For URL slugs, AsciiSlugger is the right choice, for pure byte or binary data ByteString, and CodePointString should only be reached for deliberately, where an actually measured performance difference against UnicodeString is relevant, rather than using it preemptively throughout a project where the extra cognitive overhead is rarely justified.
| Class | Operates at the level of | Typical use | Example method |
|---|---|---|---|
| ByteString | Bytes | Binary data, ASCII, hash values | ->append() |
| CodePointString | Unicode code points | Simple Unicode text without complex graphemes | ->length() |
| UnicodeString | Grapheme clusters | User-facing text, display lengths | ->slice() |
| AsciiSlugger | Transliteration to ASCII | URL slugs, file names | ->slug() |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
String Component for Unicode Text: The Key Points at a Glance
The problem
strlen and substr operate byte by byte and cut multibyte characters apart mid-byte.
UnicodeString
Correctly recognizes grapheme clusters, the safe default for user-facing text.
Normalization
normalize(NFC) unifies differently encoded but visually identical characters.
Slugify
AsciiSlugger transliterates Unicode text, locale-aware, into a URL-safe ASCII string.