Public Suffix List in PHP: Registrierbare Domain finden

Aktualisiert: 6 Min. Lesezeit Redaktion The PHP Experts
Kurz gesagt

Die Public Suffix List von publicsuffix.org verzeichnet alle Endungen, unter denen sich Domains registrieren lassen, auch mehrteilige wie co.uk. In PHP lädst du die Datei, suchst für einen Hostnamen die längste passende Regel und nimmst ein Label mehr dazu: Das ist die registrierbare Domain. Fertig umgesetzt ist das im Composer-Paket jeremykendall/php-domain-parser.

Irgendwann braucht fast jede Anwendung die Antwort auf eine harmlos klingende Frage: Welche Domain steckt in diesem Hostnamen? Für www.example.de ist das leicht. Für shop.kunde.co.uk nimmt der naive Ansatz mit den letzten zwei Labels co.uk und gruppiert damit jeden britischen Firmenkunden unter derselben Domain. Die Public Suffix List löst genau dieses Problem.

Was die Public Suffix List ist

Die Liste wird von Mozilla initiiert und gemeinschaftlich unter publicsuffix.org gepflegt. Sie verzeichnet alle Suffixe, unter denen Dritte Namen registrieren können. Browser nutzen sie, um zu verhindern, dass eine Website Cookies für ein ganzes Suffix wie co.uk setzt. Für PHP-Anwendungen ist sie die Grundlage, um die „registrierbare Domain“ zu bestimmen, im Englischen auch eTLD+1 genannt.

Die Datei ist reiner Text, eine Regel pro Zeile. Die Kopie, die ich für diesen Beitrag geladen habe, hatte 10.398 Regeln, davon 6.949 im ICANN-Teil und 3.449 im privaten Teil. Es gibt drei Arten von Regeln:

RegelBeispielBedeutung
normalco.ukco.uk ist ein Suffix
Wildcard*.ckjedes Label unter .ck ist ein Suffix
Ausnahmewww.ck mit vorangestelltem Ausrufezeichenhebt eine Wildcard für genau diesen Namen auf

Gibt es keine passende Regel, gilt die Standardregel *: Das letzte Label ist das Suffix. Darum funktionieren auch Endungen, die neu und noch nicht eingetragen sind.

Der Algorithmus in eigenem PHP-Code

Für viele Projekte reicht eine eigene Klasse, und sie ist lehrreich, weil sie zeigt, wie die Regeln zusammenspielen. Die folgende Implementierung lädt die Datei einmal, trennt die drei Regelarten und sucht die längste passende Regel.

<?php
declare(strict_types=1);

final class PublicSuffixList
{
    /** @var array<string, true> */
    private array $rules = [];
    /** @var array<string, true> */
    private array $wildcards = [];
    /** @var array<string, true> */
    private array $exceptions = [];

