symfony/polyfill-intl-normalizer
Provides a pure-PHP fallback for the Normalizer class from PHP's Intl extension, enabling Unicode string normalization without requiring intl to be installed.
What this package actually is. It is not a feature you add to a Laravel app — it is a compatibility shim. If ext-intl is loaded, the package does essentially nothing: its bootstrap.php sees that normalizer_normalize(), normalizer_is_normalized(), normalizer_get_raw_decomposition() and the global Normalizer class already exist and stays out of the way. When ext-intl is missing, it registers a pure-PHP Normalizer (formally Symfony\Polyfill\Intl\Normalizer\Normalizer, aliased to the global class) plus the procedural functions, so code written against the native API keeps working. That means your job is mostly to (a) confirm the polyfill is present where you need it, and (b) write code that behaves identically in both worlds.
Step 1 — check whether you already have it. In a typical Laravel app you probably do, transitively: laravel/framework → symfony/console → symfony/string → symfony/polyfill-intl-normalizer.
composer show symfony/polyfill-intl-normalizer
composer why symfony/polyfill-intl-normalizer
php -r 'var_dump(extension_loaded("intl"));' # is the polyfill even active?
php -m | grep -i intl
If it only arrives transitively, and you are about to call Normalizer::normalize() in your own code, require it explicitly — otherwise a future dependency bump can silently remove it:
composer require symfony/polyfill-intl-normalizer:^1.38
^1.38 is the floor worth pinning if you intend to use Normalizer::getRawDecomposition() (added in 1.38); earlier 1.x tags have it missing.
Step 2 — decide your contract. Two honest options:
Require the real thing. Add "ext-intl": "*" to composer.json require. You get ICU-speed normalization and the polyfill is dead weight. Correct choice when you control the runtime (Docker, Forge, Vapor, Herd).
Accept the fallback. Leave ext-intl out (or in suggest) and rely on the polyfill. Correct when you ship to shared hosting, distribute a package, or run CI images you don't fully control.
Step 3 — first use case: normalize user-submitted names on write. This is the day-to-day win. macOS and some clients submit filenames and form data in decomposed form (e + U+0301), Windows and mobile keyboards usually submit precomposed (é). Same visible string, different bytes, and your UNIQUE index, your Str::slug(), and your search index all treat them as distinct.
$name = \Normalizer::normalize($request->string('name')->toString(), \Normalizer::FORM_C);
if ($name === false) {
// invalid UTF-8 or unsupported form — handle it, see Gotchas
}
Where to look first. The README is three lines; do not expect documentation there. The authoritative reference is the native PHP docs for Normalizer — the polyfill deliberately mirrors that API. In the vendor tree the only files that matter are:
vendor/symfony/polyfill-intl-normalizer/bootstrap.php — see exactly which functions/classes it conditionally defines.vendor/symfony/polyfill-intl-normalizer/Normalizer.php — the implementation; the static normalize(), isNormalized(), getRawDecomposition() signatures.vendor/symfony/polyfill-intl-normalizer/Resources/unidata/ — the Unicode tables (this is the ~50–100 KB you're paying for).Sanity check in tinker:
>>> \Normalizer::normalize("e\u{0301}", \Normalizer::FORM_C) === "\u{00E9}"
=> true
>>> \Normalizer::normalize("Cafe", \Normalizer::FORM_KC)
=> "Cafe"
Wrap it once, inject it everywhere. Normalizer is a global static class; calling it directly in 40 places makes tests brittle and gives you no seam. One interface, one binding:
namespace App\Support;
interface UnicodeNormalizer
{
public function normalize(string $value, int $form = \Normalizer::FORM_C): string;
public function isNormalized(string $value, int $form = \Normalizer::FORM_C): bool;
}
namespace App\Support;
final class IntlNormalizer implements UnicodeNormalizer
{
public function normalize(string $value, int $form = \Normalizer::FORM_C): string
{
$result = \Normalizer::normalize($value, $form);
// Normalizer returns false on invalid UTF-8 rather than throwing.
return $result === false ? $value : $result;
}
public function isNormalized(string $value, int $form = \Normalizer::FORM_C): bool
{
return \Normalizer::isNormalized($value, $form) === true;
}
}
// AppServiceProvider::register()
$this->app->singleton(\App\Support\UnicodeNormalizer::class, \App\Support\IntlNormalizer::class);
Now a test fake that returns input unchanged lets you assert that you normalized without depending on Unicode tables:
$this->app->instance(UnicodeNormalizer::class, new class implements UnicodeNormalizer {
public array $seen = [];
public function normalize(string $v, int $f = \Normalizer::FORM_C): string { $this->seen[] = $v; return $v; }
public function isNormalized(string $v, int $f = \Normalizer::FORM_C): bool { return true; }
});
Pick the right form, and pick it per column — not globally.
| Form | Use it for |
|---|---|
NFC (FORM_C) |
Default. Storage, display, most DB columns. What almost every consumer expects. |
NFD (FORM_D) |
Rare: linguistic/phonetic processing, some search tokenizers, reverse-mapping tasks. |
NFKC (FORM_KC) |
Lookup keys and identifiers: collapses fi→fi, C→C, ²→2. Lossy — never store as the display value. |
NFKD (FORM_KD) |
Same as NFKC but decomposed; almost never what you want in an app. |
The pattern that pays off: store the original, store a normalized sibling column for matching.
// migration
$table->string('name'); // as typed, NFC-normalized
$table->string('name_key')->nullable()->index(); // NFKC, lowercased — for lookups
$table->string('email_key')->nullable()->unique(); // same idea for email uniqueness
// app/Models/User.php — Laravel 9+ attribute style
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function name(): Attribute
{
return Attribute::make(
set: function (string $value) {
$n = app(\App\Support\UnicodeNormalizer::class);
return [
'name' => $n->normalize($value, \Normalizer::FORM_C),
'name_key' => mb_strtolower($n->normalize($value, \Normalizer::FORM_KC)),
];
},
);
}
Then where('name_key', $key) and where('email_key', $key) are exact, index-friendly, and immune to the macOS/Windows byte difference. This matters most for email: josé@example.com composed vs. decomposed will create two accounts otherwise.
Backfill existing rows with an Artisan command, not a migration.
User::query()->whereNull('name_key')->lazyById(500)->each(function (User $u) {
$u->forceFill(['name_key' => mb_strtolower(
\Normalizer::normalize($u->name, \Normalizer::FORM_KC) ?: $u->name
)])->saveQuietly();
});
saveQuietly() avoids firing observers and touching updated_at; lazyById() keeps memory flat. For large tables, dispatch as a queued job per chunk so the deploy doesn't time out.
Normalize uploaded filenames before they hit the disk. This is the single most common real bug the polyfill fixes — a decomposed filename pasted from a Mac creates a second, byte-different file on S3:
$original = $file->getClientOriginalName();
$safe = \Normalizer::normalize($original, \Normalizer::FORM_C) ?: $original;
$path = $file->storeAs('uploads', Str::random(40) . '-' . Str::slug(pathinfo($safe, PATHINFO_FILENAME))
. '.' . pathinfo($safe, PATHINFO_EXTENSION));
Pair this with NFC normalization of the Content-Disposition filename on download, or Safari and Windows clients will mangle it.
Build cache/download keys from the normalized form. If a hash is anything other than a pure content hash, normalize the input first:
$key = 'doc:'.hash('xxh128', app(UnicodeNormalizer::class)->normalize($title, \Normalizer::FORM_C));
Feed normalized text to search (Scout / Meilisearch / Algolia / Typesense). Engines do their own folding, but they do not guarantee Unicode canonical equivalence across their tokenizers, and re-indexing is expensive. Normalize in the toSearchableArray():
public function toSearchableArray(): array
{
$n = app(\App\Support\UnicodeNormalizer::class);
return [
'id' => $this->id,
'title' => $n->normalize($this->title, \Normalizer::FORM_C),
'content' => $n->normalize(strip_tags($this->content), \Normalizer::FORM_C),
];
}
Use it through symfony/string too, since symfony/console already drags it into every Laravel app:
use function Symfony\Component\String\u;
$clean = u($request->input('title'))->normalize()->trim()->toString();
UnicodeString::normalize() takes the same Normalizer::FORM_* constants, so it's a drop-in when you're already in the fluent u()/Str::of() style.
Add a validation rule when canonical form is a business requirement (regulated exports, KYC, dedup pipelines):
Validator::extend('nfc', fn ($attr, $value) =>
\Normalizer::isNormalized($value, \Normalizer::FORM_C) === true
);
Reject and re-prompt is usually worse UX than auto-fixing — reserve this for APIs where a canonical payload is contractually required.
normalize() returns false, it does not throw. Every call site needs a decision. It fails on invalid UTF-8 (easy to get from multipart/form-data, text/csv imports, or $_POST from a misconfigured client) and on an invalid $form value. If you pipe the result straight into a DB write you'll silently store '' or trigger a type error. Use the ?: $original / null-coalesce pattern shown above, and validate UTF-8 at the boundary with mb_check_encoding($s, 'UTF-8').
Laravel's Str::ascii() and Str::slug() do not use Normalizer. They go through voku/portable-ascii. So installing this package will not change your slugs, and uninstalling it will not break them. Anyone telling you Str::slug() is fixed by this polyfill is wrong — verify before you plan work around it. Str::upper()/Str::lower() are mbstring, also unrelated.
Your dev box and prod box almost certainly disagree about intl. Herd, Valet, Sail, and the official PHP images ship ext-intl; shared hosting and slim Alpine images frequently don't. That means locally the polyfill is dormant and in production it's live. Any Unicode edge case you "verified locally" was verified against ICU, not against this package. Two mitigations:
intl is loaded, so you can compare them:public function test_polyfill_matches_icu(): void
{
foreach (['é', "e\u{0301}", 'Cafe', 'fi', 'naïve', '🇺🇸'] as $s) {
$this->assertSame(
\Normalizer::normalize($s, \Normalizer::FORM_C),
\Symfony\Polyfill\Intl\Normalizer\Normalizer::normalize($s, \Normalizer::FORM_C) ?: null,
'mismatch for '.bin2hex($s)
);
}
}
docker-php-ext-install intl omitted.Performance: this is pure PHP, expect it to be meaningfully slower than ICU — comfortably an order of magnitude on typical text, worse on long strings with many combining marks. Never put it inside a loop over a collection in a request path. Concretely:
accessor.php -r 'require "vendor/autoload.php";
$s = str_repeat("Café ", 20000);
$t = microtime(true);
\Symfony\Polyfill\Intl\Normalizer\Normalizer::normalize($s);
printf("polyfill: %.1fms\n", (microtime(true)-$t)*1000);'
Run the same script using \Normalizer:: on an intl-enabled box for the comparison.
Do not NFKC your display data. NFKC is lossy: it turns ½ into 1⁄2, ligatures into letter pairs, superscripts into digits, and some vendor logos/CJK compatibility ideographs into different code points. Users notice. NFC for display, NFKC only for the *_key lookup column.
Normalization is not sanitization. It does not trim whitespace, case-fold, strip zero-width characters (U+200B, U+FEFF), remove control characters, or collapse the a + U+0301 lookalike-vs-Cyrillic а homoglyph problem. NFKC will not save you from spoofed usernames. If impersonation matters, you need a confusables/skeleton check on top — normalization is a prerequisite, not a solution.
Database collations do not do canonical equivalence for you. utf8mb4_unicode_ci and utf8mb4_0900_ai_ci give you accent- and case-insensitivity, but a decomposed and composed é are still different byte sequences and will not match under a UNIQUE index. Normalize at the application boundary (NFC on storage) — you cannot rely on MySQL or Postgres to do it. Set 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_0900_ai_ci' in config/database.php and still normalize explicitly.
Unicode version drift between ICU and the polyfill's tables. The polyfill vendors its own Resources/unidata snapshot; the native class uses whatever ICU your PHP was built against. For the last few Unicode versions the differences are confined to newly-added characters, but "the polyfill produced a different answer on this rare codepoint" is a real, if rare, class of bug. Pin assertions in tests to characters you've actually verified, and re-run the parity test after every composer update that bumps symfony/polyfill-*.
Emoji sequences are the sharp edge. Normalization will NFC the individual scalars, but ZWJ sequences, regional indicator pairs (🇺🇸), skin-tone modifiers, and variation selectors are not "normalized" in any sense you'd find useful. If you're counting, truncating, or slugging emoji-bearing user content, use grapheme_* (from ext-intl or symfony/polyfill-intl-grapheme — a sibling package, not this one) rather than Normalizer. Str::limit() on a string with combining marks can also split a grapheme; that's mbstring, again not this package's problem to solve.
Normalizer::NONE is a valid form and is a no-op, returning the input untouched. Handy in tests to assert your wrapper isn't accidentally transforming things.
There is no extension point in this package. You cannot swap the algorithm, register a custom form, or point it at a different Unicode version. The only seam is the one you build: the UnicodeNormalizer interface above. If you later decide to require ext-intl, you change one class binding and delete a Composer line — nothing else moves.
Debugging unknown bytes. When a matching bug appears, dump the code points rather than staring at the rendered string:
dump(array_map(fn ($c) => 'U+'.strtoupper(dechex(mb_ord($c, 'UTF-8'))), mb_str_split($value)));
dump(\Normalizer::isNormalized($value, \Normalizer::FORM_C));
dump(\Normalizer::getRawDecomposition($value)); // 1.38+; also useful for auditing weird input
getRawDecomposition() is genuinely handy in a Laravel context for an Artisan unicode:inspect {string} command — a five-minute tool that saves hours the next time a support ticket says "these two names look identical but the system says they're different."
Autoload conflict warning. The polyfill uses Composer's files autoload and defines global Normalizer/normalizer_* only via class_exists/function_exists guards. If you ever write your own global Normalizer class or a normalizer_normalize() helper in a legacy codebase, you'll get a fatal or, worse, silently get yours. Namespace your own helpers.
Don't put normalization in a saving observer and a mutator. It's idempotent so you won't corrupt data, but you'll pay the CPU cost twice on every write for no benefit. Pick one layer — mutator for model-level, FormRequest prepareForValidation() for API-level — and stay there.
How can I help you explore Laravel packages today?