Blog
A String Isn't as Long as You Think
- php
- laravel
- typescript
- mysql
- debugging
Here's a bug report you've probably seen some version of. "The form says I have room, but when I hit save it tells me my bio is too long."
Or the opposite. Validation passes, the insert blows up with Incorrect string value, and the only thing different about this user is that they put a thumbs up at the end of their name.
Nothing is wrong with the user. Something is wrong with what we think "length" means.
Four answers to one question
Ask "how long is this string?" and there are at least four honest answers:
- Bytes. How much space it takes in UTF-8.
- Code points. How many Unicode characters, in the technical sense.
- UTF-16 code units. What JavaScript counts.
- Graphemes. What a human would point at and call one character.
For plain ASCII all four agree, which is why this hides so well in testing. The moment someone types an accent or an emoji, they split apart.
strlen('é'); // 2 (bytes)
mb_strlen('é'); // 1 (code points)
strlen('👍'); // 4
mb_strlen('👍'); // 1
$family = '👨👩👧';
strlen($family); // 18
mb_strlen($family); // 5 (three people joined by two zero-width joiners)
grapheme_strlen($family); // 1 (needs the intl extension)JavaScript has its own opinion:
'👍'.length; // 2 (UTF-16 code units)
[...'👍'].length; // 1 (code points)
const family = '👨👩👧';
family.length; // 8
[...family].length; // 5
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
[...graphemes.segment(family)].length; // 1One emoji. Answers of 1, 5, 8, and 18 depending on who you ask. None of them are wrong. They're just measuring different things.
Where this actually bites
The frontend and backend disagree on the limit
An HTML maxlength counts UTF-16 code units, same as JavaScript's .length. Laravel's max:255 rule on a string counts with mb_strlen, which is code points.
So an input with maxlength="20" and a backend rule of max:20 aren't enforcing the same limit. Emoji cost two units in the browser and one on the server. In this direction the browser is stricter, so the user gets blocked early and confused. Swap to a JS counter that uses something else, or a backend that uses strlen, and now the server rejects what the browser said was fine.
Pick one definition and use it on both sides. For user-facing limits, code points are a reasonable default because that's what Laravel and your database column already use.
export const charCount = (s: string) => [...s].length;Use that for the little "12 / 160" counter instead of .length, and the number the user sees matches what the server checks.
strlen in a validation path
If any hand-rolled check in a PHP codebase uses strlen, it's counting bytes. That's fine for things that really are byte limits (a token, a hash, a header). It's wrong for anything a person typed.
// Rejects a 60 character name if enough of it is accented
if (strlen($request->input('name')) > 100) { ... }
// What you meant
if (mb_strlen($request->input('name')) > 100) { ... }Older codebases are full of the first one. A quick search for strlen( near request input is usually worth the five minutes.
MySQL's utf8 isn't the UTF-8 you think
In MySQL, utf8 has historically meant utf8mb3: at most three bytes per character. Most emoji need four. In strict mode the insert fails with Incorrect string value. Without strict mode it's worse, because the value gets mangled and saved anyway.
The fix is utf8mb4, which is what Laravel's default MySQL config has used for years:
// config/database.php
'mysql' => [
// ...
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
],The catch is that the config only sets the connection. Tables created back when the app used utf8 keep their old character set until you convert them. If emoji fail on one table and work on another, check the table, not the app:
SHOW CREATE TABLE users;If you've ever wondered why old Laravel apps have Schema::defaultStringLength(191) in a service provider, this is the same story. Older InnoDB setups capped index keys at 767 bytes, and 191 characters times 4 bytes is the biggest VARCHAR that fit.
Good news on the column side: VARCHAR(255) is 255 characters, not 255 bytes. So your database and Laravel's max:255 do agree with each other.
Truncating is where it really breaks
Cutting a string to fit a preview, a meta description, or an SMS is where byte thinking turns into actual corrupted data.
substr($bio, 0, 100);If byte 100 lands in the middle of a multibyte character, you now have invalid UTF-8. That string might render as a little question mark box. Or it might hit json_encode, which returns false on malformed UTF-8 instead of a string. Now your API response is empty and the error is nowhere near the line that caused it.
mb_substr fixes the bytes, but it can still split a grapheme. Cut the family emoji above after three code points and you get two people and a dangling joiner. If you have intl, cut on graphemes:
function truncate(string $text, int $limit, string $end = '…'): string
{
if (grapheme_strlen($text) <= $limit) {
return $text;
}
return rtrim(grapheme_substr($text, 0, $limit)) . $end;
}Same idea in TypeScript:
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
export function truncate(text: string, limit: number, end = '…'): string {
const parts = Array.from(segmenter.segment(text), (s) => s.segment);
if (parts.length <= limit) return text;
return parts.slice(0, limit).join('').trimEnd() + end;
}text.slice(0, n) has the same problem as substr. It can cut a surrogate pair in half and leave you with a lone half of an emoji.
And if you're dealing with input you don't control, make the JSON failure loud instead of silent:
json_encode($payload, JSON_THROW_ON_ERROR);An exception with "Malformed UTF-8 characters" in it is a lot easier to chase than a blank response.
Same letters, different bytes
One more, because it causes the weirdest "but they're the same!" bugs. é can be stored as a single code point, or as a plain e followed by a combining accent. They look identical. They don't compare equal.
const a = 'caf\u00e9';
const b = 'cafe\u0301';
a === b; // false
a.normalize('NFC') === b.normalize('NFC'); // trueNormalizer::normalize($a, Normalizer::FORM_C) === Normalizer::normalize($b, Normalizer::FORM_C); // trueIf you're checking uniqueness on usernames, slugs, or anything a person typed, normalize before you compare and before you store. Otherwise two "identical" values can both pass a unique check.
The habit
When a field has a length limit, decide what "length" means and write it down. Then make every layer agree:
- Count user text in code points (
mb_strlen,[...s].length) unless you have a reason not to. - Use
strlenand.lengthonly for things that really are bytes or code units. - Make sure MySQL tables, not just the connection, are
utf8mb4. - Truncate with
grapheme_substrorIntl.Segmenter, never withsubstrorslice. - Normalize before comparing anything a human typed.
Then add a test with an emoji and an accented name in it. The ASCII-only test suite was never going to find this.