    public static function fromFile(string $path, bool $includePrivate = true): self
    {
        $psl = new self();
        $handle = fopen($path, 'rb') ?: throw new RuntimeException("Kann $path nicht lesen");
        while (($line = fgets($handle)) !== false) {
            $line = trim($line);
            if (str_contains($line, '===BEGIN PRIVATE DOMAINS===') && $includePrivate === false) {
                break;
            }
            if ($line === '' || str_starts_with($line, '//')) {
                continue;
            }
            $raw = strtolower(explode(' ', $line)[0]);
            [$kind, $name] = match (true) {
                str_starts_with($raw, '!')  => ['exceptions', substr($raw, 1)],
                str_starts_with($raw, '*.') => ['wildcards', substr($raw, 2)],
                default                     => ['rules', $raw],
            };
            // Die Liste ist UTF-8, Abfragen kommen als Punycode: einmal beim Laden umwandeln
            $ascii = idn_to_ascii($name, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
            if ($ascii !== false) {
                $psl->{$kind}[$ascii] = true;
            }
        }
        fclose($handle);
        return $psl;
    }

    /** Öffentliches Suffix nach dem PSL-Algorithmus (längste passende Regel gewinnt). */
    public function suffix(string $asciiHost): string
    {
        $labels = explode('.', strtolower(rtrim($asciiHost, '.')));
        $count  = count($labels);
        for ($i = 0; $i < $count; $i++) {
            $candidate = implode('.', array_slice($labels, $i));
            if (isset($this->exceptions[$candidate])) {
                return implode('.', array_slice($labels, $i + 1));
            }
            if (isset($this->rules[$candidate])) {
                return $candidate;
            }
            $parent = implode('.', array_slice($labels, $i + 1));
            if ($i + 1 < $count && isset($this->wildcards[$parent])) {
                return $candidate;
            }
        }
        return $labels[$count - 1]; // Standardregel "*": letzte Ebene
    }

    public function registrableDomain(string $asciiHost): ?string
    {
        $host   = strtolower(rtrim($asciiHost, '.'));
        $suffix = $this->suffix($host);
        if ($host === $suffix) {
            return null; // Host ist selbst ein Suffix, z. B. "co.uk"
        }
        $rest = substr($host, 0, -strlen($suffix) - 1);
        return substr(strrchr('.' . $rest, '.'), 1) . '.' . $suffix;
    }
}

$psl = PublicSuffixList::fromFile(__DIR__ . '/public_suffix_list.dat');
foreach (['www.example.co.uk', 'shop.example.de', 'user.github.io', 'co.uk', 'a.b.example.ck', 'www.ck', 'city.kawasaki.jp', 'www.xn--mller-kva.de'] as $h) {
    printf("%-22s suffix=%-14s registrierbar=%s\n", $h, $psl->suffix($h), $psl->registrableDomain($h) ?? '(keine)');
}

Die Schleife läuft von links nach rechts und prüft zuerst den längsten Kandidaten. Die erste Regel, die passt, ist damit automatisch die längste. Ausnahmen werden vor allem anderen geprüft, so wie es der Algorithmus auf publicsuffix.org verlangt. Die Ausgabe mit der aktuellen Liste:

www.example.co.uk      suffix=co.uk          registrierbar=example.co.uk
shop.example.de        suffix=de             registrierbar=example.de
user.github.io         suffix=github.io      registrierbar=user.github.io
co.uk                  suffix=co.uk          registrierbar=(keine)
a.b.example.ck         suffix=example.ck     registrierbar=b.example.ck
www.ck                 suffix=ck             registrierbar=www.ck
city.kawasaki.jp       suffix=kawasaki.jp    registrierbar=city.kawasaki.jp
www.xn--mller-kva.de   suffix=de             registrierbar=xn--mller-kva.de

Die Klasse erwartet Punycode. Umlaut-Hosts wandelst du vorher um, wie im Beitrag zu Punycode in PHP beschrieben, und die Liste selbst wird beim Laden umgewandelt, weil sie IDN-Suffixe in UTF-8 enthält.

ICANN oder privat: zwei verschiedene Fragen

user.github.io zeigt den Unterschied. Mit privatem Teil ist github.io ein Suffix und user.github.io die registrierbare Domain. Das ist für Cookies richtig, denn GitHub verteilt dort Subdomains an Nutzer. Für die Frage „Welche Domain hat jemand beim Registrar gekauft?“ ist es falsch: Das ist github.io.

Deshalb hat fromFile() den Parameter $includePrivate. Willst du Domains für Registrar-Zwecke gruppieren, lade nur den ICANN-Teil. Für Sicherheitsfragen wie Cookie-Scopes oder CORS-Allowlists brauchst du beide Teile.

php-domain-parser per Composer

Wenn du die Pflege nicht selbst übernehmen willst, nimm jeremykendall/php-domain-parser. Das Paket setzt den Algorithmus vollständig um, unterscheidet ICANN und privat und arbeitet mit IDN. Getestet habe ich Version 6.4.0:

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Pdp\Domain;
use Pdp\Rules;

$rules  = Rules::fromPath(__DIR__ . '/public_suffix_list.dat');
$result = $rules->resolve(Domain::fromIDNA2008('www.Müller.co.uk'));

echo $result->registrableDomain()->toString(), "\n";   // müller.co.uk
echo $result->suffix()->toString(), "\n";              // co.uk
echo $result->subDomain()->toString(), "\n";           // www
var_dump($result->suffix()->isICANN());               // bool(true)

echo $rules->getICANNDomain('user.github.io')->registrableDomain()->toString(), "\n"; // github.io

Das Einlesen der Liste dauerte auf meinem Rechner rund 30 Millisekunden. Bei jedem Web-Request ist das zu viel. Das Rules-Objekt lässt sich serialisieren: Die serialisierte Fassung war gut 230 KB groß, und unserialize() brauchte weniger als eine halbe Millisekunde. Leg sie also in APCu, Redis oder eine Datei. Für größere Setups bringt das Paket im Namensraum Pdp\Storage eigene Klassen für PSR-16-Caches und PSR-18-HTTP-Clients mit, die Download und Cache-Lebensdauer übernehmen.

Neben der Public Suffix List kann das Paket auch die IANA-Liste der Top-Level-Domains auswerten. Damit beantwortest du die Frage, ob eine Endung überhaupt existiert, was die PSL wegen der Standardregel * nicht leistet: Für sie ist auch .gibtsnicht ein gültiges Suffix.

Die Liste aktuell halten

Die Datei liegt unter https://publicsuffix.org/list/public_suffix_list.dat. Lade sie nicht bei jedem Request, sondern halte eine lokale Kopie und erneuere sie per Cronjob, einmal pro Woche ist ein vernünftiger Rhythmus. Prüfe nach dem Download, ob die Datei plausibel groß ist und die Markierung ===BEGIN ICANN DOMAINS=== enthält, bevor du die alte ersetzt. Eine abgebrochene Übertragung sollte nicht dazu führen, dass plötzlich jeder Host als eigene Domain gilt. Schreib die neue Datei zuerst unter einem temporären Namen und benenne sie dann mit rename() um. Das ist auf demselben Dateisystem atomar, laufende Prozesse lesen also entweder die alte oder die neue Fassung, nie eine halbe. Vergiss danach nicht, einen Cache mit der geparsten Fassung zu leeren, sonst arbeitet die Anwendung noch tagelang mit dem alten Stand.

Wofür du die registrierbare Domain brauchst

Typische Anwendungen sind die Gruppierung von Links oder Absendern nach Domain, das Erkennen externer Links, Cookie-Scopes und das Zuordnen von Kundendomains zu Verträgen. Auch für Domain-Checks ist sie nützlich: Wer shop.meinefirma.co.uk eingibt, meint meist meinefirma.co.uk, und genau die solltest du an die Verfügbarkeitsprüfung in PHP oder einen RDAP-Lookup geben.

Was die Liste nicht ersetzt, ist die Formprüfung. Ob exa_mple.co.uk überhaupt ein gültiger Name ist, beantwortet der Regex aus Domain validieren mit Regex.

Hast du die registrierbare Domain ermittelt und willst wissen, ob sie noch frei ist? Die Domain-Suche von betadomain prüft sie sofort. Unter den rund 490 Endungen dort ist auch .co für 34,99 € im Jahr, und die Endungs-Übersicht zeigt, welche mehrteiligen Suffixe es gibt.

Schritt für Schritt

