Ein Nachname steht zweimal untereinander auf dem Bildschirm, Buchstabe für Buchstabe derselbe, und ein Vergleich mit === antwortet trotzdem mit false. Das ist keine Macke des Interpreters, sondern eine Eigenschaft von Unicode: für viele sichtbare Zeichen gibt es zwei zulässige Schreibweisen. PHP Normalizer bringt beide auf dieselbe Bytefolge, und danach stimmt der Vergleich wieder.
Im Bild laufen zwei Schreibweisen desselben Zeichens von links in eine gemeinsame Form und verlassen sie als ein einziger Wert. Genauso geht der Text vor, angefangen beim Symptom.
Wenn zwei gleiche Wörter nicht gleich sind
Eine Notation vorweg, sonst sind die Beispiele nicht lesbar. In doppelten Anführungszeichen versteht PHP die Schreibweise \u{...} und setzt dort das Zeichen mit der angegebenen Nummer ein. \u{00E4} ist damit ein einziges Zeichen, a\u{0308} dagegen sind zwei: ein gewöhnliches a und dahinter ein Kombinationszeichen, das die beiden Punkte darübersetzt. Auf dem Bildschirm ist kein Unterschied zu sehen, im Speicher sehr wohl.
<?php
/* Beide Zeilen ergeben auf dem Bildschirm dasselbe Wort */
$vorkomponiert = "M\u{00E4}dchen"; /* Umlaut als ein Codepoint */
$zerlegt = "Ma\u{0308}dchen"; /* a plus Kombinationszeichen */
echo $vorkomponiert; /* Mädchen */
echo $zerlegt; /* Mädchen */
var_dump($vorkomponiert === $zerlegt); /* bool(false) */
/* Der Blick auf die Bytes zeigt, warum */
echo bin2hex($vorkomponiert); /* 4dc3a4646368656e */
echo bin2hex($zerlegt); /* 4d61cc88646368656e */
Die beiden letzten Zeilen sind der Beleg. An zweiter Stelle steht einmal c3a4 und einmal 61cc88, also zwei Bytes gegen drei. Der Vergleich arbeitet dabei völlig korrekt: er stellt Byte gegen Byte, und die Bytes sind verschieden. Aus demselben Grund scheitert hier auch strcmp(). Die Nummern hinter den Zeichen erklärt das Tutorial zu ord() und chr(). Der Fehler steckt also nicht im Vergleich, sondern in den Daten davor, und genau dort setzt PHP Normalizer an.
Was PHP Normalizer tatsächlich macht
Die Klasse hat eine einzige Aufgabe: sie wählt unter mehreren zulässigen Schreibweisen eine verbindliche aus. PHP Normalizer nimmt dazu eine Zeichenkette und eine Form entgegen und gibt die Zeichenkette in dieser Form aus, oder false, wenn die Eingabe kein gültiges UTF-8 ist. Der Inhalt bleibt derselbe, nur seine technische Schreibweise wird festgelegt.
<?php
$a = Normalizer::normalize($vorkomponiert, Normalizer::FORM_C);
$b = Normalizer::normalize($zerlegt, Normalizer::FORM_C);
var_dump($a === $b); /* bool(true) */
/* Prozedural geschrieben, gleiches Ergebnis */
var_dump(
normalizer_normalize($zerlegt, Normalizer::FORM_C) === $vorkomponiert
); /* bool(true) */
/* Ohne zweites Argument gilt FORM_C, trotzdem besser hinschreiben */
var_dump(Normalizer::normalize($zerlegt) === $vorkomponiert); /* bool(true) */
Zwischen Normalizer::normalize() und normalizer_normalize() besteht kein fachlicher Unterschied, das ist dieselbe Funktion in zwei Schreibweisen. Fehlt die Form, arbeitet PHP Normalizer mit FORM_C weiter. Hinschreiben sollte man sie trotzdem, sonst muss beim nächsten Lesen jemand raten, ob die Vorgabe gemeint war oder vergessen wurde.
Die vier Formen von PHP Normalizer: NFC, NFD, NFKC und NFKD
Vier Formen stehen zur Wahl, und sie unterscheiden sich in zwei Achsen: zusammensetzen oder zerlegen, und dabei Information erhalten oder wegwerfen. Die zweite Achse ist die wichtige, denn PHP Normalizer arbeitet in zwei der vier Fälle verlustbehaftet.
| Form | Was sie tut | Verlustbehaftet | Typischer Einsatz |
NFC
FORM_C | Setzt Grundzeichen und Kombinationszeichen wieder zu einem Codepoint zusammen | nein | Datenbank, Formulare, alles, was gespeichert oder verglichen wird |
NFD
FORM_D | Zerlegt jedes Zeichen in Grundzeichen und nachgestellte Kombinationszeichen | nein | Akzente einzeln auswerten oder entfernen, Analyse im Arbeitsspeicher |
NFKC
FORM_KC | Ersetzt Sonderformen durch ihre schlichte Entsprechung und setzt danach zusammen | ja | Suchindex, Vergleichsspalten, niemals das Original |
NFKD
FORM_KD | Wie NFKC, lässt die Zeichen danach aber zerlegt liegen | ja | Zwischenstufe, wenn Akzente nach dem Vereinfachen noch abgestreift werden |
Die Konstanten tragen übrigens Zahlenwerte, die sich niemand merken muss: FORM_D ist 4, FORM_KD ist 8, FORM_C ist 16, FORM_KC ist 32. Im Code steht immer der Name.
Länge: warum derselbe Name mal 11 und mal 12 Zeichen hat
Zwischen NFC und NFD steckt ein Unterschied, den man erst beim Zählen bemerkt: die zerlegte Form braucht ein Zeichen mehr pro Umlaut, und dieses Zeichen braucht zwei zusätzliche Bytes. Landet sie in einer Spalte, wird es teuer. Damit liefert PHP Normalizer hier den Schlüssel zu einem Fehlerbild, das viele zuerst in der Datenbankkonfiguration suchen. Als Zwischenschritt bleibt NFD trotzdem nützlich: kurz zerlegen, auf den Kombinationszeichen arbeiten, wieder in NFC speichern.
<?php
$name = "M\u{00FC}nchhausen";
$nfd = Normalizer::normalize($name, Normalizer::FORM_D);
echo strlen($name); /* 12 Bytes */
echo mb_strlen($name); /* 11 Zeichen */
echo strlen($nfd); /* 13 Bytes */
echo mb_strlen($nfd); /* 12 Zeichen */
/*
Eine Spalte VARCHAR(11) nimmt nur elf Zeichen auf.
In NFC passt der Name, in NFD faellt das letzte Zeichen weg.
*/
echo mb_substr($nfd, 0, 11); /* Münchhause */
Dasselbe Muster gilt für jedes zerlegbare Zeichen. Die folgenden Werte sind mit PHP Normalizer im Projektcontainer gemessen, nicht geschätzt.
| Zeichenkette | NFC Bytes | NFC Zeichen | NFD Bytes | NFD Zeichen |
| a mit zwei Punkten | 2 | 1 | 3 | 2 |
| o mit zwei Punkten | 2 | 1 | 3 | 2 |
| e mit Akut | 2 | 1 | 3 | 2 |
| Eszett | 2 | 1 | 2 | 1 |
| Münchhausen | 12 | 11 | 13 | 12 |
Die vierte Zeile ist eine kleine Überraschung: das Eszett hat gar keine Zerlegung und bleibt in beiden Formen ein einzelnes Zeichen. Grundlagen zur Länge stehen in Länge einer Zeichenkette ermitteln und in Multibyte-Strings mit UTF-8. Dort steht, wie viele Zeichen in einer Zeichenkette stecken. Hier geht es darum, dass dieselben Zeichen überall dieselbe Bytefolge haben, und dafür ist PHP Normalizer zuständig. Ohne das Zweite ist das Erste unzuverlässig.
Dateinamen zwischen macOS, Linux und Windows
Am häufigsten fällt das Problem beim Umgang mit Dateien auf. Von einem Mac kommen Dateinamen oft in zerlegter Form: auf dem älteren Dateisystem HFS+ war das zwingend, weil es jeden Namen selbst zerlegt abgelegt hat. APFS speichert den Namen dagegen so, wie er übergeben wurde, und normalisiert nicht mehr zwingend. Linux und Windows reichen die vorkomponierte Form durch. Verlassen kann man sich also auf keine der beiden Formen: vor jedem Vergleich normalisiert man selbst. Sonst treffen beim Abgleich eines Uploads mit dem Serververzeichnis zwei Welten aufeinander, obwohl in beiden derselbe Name steht.
<?php
/* So kommt der Name vom Mac an */
$hochgeladen = "Urlaub O\u{0308}sterreich.zip";
/* So steht er im Verzeichnis auf dem Linux-Server */
$imVerzeichnis = "Urlaub \u{00D6}sterreich.zip";
var_dump($hochgeladen === $imVerzeichnis); /* bool(false) */
echo strlen($hochgeladen); /* 23 */
echo strlen($imVerzeichnis); /* 22 */
/* Auch die Suche in einer Dateiliste geht ins Leere */
var_dump(in_array($hochgeladen, [$imVerzeichnis], true)); /* bool(false) */
/* Mit einer gemeinsamen Form passt es wieder */
$schluessel = static fn (string $n): string
=> (string) Normalizer::normalize($n, Normalizer::FORM_C);
var_dump($schluessel($hochgeladen) === $schluessel($imVerzeichnis));
/* bool(true) */
Wer keinen Mac zur Hand hat, kann den Fehler nicht nachstellen, und genau deshalb wandert die Suche gern in den Zeichensatz der Datenbank. Ein Aufruf von PHP Normalizer direkt bei der Annahme der Datei erledigt den Fall dagegen unabhängig davon, welches Betriebssystem der nächste Kollege benutzt.
Einmal an der Eingangsschleuse normalisieren
Daraus folgt eine Regel: einmal an der Eingangsschleuse in NFC bringen, danach muss sich kein weiterer Codeteil mehr darum kümmern. NFC deshalb, weil das W3C diese Form für Inhalte im Netz empfiehlt und weil sie nichts wegwirft und die Zeichenkette dabei so kurz hält wie möglich. Wo die Schleuse liegt, entscheidet die Anwendung: Formulardaten, Dateinamen aus einem Upload, Zeilen aus einem CSV-Import, Antworten fremder Schnittstellen. PHP Normalizer steht dort genau einmal im Code, und danach greift auch ein Unique-Index wieder, der bei zwei Schreibweisen desselben Namens vorher tatenlos zusah.
<?php
if (!extension_loaded('intl')) {
throw new RuntimeException(
'Die intl-Erweiterung fehlt, Normalizer steht nicht bereit.'
);
}
/*
Jedes Textfeld aus dem Formular wird an genau einer Stelle
in NFC gebracht, bevor irgendetwas damit passiert.
*/
function eingangsschleuse(array $felder): array
{
return array_map(static function ($wert) {
if (!is_string($wert)) {
return $wert;
}
return Normalizer::isNormalized($wert, Normalizer::FORM_C)
? $wert
: (string) Normalizer::normalize($wert, Normalizer::FORM_C);
}, $felder);
}
$sauber = eingangsschleuse($_POST);
Welche Form richtig ist, entscheiden zwei Fragen: wozu der Wert gebraucht wird und ob er schon in der gewünschten Form vorliegt. Als Ablauf gelesen sieht das so aus.
flowchart TD
A[String kommt herein] --> B{intl geladen?}
B -->|Nein| C[Abbruch mit Hinweis]
B -->|Ja| D{Wozu dient der Wert?}
D -->|Speichern, Vergleichen| E[NFC nehmen]
D -->|Zeichen einzeln lesen| F[NFD nehmen]
D -->|Suchindex bauen| G[NFKC nehmen]
E --> H{isNormalized true?}
H -->|Ja| I[Wert bleibt, wie er ist]
H -->|Nein| J[normalize aufrufen]
I --> K[In die Datenbank schreiben]
J --> K
F --> L[Nur zur Analyse, nicht speichern]
G --> M[Nur in den Index, nie ins Original]
isNormalized() als billiger Vorabtest
Der Zweig mit der Frage vor dem Aufruf spart echte Arbeit. PHP Normalizer erzeugt beim Normalisieren jedes Mal eine neue Zeichenkette, auch wenn am Wert nichts zu ändern war. Normalizer::isNormalized() beantwortet dagegen nur eine Ja-Nein-Frage und legt nichts Neues an; prozedural heißt sie normalizer_is_normalized().
<?php
/* So kommt es aus einem Formular auf einem Mac an */
$eingabe = "Jose\u{0301} Garci\u{0301}a";
var_dump(Normalizer::isNormalized($eingabe, Normalizer::FORM_C));
/* bool(false) */
$sauber = Normalizer::isNormalized($eingabe, Normalizer::FORM_C)
? $eingabe
: Normalizer::normalize($eingabe, Normalizer::FORM_C);
echo $sauber; /* José García */
echo strlen($eingabe); /* 15 Bytes */
echo strlen($sauber); /* 13 Bytes */
Zur Größenordnung ein ehrliches Wort, weil im Netz gern von einem gewaltigen Gewinn die Rede ist. Gemessen im Projektcontainer mit 200.000 Durchläufen auf demselben kurzen Namen: rund 0,018 Sekunden für isNormalized() gegen rund 0,030 Sekunden für normalize(). Das ist knapp Faktor 2, je nach Lauf zwischen 1,6 und 2,2, und der Gewinn stammt hauptsächlich aus der eingesparten Speicherzuweisung. Bei ein paar hundert Formularfeldern merkt das niemand, bei einem nächtlichen Import über Millionen Zeilen schon.
Vergleichsschlüssel mit mb_strtolower() bauen
Zwei Namen sollen als gleich gelten, egal ob jemand sie groß oder klein tippt und aus welchem Betriebssystem sie stammen. Beides zusammen leistet weder PHP Normalizer noch mb_strtolower() allein. Erst die Reihenfolge macht daraus einen tragfähigen Vergleichswert: erst vereinheitlichen, dann kleinschreiben.
<?php
function vergleichsschluessel(string $wert): string
{
$normalisiert = Normalizer::normalize($wert, Normalizer::FORM_C);
if ($normalisiert === false) {
throw new InvalidArgumentException(
'Kein gueltiges UTF-8: ' . intl_get_error_message()
);
}
return mb_strtolower($normalisiert, 'UTF-8');
}
/* Grossschreibung und Zerlegung gleichzeitig, trotzdem gleich */
var_dump(
vergleichsschluessel("J\u{00D6}RG")
=== vergleichsschluessel("Jo\u{0308}rg")
); /* bool(true) */
echo vergleichsschluessel("J\u{00D6}RG"); /* jörg */
Bei ungültigem UTF-8 gibt PHP Normalizer genau den Wert false zurück, den der Abbruch oben abfängt, und intl_get_error_message() nennt den Grund. Ein abgeschnittenes Mehrbyte-Zeichen aus einer beschädigten Datei liefert etwa Error converting input string to UTF-16: U_INVALID_CHAR_FOUND. Wer den Rückgabewert ungeprüft weiterreicht, schreibt später eine leere Spalte in die Datenbank und sucht die Ursache an einer ganz anderen Stelle.
<?php
/* Ein abgeschnittenes Mehrbyte-Zeichen, wie es aus einer
kaputten Datei kommen kann */
$kaputt = "\xC3\x28";
var_dump(Normalizer::normalize($kaputt, Normalizer::FORM_C));
/* bool(false) */
echo intl_get_error_message();
/* Error converting input string to UTF-16: U_INVALID_CHAR_FOUND */
Zwei Grenzen dieses Vergleichswerts sollte man kennen. Erstens macht mb_strtolower() aus einem Eszett kein doppeltes s, ein groß geschriebenes STRASSE und die Schreibweise mit Eszett fallen also nicht zusammen. Wer auch das braucht, nimmt mb_convert_case($wert, MB_CASE_FOLD, 'UTF-8'), das liefert für beide Varianten strasse. Zweitens arbeitet strcasecmp() byteweise und scheitert bei zerlegten Umlauten deshalb genauso wie ===.
Und noch eine Erwartung sollte man fahren lassen: eine mit PHP Normalizer vereinheitlichte Form macht die Sortierung reproduzierbar, aber nicht sprachlich richtig.
<?php
$woerter = ["Zebra", "\u{00C4}pfel", "Apfel", "Ofen", "\u{00D6}l"];
$nfc = array_map(
static fn (string $w): string
=> (string) Normalizer::normalize($w, Normalizer::FORM_C),
$woerter
);
sort($nfc);
echo implode(', ', $nfc);
/* Apfel, Ofen, Zebra, Äpfel, Öl */
Die Umlaute stehen weiterhin hinter dem Z, weil sort() Bytes vergleicht. Für eine Reihenfolge nach deutschen Regeln ist die Klasse Collator aus derselben Erweiterung zuständig.
NFKC und NFKD: nützlich im Index, gefährlich im Original
Die beiden Kompatibilitätsformen räumen mehr auf, als die meisten erwarten, und der Schritt ist endgültig. Aus einer Ligatur werden zwei gewöhnliche Buchstaben, aus einer hochgestellten Zwei eine normale, aus einem geschützten Leerzeichen ein gewöhnliches.
<?php
/* Ligatur wird zu zwei normalen Buchstaben */
echo Normalizer::normalize("Auf\u{FB01}nden", Normalizer::FORM_KC);
/* Auffinden */
/* Hochgestellte Zwei verliert ihre Hochstellung */
echo Normalizer::normalize("50 m\u{00B2}", Normalizer::FORM_KC);
/* 50 m2 */
/* Geschuetztes Leerzeichen wird ein normales Leerzeichen */
echo bin2hex(Normalizer::normalize("1\u{00A0}000", Normalizer::FORM_KC));
/* 3120303030 (aus c2a0 wird 20) */
/* Der Verlust ist endgueltig, NFC holt das Original nicht zurueck */
$kc = Normalizer::normalize("\u{00BD}", Normalizer::FORM_KC);
var_dump(Normalizer::normalize($kc, Normalizer::FORM_C) === "\u{00BD}");
/* bool(false) */
Beim Bruchzeichen lauert eine Falle, die im Netz meistens falsch beschrieben wird. Das halbe Bruchzeichen wird unter NFKC nicht zu einer Eins, einem gewöhnlichen Schrägstrich und einer Zwei, sondern zu einer Eins, dem Zeichen U+2044 FRACTION SLASH und einer Zwei. Aus einem Zeichen werden drei, und der Strich in der Mitte sieht nur aus wie ein Schrägstrich.
<?php
$kc = Normalizer::normalize("\u{00BD} Liter", Normalizer::FORM_KC);
echo bin2hex($kc);
/* 31 e28184 32 20 4c69746572
also: 1, FRACTION SLASH U+2044, 2, Leerzeichen, "Liter" */
echo mb_strlen("\u{00BD} Liter"); /* 7 */
echo mb_strlen($kc); /* 9 */
/* Der naheliegende Griff geht ins Leere */
var_dump(str_contains($kc, '/')); /* bool(false) */
var_dump(str_contains($kc, "\u{2044}")); /* bool(true) */
Daraus folgt die Regel für die Praxis: NFKC und NFKD arbeiten hervorragend als Zutat für einen Suchindex, in dem eine Suche nach "Auffinden" auch die Ligaturschreibweise findet. In die Spalte, in der das Original steht, gehören sie nie, denn ein Produkttext mit einer absichtlich gewählten Sonderschreibweise verliert sie dort stillschweigend und für immer. Das Original bleibt in NFC, und PHP Normalizer erzeugt daneben eine zweite, reduzierte Fassung für den Index.
Vier Fehler, die keine Meldung erzeugen
Die folgenden vier Punkte rund um PHP Normalizer erzeugen weder eine Warnung noch einen Eintrag im Fehlerprotokoll. Das Ergebnis ist einfach ein anderes als erwartet, und drei davon kosten erfahrungsgemäß einen halben Tag Suche.
Die Ursache in der Datenbank suchen Kollation, Zeichensatz und Verbindungseinstellung sind selten schuld. Solange zwei nicht vereinheitlichte Werte verglichen werden, kann keine Einstellung der Welt das reparieren.
utf8_encode() als Reparaturversuch Die Funktion wechselt zwischen zwei Kodierungen und hat mit dem Thema nichts zu tun. Seit PHP 8.2 ist sie zudem als veraltet gekennzeichnet. Angewendet auf sauberes UTF-8 macht sie den Wert kaputt.
Ein str_replace() auf den Schrägstrich nach NFKC Nach der Kompatibilitätsform steht im Bruch U+2044 und kein /. Die Ersetzung greift ins Leere, und niemand bemerkt es, weil das Zeichen im Browser fast identisch aussieht.
Auf dem Zielsystem gibt es die Klasse nicht Lokal läuft alles, auf dem Kundenserver bricht der Aufruf mit einem Fatal Error ab. Ohne die intl-Erweiterung existieren weder die Klasse noch die prozeduralen Funktionen.
PHP Normalizer oder Transliterator?
Beide Klassen stammen aus der intl-Erweiterung und werden im Code-Review regelmäßig vertauscht. PHP Normalizer räumt innerhalb derselben Schrift auf und macht Werte vergleichbar: das Wort bleibt dasselbe Wort in denselben Buchstaben. Die Umschrift mit transliterator_transliterate() tauscht die Schrift aus und macht Werte lesbar. Für Gleichheit, Unique-Indizes und Dublettensuche gilt das Erste, für Slugs und für Dateinamen aus fremden Schriften das Zweite. Wie diese Umschrift im Einzelnen arbeitet, welche Regelketten dafür bereitstehen und wo sie an ihre Grenze kommt, behandelt ein eigenes Tutorial.
Fazit
Unicode kennt für viele sichtbare Zeichen zwei zulässige Schreibweisen, und ein fehlgeschlagener Vergleich hat fast immer genau darin seinen Grund. PHP Normalizer löst das mit einer Zeile: Normalizer::normalize($wert, Normalizer::FORM_C) bringt beide Varianten auf dieselbe Bytefolge. NFD bleibt ein Zwischenschritt für die Arbeit an einzelnen Akzenten, NFKC und NFKD werfen Information weg und haben deshalb nur im Suchindex etwas verloren.
Eine Voraussetzung bleibt: ohne die intl-Erweiterung gibt es weder die Klasse noch normalizer_normalize(). Wo sie sich nicht nachrüsten lässt, springt symfony/polyfill-intl-normalizer ein, langsamer und mit kleinerem Umfang.
Drei Punkte tragen den Rest. Normalisiert wird an einer einzigen Stelle, dort, wo Daten in die Anwendung kommen. Der Rückgabewert wird gegen false geprüft, denn ungültiges UTF-8 liefert keinen leeren String, sondern genau diesen Wert. Und für einen Vergleich, der auch Großschreibung übersteht, folgt auf PHP Normalizer ein mb_strtolower(), nie umgekehrt. Wer das beherzigt, bekommt Namen, die sich finden lassen, und Spalten, die nichts abschneiden.