Two customer records render identically in the admin panel. The unique index accepted both, the validation rule passed, and support has been merging them by hand for a month. One of them ends in U+00A0, a no-break space, pasted out of Excel — and trim() does not remove it, because trim() strips exactly six ASCII characters and nothing else. mb_trim() in PHP 8.4 does remove it, which is the whole reason this function exists.
Here is the bug, reduced:
$a = 'Acme Ltd';
$b = "Acme Ltd\u{00A0}"; // pasted from a spreadsheet
$a === $b; // false
strlen($a); // 8
strlen($b); // 10 — two extra bytes, zero extra pixels
mb_strlen($b); // 9
trim($b) === $a; // false ← the one that costs you a week
mb_trim($b) === $a; // true
A unique rule and a unique index both compare bytes. Two byte sequences that render the same are still two rows. mb_trim() is one of five functions PHP 8.4 added to close long-standing mbstring gaps — the same release that gave us array_find, array_any and array_all.
What trim() has always been doing#
trim()'s default character list is six characters, and they are all ASCII:
| Character | Code point | Stripped by trim() |
Stripped by mb_trim() |
|---|---|---|---|
| Space | U+0020 | yes | yes |
| Tab | U+0009 | yes | yes |
| Line feed | U+000A | yes | yes |
| Carriage return | U+000D | yes | yes |
| NUL | U+0000 | yes | yes |
| Vertical tab | U+000B | yes | yes |
| Form feed | U+000C | no | yes |
| Next line | U+0085 | no | yes |
| No-break space | U+00A0 | no | yes |
| Ogham space mark | U+1680 | no | yes |
| En/em space | U+2000–U+200A | no | yes |
| Line separator | U+2028 | no | yes |
| Paragraph separator | U+2029 | no | yes |
| Narrow no-break space | U+202F | no | yes |
| Ideographic space | U+3000 | no | yes |
| Zero width space | U+200B | no | no |
| BOM | U+FEFF | no | no |
I generated that table by running every code point through both functions on PHP 8.5 rather than copying a release-notes list, and the last two rows are why. mb_trim() strips whitespace, and in Unicode terms U+200B and U+FEFF are not whitespace — they are format characters in category Cf. Every post I read while researching this claimed mb_trim() handles zero-width characters. It does not:
bin2hex(mb_trim("Acme\u{200B}")); // 41636d65e2808b — the ZWSP survives
Note also that trim() misses the form feed. That is not a Unicode problem, it is just an old default nobody has ever changed. It is the same pattern as DOMDocument mangling UTF-8 for two decades before PHP 8.4 shipped a real HTML5 parser: the default was set when ASCII was a fair assumption, and it never moved.
What mb_trim actually removes, and what it leaves behind#
The five functions, with the signatures verified against the running binary rather than the manual page:
mb_trim(string $string, ?string $characters = null, ?string $encoding = null): string
mb_ltrim(string $string, ?string $characters = null, ?string $encoding = null): string
mb_rtrim(string $string, ?string $characters = null, ?string $encoding = null): string
mb_ucfirst(string $string, ?string $encoding = null): string
mb_lcfirst(string $string, ?string $encoding = null): string
$encoding defaults to your internal encoding, not to UTF-8. If you have never called mb_internal_encoding() and something upstream has set it to anything else, these functions quietly operate in that encoding. Pass 'UTF-8' explicitly in library code. An invalid encoding is a hard ValueError, not a warning:
mb_trim('abc', 'x', 'NOPE');
// ValueError: mb_trim(): Argument #3 ($encoding) must be a valid encoding
To catch the zero-width characters as well, name them:
const INVISIBLE = " \t\n\r\0\x0B\u{00A0}\u{200B}\u{FEFF}";
bin2hex(mb_trim("Acme\u{200B}", INVISIBLE)); // 41636d65 — clean
Giving mb_trim a custom character list#
The second parameter is a set of characters, matched whole rather than byte by byte, which is the thing trim() could never do:
mb_trim('。、こんにちは。、', '。、'); // こんにちは
Now the part that is genuinely surprising. trim() supports .. range syntax in its character list. mb_trim() does not — it treats the dots as literal characters:
trim('abcHELLOcba', 'a..c'); // HELLO — a range
mb_trim('abcHELLOcba', 'a..c'); // bcHELLOcb — the set {a, ., c}
That is a real regression in capability if you port a trim() call across without reading it. There is no range syntax in the multibyte family; build the set out explicitly, or use preg_replace with a character class if the set is large.
One more sharp edge: an empty string is a valid character list meaning strip nothing.
mb_trim(' a ', ''); // ' a ' — unchanged, silently
mb_trim(' a ', null); // 'a'
If a config value or a nullable column feeds that parameter, '' and null behave completely differently.
ucfirst on a name with an accent#
ucfirst() is ASCII-only and locale-independent. On a UTF-8 string it does not corrupt the first byte — it just does nothing at all, which is a quieter bug and therefore a worse one:
bin2hex('émile'); // c3a96d696c65
bin2hex(ucfirst('émile')); // c3a96d696c65 — identical
The classic fix was mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1). mb_ucfirst() replaces it, and it is not merely shorter — it uses Unicode titlecase mapping where the old polyfill used uppercase. Every row below is a real return value:
| Input | ucfirst() |
polyfill | Str::ucfirst() |
mb_ucfirst() |
|---|---|---|---|---|
émile |
émile |
Émile |
Émile |
Émile |
ijsselmeer |
ijsselmeer |
IJsselmeer |
IJsselmeer |
IJsselmeer |
ıstanbul |
ıstanbul |
Istanbul |
Istanbul |
Istanbul |
džungla |
džungla |
DŽungla |
DŽungla |
Džungla |
ß |
ß |
SS |
SS |
Ss |
file |
file |
FIle |
FIle |
File |
'' |
'' |
'' |
'' |
'' |
The Croatian digraph is the clearest case. dž uppercases to DŽ (U+01C4) but titlecases to Dž (U+01C5), and titlecase is what a human writes at the start of a word. The polyfill returns the shouty one. The fi ligature row is the same story: uppercase gives you FIle, titlecase gives you File.
So mb_ucfirst() is not a convenience wrapper over the polyfill. It is more correct than the polyfill, including more correct than Laravel's helper.
Where Laravel already handles this for you#
More than the brief for this article assumed, as it turns out. In Laravel 13, Str::trim() does not delegate to trim() at all when no character list is given — it runs a preg_replace over \s plus an explicit invisible-character class:
Str::trim("Acme\u{00A0}") === 'Acme'; // true
Str::trim("Acme\u{200B}") === 'Acme'; // true — beats mb_trim()
Str::trim("Acme\u{FEFF}") === 'Acme'; // true — beats mb_trim()
Str::trim() is therefore more aggressive than mb_trim(), because its list includes the format characters Unicode does not classify as whitespace. Str::squish() builds on it, collapsing internal runs — including a lone U+00A0 — down to a single ASCII space.
And the question that actually decides whether this article matters to your application: the default TrimStrings middleware already strips U+00A0 and U+200B from request input. Verified against a real request rather than by reading the class:
$request = Request::create('/test', 'POST', [
'company' => "Acme Ltd\u{00A0}",
'zw' => "Acme\u{200B}",
]);
(new TrimStrings)->handle($request, function (Request $r) {
$r->input('company'); // 'Acme Ltd'
$r->input('zw'); // 'Acme'
});
If every write to that column arrives through an HTTP form, you are already covered and have been for a while. Which reframes the whole problem: the exposure is not forms. It is every path that skips the middleware.
Normalising the input that never touches the middleware#
Console commands, queued jobs, bulk CSV imports through a Filament import action, webhook payloads, and anything reading a file off disk all bypass TrimStrings entirely. That is where the no-break space gets in.
Normalise at the boundary you actually own — a form request is the cheapest place when there is one:
protected function prepareForValidation(): void
{
$this->merge([
'company' => mb_trim((string) $this->input('company'), null, 'UTF-8'),
]);
}
And prove it, because this is exactly the kind of fix that silently regresses:
it('normalises a no-break space before the unique rule runs', function () {
Company::factory()->create(['name' => 'Acme Ltd']);
$this->post('/companies', ['company' => "Acme Ltd\u{00A0}"])
->assertSessionHasErrors('company'); // fails without mb_trim()
});
For an import path, do the same in the importer's row mapper rather than trusting the file. A CSV exported from Excel also tends to carry a BOM on the first cell of the first row, and since mb_trim() leaves the BOM alone you have to name it — though stripping it at the stream level when you open the file is tidier than patching one cell:
mb_ltrim($firstCell, "\u{FEFF}");
If you are streaming a large file to get there, lazy collections keep the memory flat while you do it. For the HTTP side, running your real rules as the user types with Laravel Precognition means the normalisation you add in prepareForValidation() is exercised on every keystroke, not just on submit.
Supporting PHP 8.3 with a polyfill#
symfony/polyfill-mbstring provides all five. I checked v1.36.0's bootstrap directly rather than trusting the changelog — mb_trim, mb_ltrim, mb_rtrim, mb_ucfirst and mb_lcfirst are all defined, each behind a function_exists() guard so PHP 8.4+ keeps the native implementation:
composer require symfony/polyfill-mbstring
There is a good chance it is already in your composer.lock as a transitive dependency of Symfony's console or string components. Treat it as a correctness fix, not a performance one — the userland implementation is slower than the C one, and that is fine.
Gotchas and Edge Cases#
Invalid UTF-8 passes straight through. No exception, no warning, no substitution:
$bad = "\xC3\x28abc\xC3\x28";
bin2hex(mb_trim($bad)) === bin2hex($bad); // true
Silent pass-through is the right default, but it means mb_trim() will not tell you your legacy import is producing broken bytes. Run mb_check_encoding() at the boundary if that matters.
mbstring is not guaranteed. A slimmed container image that dropped the extension fatals on the first call. If you ship a library, either declare ext-mbstring in composer.json or guard with function_exists().
mb_trim() is not normalisation. It removes characters from the ends. It does not fold é (U+00E9) and e + combining acute (U+0065 U+0301) into the same string — those still fail a unique check while rendering identically. That is a job for Normalizer::normalize() from ext-intl, and it is a separate bug hiding behind the same symptom.
Wrapping Up#
If your data arrives over HTTP, Laravel already fixed this for you and Str::trim() is the better helper anyway — it catches the zero-width characters mb_trim() deliberately leaves. Reach for mb_trim() in the layers Laravel does not wrap: console commands, importers, jobs, and any package code that cannot assume the framework. Reach for mb_ucfirst() everywhere, because it is more correct than the polyfill you were using and than Str::ucfirst().
Next, the same "core finally shipped what everyone polyfilled" story plays out in PHP 8.5's URI extension replacing parse_url(), and if you are auditing string handling it is worth knowing when json_validate() actually beats json_decode() — another function that got adopted in the one place it makes things worse.
FAQ#
What is mb_trim in PHP 8.4?
mb_trim() is a multibyte-aware replacement for trim(), added in PHP 8.4 alongside mb_ltrim() and mb_rtrim(). It strips whitespace from both ends of a string using the Unicode whitespace set rather than the six ASCII characters trim() knows about, so it removes no-break spaces, ideographic spaces, en and em spaces and the rest. It takes an optional character list and an optional encoding, both nullable.
Why does trim() not remove non-breaking spaces?
Because trim()'s default character list is a fixed set of six single-byte characters — space, tab, line feed, carriage return, NUL and vertical tab — and it operates on bytes, not code points. A no-break space is U+00A0, which is two bytes in UTF-8 and is not in that list, so trim() walks past it and stops. The result is a string that looks trimmed, compares unequal to the trimmed version, and passes straight through a unique validation rule.
What is the difference between ucfirst and mb_ucfirst?
ucfirst() only understands ASCII, so on a UTF-8 string like émile it changes nothing at all and returns the input unmodified. mb_ucfirst() operates on the first code point and applies Unicode titlecase mapping, returning Émile. Titlecase also makes it more accurate than the old mb_strtoupper(mb_substr()) polyfill: mb_ucfirst('džungla') gives Džungla, where the polyfill gives the all-caps DŽungla.
How do I trim Unicode whitespace in PHP?
On PHP 8.4 or later, call mb_trim($string). Be aware that it strips whitespace only — the zero-width space U+200B and the byte order mark U+FEFF are Unicode format characters, not whitespace, and survive the default call. To remove those too, pass them in the character list explicitly, or in a Laravel application use Str::trim(), which covers both because it matches against an explicit invisible-character class.
Does Laravel's Str::ucfirst handle accented characters?
Yes. Str::ucfirst('émile') returns Émile correctly, because it uppercases the first character with mb_strtoupper() rather than calling ucfirst(). It is not titlecase-aware, though, so it returns SS for ß and DŽungla for džungla where mb_ucfirst() returns the more human Ss and Džungla. On PHP 8.4 and up, mb_ucfirst() is the more correct of the two.
How do I polyfill mb_trim on PHP 8.3?
Install symfony/polyfill-mbstring — version 1.36 provides all five of the new functions, and each definition sits behind a function_exists() guard so the native C implementations take over once you upgrade to PHP 8.4. There is a good chance the package is already in your lock file as a transitive dependency of a Symfony component. It is a correctness fix rather than a performance one; the userland implementation is measurably slower than the extension.