  1. Liste herunterladenpublic_suffix_list.dat von publicsuffix.org laden und lokal speichern, per Cronjob wöchentlich erneuern.
  2. Regeln einlesenKommentare und Leerzeilen überspringen, normale Regeln, Wildcards und Ausnahmen getrennt in Arrays ablegen, alles in Punycode.
  3. Längste Regel suchenFür den Hostnamen von links nach rechts kürzere Kandidaten bilden und die erste Regel nehmen, die passt, Ausnahmen zuerst.
  4. Registrierbare Domain bildenZum gefundenen Suffix ein weiteres Label hinzunehmen. Ist der Host selbst ein Suffix, gibt es keine registrierbare Domain.

Domain gefunden? Prüfen und registrieren kannst du sie direkt bei Domains mit festem Jahrespreis – Jahrespreis ab 2,99 €.

Häufige Fragen

Warum reicht es nicht, die letzten zwei Labels zu nehmen?

Weil viele Länder Domains auf der dritten Ebene vergeben, etwa example.co.uk oder example.com.au. Die letzten zwei Labels wären dort co.uk, also eine Endung und keine Domain. Ohne die Liste ordnest du alle Kunden einer solchen Endung derselben „Domain“ zu.

Was ist der Unterschied zwischen ICANN- und privaten Einträgen?

Der ICANN-Teil enthält Endungen, die Registries vergeben, zum Beispiel co.uk. Der private Teil enthält Suffixe, unter denen Firmen Subdomains an Kunden verteilen, etwa github.io. Für Cookies gelten beide, für die Frage nach der beim Registrar gekauften Domain nur der ICANN-Teil.

Wie oft muss ich die Liste aktualisieren?

Die Liste ändert sich laufend, weil neue Endungen und private Suffixe dazukommen. Ein wöchentliches Update per Cronjob ist ein guter Rhythmus. Lade die Datei nicht bei jedem Request, sondern halte eine lokale Kopie vor.

Welche Version von php-domain-parser ist aktuell?

Bei Erstellung dieses Beitrags war es die Reihe 6.x, zuletzt 6.4.0. Die API unterscheidet sich deutlich von Version 5, ältere Tutorials funktionieren deshalb oft nicht mehr. Prüf vor dem Einsatz die aktuelle Version auf Packagist.