Truncating text looks like the simplest thing a program does: ask for twenty characters, get twenty characters. It is simple only while everyone reads the same script.
Ask for twenty and an English reader gets nineteen. A Khmer reader gets nine. Not nine because something failed — nine because the request was answered exactly as written, and "twenty characters" turns out to name three different quantities depending on whose language you are cutting. We measured the same truncation across six languages, then counted what our own code uses to do it.
Three numbers, all called length
bytes code points visible characters
English 49 49 49
Vietnamese 64 47 47
Lao 96 32 31
Burmese 119 41 29
Thai 138 46 39
Khmer 120 40 20
Khmer needs two code points for every visible character. A Khmer letter is frequently a cluster of a base consonant, a subscript consonant written beneath it, and a vowel sign that may sit on either side. To a reader that is one character. To a string function it is several.
This is the same distinction that decides how much of a name fits in a form field. The difference is that a form rejects the extra characters visibly, and a truncation just quietly delivers less.
Cutting by bytes destroys the string
The plain substr cuts at a byte offset with no interest in what lives there. On four of the six languages, the result is not valid UTF-8:
substr($s, 0, 20) produced invalid UTF-8 for:
Thai, Khmer, Burmese, Lao
and valid output for:
Vietnamese, English
The output ends with a partial character: the leading byte of a sequence whose continuation bytes were cut off. A reader sees the replacement glyph — the black diamond with a question mark. What a downstream system does with it depends entirely on how forgiving that system is, and JSON encoders are not forgiving; more on that below.
This kind of bug survives because it works perfectly on English and on Vietnamese, which is often the extent of a developer's test data.
Cutting by code points is safe and still unfair
Laravel's Str::limit counts code points, so it never produces invalid UTF-8. Asking all six for twenty:
English 19 visible characters
Vietnamese 20
Lao 19
Thai 17
Burmese 16
Khmer 9
No output is broken here, but each reader is served a different amount of text. If those numbers feed a card layout, a meta description, or a preview beside a thumbnail, the Khmer entry arrives half-empty against a design that was tuned on the English one.
One of the six also ended part-way through a cluster. Burmese, at twenty code points, stopped between a base character and the mark that belongs to it — leaving a vowel sign orphaned onto whatever it could attach to. The string is valid UTF-8, so it renders, and it is wrong in a way no encoding check will ever report.
What twenty actually looks like
The argument so far has been arithmetic. Here it is on the page. Both rows asked for twenty:
Khmer, code-point cut ភាសាខ្មែរជាភាសាផ្លូវ
Khmer, grapheme cut ភាសាខ្មែរជាភាសាផ្លូវការរបស់ប្រទេសកម្ពុជា
Thai, code-point cut ประเทศไทยมีภาษาที่สว
Thai, grapheme cut ประเทศไทยมีภาษาที่สวยงา
The two Khmer lines are the same request. The second is roughly twice as long, because twenty clusters is about forty code points in this script, and the first row stopped at twenty of the latter.
If you read Khmer, the shorter line stops in an odd place. If you do not, that is precisely the point: nothing about it looks damaged from outside the language, which is why it ships.
Cutting by grapheme is the one that means what it says
grapheme_substr($s, 0, 20)
This counts what a reader counts. Twenty means twenty for every language in the table, and it never lands inside a cluster. It requires PHP's intl extension, which is the entire cost.
So we looked at our own code:
substr 87 calls
mb_substr 88 calls
Str::limit 101 calls
grapheme_substr 0 calls
Zero. Across a codebase whose audience is Southeast Asia, the function that counts characters the way Southeast Asian readers do is used nowhere at all — the outcome of 276 individually reasonable decisions.
Two things that went wrong while measuring this
These are the kind of error that produces a clean-looking result, which is what makes them worth repeating.
The first: the check for whether a cut landed mid-cluster compared the truncated string against a grapheme-aware re-cut of itself. That is equal by construction, so the check passed everything and reported that nothing ever broke. The check has to compare the truncated string against a grapheme-perfect slice of the original, because the original contains the rest of the character the bad cut sliced in half. Corrected, it immediately found the Burmese case.
The second: the script wrote its results file empty. json_encode returns false rather than throwing when handed invalid UTF-8, and the file was being written from its return value without a check. The invalid UTF-8 was, of course, the substr output the script existed to demonstrate — the measurement destroyed its own record, silently, and the only symptom was a zero-byte file.
What to do
cutting text for display grapheme_substr
enforcing a stored limit count what the storage counts, and say so
anything user-facing cut on a word or cluster boundary, then ellipsis
never substr on text that came from a person
And if a limit exists because a downstream system imposes one — a database column, a meta tag, an SMS segment — then measure the limit in that system's units and let the visible length fall where it falls. The failure mode is treating one number as equally fair to every script when it is not.
How this was measured, and what it does not establish
One sentence per language, cut at twenty by four methods, in PHP 8.4 with intl available, inside the application container so Str::limit is the real one. The codebase counts are from the repository's own PHP and Blade files.
Whether a particular cut lands mid-cluster depends on the sentence. Burmese failed here and Thai did not; a different sentence would move that around. The per-language pass or fail is an example, not a property of the script. What is stable is the ratio of code points to visible characters, which is a property of the writing system.
Vietnamese is safe in this table because the text is precomposed. The same words in decomposed form — base letter plus combining marks — would behave like the others, and text arriving from an unknown source can be in either form. Normalising to NFC first is a separate habit worth having.
None of this says Laravel is broken. Str::limit is safer than substr on every script tested and never produced invalid output. But no framework can fix the disparity in what readers receive, because only you can decide whether your twenty refers to storage or to readable symbols.