Eine Kundin hat die Domain bäckerei-mayer.de bestellt. Im Hosting-Panel steht dafür xn--bckerei-mayer-bfb.de, und das Monitoring-Skript meldet seit Tagen, die Domain gebe es nicht. Beides hat dieselbe Ursache: Das DNS kennt keine Umlaute, und PHP fragt nur dann richtig nach, wenn der Name vorher umgewandelt wurde. Wie man eine E-Mail-Adresse mit Umlautdomain prüft, zeigt das Tutorial zu filter_validate_email. Hier geht es um die Umwandlung selbst, also darum, was PHP idn_to_ascii() mit einem Domainnamen macht, welche Optionen das Ergebnis verändern und wann die Funktion false liefert.
Alle Beispiele zu PHP idn_to_ascii() liefen unter Windows und Linux mit PHP 8.4. Nur bei einer Domain mit ß kamen auf den beiden Systemen verschiedene Ergebnisse heraus.
Was PHP idn_to_ascii() mit einer IDN macht
Internationalisierte Domainnamen, kurz IDN, dürfen Umlaute, Akzente oder ganz andere Schriften enthalten. Hostnamen im DNS bestehen aber nur aus Buchstaben von a bis z, Ziffern und Bindestrichen. Damit beide Welten zusammenpassen, kodiert Punycode jeden Teil des Namens mit Sonderzeichen als ASCII-Folge und setzt das Präfix xn-- davor. Aus müller.de wird so xn--mller-kva.de. Ein solcher Teil zwischen zwei Punkten heißt Label, und PHP idn_to_ascii() behandelt jedes Label für sich. Vorher wird der Name nach dem Standard UTS #46 aufbereitet: Großbuchstaben werden klein, Sonderformen wie vollbreite Zeichen oder ein zerlegter Umlaut werden vereinheitlicht. Wie diese Normalisierung bei gewöhnlichen Texten funktioniert, erklärt das Tutorial zum PHP Normalizer.
Der Weg eines Namens durch die Funktion sieht so aus:
flowchart TD
A[Domain in UTF-8] --> B[klein, normalisiert]
B --> C[Zerlegen in Labels am Punkt]
C --> D{Label nur ASCII?}
D -->|ja| E[Label bleibt stehen]
D -->|nein| F["Punycode mit Präfix xn--"]
E --> G{Regel oder Länge verletzt?}
F --> G
G -->|ja| H[false, Grund in idna_info]
G -->|nein| I[Labels wieder zusammensetzen]
Die Signatur im Original: Parameter und Rückgabewert
Beide Richtungen haben dieselben Parameter:
idn_to_ascii(
string $domain,
int $flags = IDNA_DEFAULT,
int $variant = INTL_IDNA_VARIANT_UTS46,
array &$idna_info = null
): string|false
idn_to_utf8(string $domain, ...): string|false
/* Converts a domain name from Unicode to IDNA ASCII.
domain - a unicode domain name encoded in
UTF-8, the domain name to convert
flags - IDNA_* options, combined with |
variant - only INTL_IDNA_VARIANT_UTS46 can be
used; the IDNA 2003 variant was
deprecated and is removed
idna_info - array with result,
isTransitionalDifferent and errors
return - the domain name in the IDNA
ASCII-compatible format (punycode),
or false on error.
This function is part of the intl module, the
package has to be installed and enabled. */
Bei einem Fehler gibt die Funktion nur false zurück, ohne Warnung und ohne Eintrag in error_get_last(). Den Grund erfährt nur, wer das Array $idna_info als vierten Parameter übergibt. Die alte Variante INTL_IDNA_VARIANT_2003 gibt es in PHP 8 nicht mehr, im Test war die Konstante gar nicht definiert.
Von Unicode zu ASCII und zurück mit idn_to_utf8()
Für die lesbare Form ist idn_to_utf8() zuständig. Das folgende Beispiel zeigt beide Richtungen mit den Ausgaben aus dem Test:
<?php
echo idn_to_ascii('müller.de'), PHP_EOL;
/* xn--mller-kva.de */
echo idn_to_ascii('shop.müller-bäckerei.de'), PHP_EOL;
/* shop.xn--mller-bckerei-hfb76a.de */
echo idn_to_ascii('MÜLLER.DE'), PHP_EOL;
/* xn--mller-kva.de */
echo idn_to_utf8('xn--mller-kva.de');
/* müller.de */
Das Label shop bleibt stehen, nur der Teil mit Umlauten wird kodiert. Großbuchstaben verschwinden, auch bei reinen ASCII-Namen: Aus EXAMPLE.COM wurde example.com. Ein Punkt am Ende bleibt erhalten.
Ob eine Umwandlung überhaupt nötig ist, lässt sich vorher prüfen. Ein regulärer Ausdruck wie preg_match('/[^\x00-\x7F]/', $domain) findet Zeichen außerhalb von ASCII. Nötig ist die Prüfung aber nicht. PHP idn_to_ascii() ließ im Test eine reine ASCII-Domain und eine schon umgewandelte Domain unverändert, abgesehen von der Kleinschreibung. Bei einer gültigen Domain schadet ein zusätzlicher Aufruf also nie, und das Skript wird einfacher, wenn es jede Domain umwandelt.
Voraussetzung: die Erweiterung intl
Die Funktion gehört zur Erweiterung intl, die auf der ICU-Bibliothek aufbaut. Fehlt sie, bricht das Skript mit Fatal error: Uncaught Error: Call to undefined function idn_to_ascii() ab. Die Meldung nennt die Erweiterung nicht, was die Suche erschwert. Unter Windows lag die Datei php_intl.dll im Test schon bei, in der php.ini stand die Zeile aber auskommentiert als ;extension=intl. Das Semikolon entfernen und den Webserver neu starten, etwa Apache unter XAMPP. Auf Ubuntu und Debian kommt die Erweiterung über das Paket php-intl. Im offiziellen Docker-Image genügt docker-php-ext-install intl allein nicht: Im Image php:8.4-cli brach der Bau mit Package requirements (icu-uc >= 50.1 icu-io icu-i18n) were not met ab. Vorher gehören die ICU-Entwicklerdateien ins Image, im Container oder in einem RUN-Schritt des Dockerfiles:
apt-get update
apt-get install -y libicu-dev
docker-php-ext-install intl
Danach war intl aktiv, mit ICU 76.1. Im Skript selbst prüft eine Zeile, ob die Funktion bereitsteht:
<?php
if (!function_exists('idn_to_ascii')) {
exit('Die Erweiterung intl fehlt.');
}
Der Fall straße.de: transitional oder nontransitional
Beim ß gibt es zwei Lesarten. Die ältere, transitional genannt, macht daraus ss, wie es die erste Fassung des IDN-Standards vorsah. Die neuere, nontransitional, behält das ß und kodiert es. Welche davon PHP idn_to_ascii() ohne Optionen wählt, hängt nicht an PHP, sondern an der ICU-Version, mit der PHP gebaut wurde:
| Aufruf | ICU 75.1 (Windows) | ICU 76.1 (Linux) |
Wert von IDNA_DEFAULT | 0 | 48 |
straße.de ohne Option | strasse.de | xn--strae-oqa.de |
straße.de mit IDNA_NONTRANSITIONAL_TO_ASCII | xn--strae-oqa.de | xn--strae-oqa.de |
idn_to_utf8() auf straße.de ohne Option | strasse.de | straße.de |
großes Eszett ẞ.de ohne Option | ss.de | xn--zca.de |
Die Erklärung steht in der Header-Datei der ICU-Bibliothek: Seit ICU 76 bedeutet die Vorgabe nontransitional, weil Unicode die Lesart transitional abgekündigt hat. Davor setzte IDNA_DEFAULT gar keine Option. Derselbe PHP-Code liefert also nach einem Serverumzug andere Werte, und ein Abgleich mit alten Datensätzen geht schief. In $idna_info['isTransitionalDifferent'] stand in allen diesen Fällen true, ein Hinweis, dass die beiden Lesarten hier auseinandergehen.
<?php
echo INTL_ICU_VERSION, ' / ', IDNA_DEFAULT, PHP_EOL;
/* 75.1 / 0 oder 76.1 / 48 */
echo idn_to_ascii('straße.de'), PHP_EOL;
/* ICU 75.1: strasse.de
ICU 76.1: xn--strae-oqa.de */
const IDN_OPT = IDNA_NONTRANSITIONAL_TO_ASCII;
echo idn_to_ascii('straße.de', IDN_OPT);
/* überall: xn--strae-oqa.de */
Welche ICU-Version der eigene Server mitbringt, verrät INTL_ICU_VERSION. Die Option gehört unabhängig davon immer ausdrücklich in den Aufruf, für die Gegenrichtung entsprechend IDNA_NONTRANSITIONAL_TO_UNICODE. Dann rechnet jedes System gleich.
Strenger prüfen mit IDNA_USE_STD3_RULES und Co.
Ohne weitere Optionen ist die Funktion erstaunlich großzügig. Die übrigen Schalter lassen sich mit | kombinieren und haben im Test Folgendes bewirkt:
| Option | Wirkung im Test |
IDNA_USE_STD3_RULES | lehnt Unterstrich, Leerzeichen, Doppelpunkt, Schrägstrich und @ ab |
IDNA_CHECK_BIDI | lehnt ein Label mit lateinischen und hebräischen Zeichen ab, ein rein arabischer Name ging durch |
IDNA_CHECK_CONTEXTJ | lehnt unsichtbare Verbindungszeichen ab, aber nur zusammen mit nontransitional, sonst werden sie vorher entfernt |
IDNA_ALLOW_UNASSIGNED | keine Wirkung, nicht vergebene Zeichen blieben ein Fehler |
Für Domains aus einem Bestellformular empfiehlt sich deshalb eine feste Kombination als Konstante:
<?php
const IDN_STRENG = IDNA_NONTRANSITIONAL_TO_ASCII
| IDNA_USE_STD3_RULES
| IDNA_CHECK_BIDI
| IDNA_CHECK_CONTEXTJ;
var_dump(idn_to_ascii('mein_host.de'));
/* string(12) "mein_host.de" */
var_dump(idn_to_ascii('mein_host.de', IDN_STRENG));
/* bool(false) */
var_dump(idn_to_ascii('Bäckerei.de', IDN_STRENG));
/* string(18) "xn--bckerei-5wa.de" */
Ohne STD3 bleibt der Unterstrich stehen. Für technische Namen wie _dmarc ist das gewollt, für Domains, die ein Kunde registrieren will, nicht. Emoji-Domains lehnt keine der Optionen ab: 😀.com wurde in jedem Fall zu xn--e28h.com. Ob eine Registry so etwas vergibt, entscheidet die Funktion nicht.
Wenn PHP idn_to_ascii() false liefert
Das Array $idna_info enthält drei Einträge: result mit dem Zwischenergebnis, isTransitionalDifferent und errors. Der letzte ist eine Bitmaske aus Konstanten wie IDNA_ERROR_LABEL_TOO_LONG (2), IDNA_ERROR_LEADING_HYPHEN (8) oder IDNA_ERROR_DISALLOWED (128). Die häufigsten Bits mit je einem gemessenen Auslöser:
| Konstante (Wert) | Gemessener Auslöser |
IDNA_ERROR_EMPTY_LABEL (1) | müller..de |
IDNA_ERROR_LABEL_TOO_LONG (2) | Label mit mehr als 63 Zeichen in der ASCII-Form |
IDNA_ERROR_DOMAIN_NAME_TOO_LONG (4) | Name mit 254 Zeichen |
IDNA_ERROR_LEADING_HYPHEN (8) | -müller.de |
IDNA_ERROR_TRAILING_HYPHEN (16) | müller-.de |
IDNA_ERROR_HYPHEN_3_4 (32) | ab--cd.de |
IDNA_ERROR_DISALLOWED (128) | mein_host.de mit STD3, Bytes in ISO-8859-1 |
Eine kleine Hilfsfunktion übersetzt die Bits in lesbare Namen:
<?php
function idna_fehler(int $bits): array
{
$namen = [];
$k = get_defined_constants(true)['intl'];
foreach ($k as $name => $wert) {
if (str_starts_with($name, 'IDNA_ERROR_')
&& ($bits & $wert)) {
$namen[] = $name;
}
}
return $namen;
}
$info = [];
$ascii = idn_to_ascii(
'müller-.de',
IDNA_NONTRANSITIONAL_TO_ASCII
| IDNA_USE_STD3_RULES,
INTL_IDNA_VARIANT_UTS46,
$info
);
if ($ascii === false) {
$bits = $info['errors'] ?? 0;
echo implode(', ', idna_fehler($bits));
}
/* IDNA_ERROR_TRAILING_HYPHEN */
Das ?? 0 ist kein Zierrat. Ab 255 Zeichen Gesamtlänge lieferte PHP idn_to_ascii() im Test false, ohne das Array überhaupt zu füllen. Bis 253 Zeichen ging alles gut, bei 254 meldete die Bitmaske den Wert 4 für einen zu langen Namen. Für das einzelne Label gilt die Grenze von 63 Zeichen, und zwar in der ASCII-Form. Ein Label aus 57 ü ergab genau 63 Zeichen Punycode und ging durch, 58 ü scheiterten. Ein Firmenname mit 60 Zeichen und vielen Umlauten wurde 71 Zeichen lang und damit abgelehnt. Wer Längen vor dem Absenden prüft, misst also das Ergebnis der Umwandlung und nicht die Eingabe.
Ein leerer String führt nicht zu false, sondern zu einer Ausnahme: ValueError: idn_to_ascii(): Argument #1 ($domain) must not be empty. Ein Bindestrich am Anfang oder am Ende eines Labels scheitert auch ohne STD3, mit dem Bit 8 beziehungsweise 16. Tückisch ist ein falsch kodierter Name. Bytes in ISO-8859-1 lehnt PHP idn_to_ascii() mit dem Bit 128 ab, doppelt kodiertes UTF-8 wie müller.de dagegen nicht. Daraus wurde ohne jeden Fehler xn--m14ller-xwa2143e.de. Wer solche verstümmelten Formen in der Datenbank findet, sucht den Fehler in der Zeichenkodierung davor, nicht bei der Umwandlung.
URLs und E-Mail-Adressen: nur den Host umwandeln
Die häufigste Fehlbedienung ist eine ganze Adresse als Eingabe. Aus https://müller.de/pfad machte PHP idn_to_ascii() ohne Fehler xn--https://mller-4ob.de/pfad, aus mia@müller.de wurde xn--mia@mller-u9a.de. Doppelpunkt, Schrägstrich und @ sind ohne STD3 erlaubte Zeichen eines Labels. Richtig ist, nur den Host umzuwandeln. Den liefert parse_url() auch mit Umlauten korrekt, im Test unverändert als müller.de. Wie die Funktion eine Adresse zerlegt, zeigt das Tutorial zum Zerlegen einer URL. Die folgende Funktion wandelt nur den Host um und setzt ihn an derselben Stelle wieder ein:
<?php
function url_ascii(string $url): string|false
{
$host = parse_url($url, PHP_URL_HOST);
if (!is_string($host) || $host === '') {
return false;
}
$ascii = idn_to_ascii(
$host,
IDNA_NONTRANSITIONAL_TO_ASCII
);
if ($ascii === false) {
return false;
}
$pos = strpos($url, $host);
return substr_replace(
$url, $ascii, $pos, strlen($host)
);
}
echo url_ascii('https://müller.de/pfad?q=ä');
/* https://xn--mller-kva.de/pfad?q=ä */
echo PHP_EOL;
echo url_ascii('https://user:pw@bücher.de:8080/x');
/* https://user:pw@xn--bcher-kva.de:8080/x */
Pfad und Query bleiben unberührt, sie gehören zu urlencode() und nicht zum DNS. Eine Adresse wie mailto:mia@müller.de hat keinen Host, die Funktion liefert dann false. Bei E-Mail-Adressen trennt strrpos($mail, '@') am letzten @, umgewandelt wird nur der Teil dahinter, das Ergebnis lautet mia@xn--mller-kva.de. Ob die Adresse danach gültig ist, prüft der Filter aus dem Tutorial zu filter_validate_email.
Praxis: DNS-Abfragen mit Umlautdomains
Zurück zum Monitoring aus der Einleitung. Die DNS-Funktionen von PHP reichen den Namen unverändert weiter und scheitern still, wenn er Umlaute enthält:
<?php
$domain = 'bücher.de';
var_dump(gethostbyname($domain));
/* string(10) "bücher.de" (nicht gefunden) */
$ascii = idn_to_ascii(
$domain,
IDNA_NONTRANSITIONAL_TO_ASCII
);
echo gethostbyname($ascii);
/* eine IPv4-Adresse, z. B. 104.21.33.153 */
gethostbyname() gibt bei einem Fehlschlag den Namen selbst zurück, und genau das passierte mit bücher.de. checkdnsrr() lieferte false, dns_get_record() ein leeres Array. Mit xn--bcher-kva.de kamen eine Adresse und zwei A-Records zurück, wobei die Adresse zwischen zwei Testläufen wechselte. Keine der drei Funktionen gab eine Warnung aus. Vor jede Abfrage gehört deshalb ein Aufruf von PHP idn_to_ascii(), ob für A-Records, MX-Einträge oder andere Typen, wie sie das Tutorial zu dns_get_record() beschreibt. Auch filter_var() mit FILTER_VALIDATE_DOMAIN und FILTER_FLAG_HOSTNAME lehnte müller.de ab und akzeptierte erst die Punycode-Form.
Speichern, vergleichen, anzeigen mit PHP idn_to_ascii()
In der Datenbank steht die Domain am besten in der ASCII-Form. Sie ist eindeutig und kleingeschrieben, und ein zweiter Aufruf ändert nichts mehr. So findet eine Duplikatprüfung MÜLLER.DE, müller.de und xn--mller-kva.de als denselben Eintrag. Für die Anzeige wandelt idn_to_utf8() mit IDNA_NONTRANSITIONAL_TO_UNICODE zurück.
Eine Ausnahme sind Domains, die nur so aussehen wie bekannte Namen. аpple.com mit einem kyrillischen a ist in der Unicode-Form nicht von der echten Domain zu unterscheiden. PHP idn_to_ascii() macht daraus xn--pple-43d.com, prüft aber nicht, ob der Name verdächtig ist. Dafür bringt dieselbe Erweiterung die Klasse Spoofchecker mit:
<?php
$pruefer = new Spoofchecker();
$domain = 'аpple.com';
$anzeige = $domain;
if ($pruefer->isSuspicious($domain)) {
$anzeige = idn_to_ascii(
$domain,
IDNA_NONTRANSITIONAL_TO_ASCII
);
}
echo htmlspecialchars($anzeige);
/* xn--pple-43d.com */
Für müller.de meldete isSuspicious() false, die Domain erscheint dann in der gewohnten Form. Nur der Verdachtsfall wird in Punycode angezeigt, und der Leser sieht sofort, dass etwas nicht stimmt.
Fazit
PHP idn_to_ascii() wandelt jedes Label eines Domainnamens einzeln in Punycode um und schreibt dabei alles klein. Die Gegenrichtung übernimmt idn_to_utf8(). Ohne Optionen hängt das Ergebnis beim ß von der ICU-Version ab, deshalb gehört IDNA_NONTRANSITIONAL_TO_ASCII in jeden Aufruf. Für Formulareingaben kommen IDNA_USE_STD3_RULES und die beiden Prüfoptionen dazu.
Fehler meldet die Funktion nur über false und das Array $idna_info, nie mit einer Warnung. Ganze URLs und E-Mail-Adressen gehören vorher zerlegt, Längen werden an der ASCII-Form gemessen. Wer jede DNS-Abfrage mit der umgewandelten Form ausführt, die ASCII-Form speichert und nur verdächtige Namen in Punycode zeigt, hat mit Umlautdomains keine Überraschungen mehr.