Public Suffix List in PHP: Get the Registrable Domain

Updated: 6 min read Editorial team The PHP Experts
Short answer

The Public Suffix List at publicsuffix.org lists every suffix under which names can be registered, including multi-part ones like co.uk. In PHP you load the file, find the longest rule matching a hostname and add one more label: that's the registrable domain. The Composer package jeremykendall/php-domain-parser implements the full algorithm.

Sooner or later most applications need to answer an innocent-looking question: which domain is this hostname part of? For www.example.com it’s trivial. For shop.client.co.uk, the “last two labels” shortcut returns co.uk and lumps every British business into one bucket. That’s the problem the Public Suffix List solves.

What the Public Suffix List is

Started by Mozilla and maintained as a community project at publicsuffix.org, the list records every suffix under which third parties can register names. Browsers use it to stop a site from setting cookies for an entire suffix like co.uk. In PHP it’s how you compute the registrable domain, also called eTLD+1.

It’s a plain text file with one rule per line. The copy I fetched for this guide held 10,398 rules: 6,949 in the ICANN section and 3,449 in the private section. There are three kinds of rule:

RuleExampleMeaning
normalco.ukco.uk is a suffix
wildcard*.ckevery label under .ck is a suffix
exceptionwww.ck, prefixed with an exclamation markcancels a wildcard for exactly that name

When nothing matches, the default rule * applies: the last label is the suffix. That’s why brand-new TLDs still work before they’re added.

Implementing the algorithm in PHP

For many projects your own class is enough, and writing one shows how the rule types interact. This one loads the file once, separates the three rule kinds and finds the longest match.

<?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("Cannot read $path");
        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],
            };
            // the list is UTF-8, lookups use punycode: convert once while loading
            $ascii = idn_to_ascii($name, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
            if ($ascii !== false) {
                $psl->{$kind}[$ascii] = true;
            }
        }
        fclose($handle);
        return $psl;
    }

    /** Public suffix per the PSL algorithm (longest matching rule wins). */
    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]; // default rule "*": last label
    }

    public function registrableDomain(string $asciiHost): ?string
    {
        $host   = strtolower(rtrim($asciiHost, '.'));
        $suffix = $this->suffix($host);
        if ($host === $suffix) {
            return null; // host is itself a suffix, e.g. "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.com', 'user.github.io', 'co.uk', 'a.b.example.ck', 'www.ck', 'city.kawasaki.jp', 'www.xn--bcher-kva.com'] as $h) {
    printf("%-22s suffix=%-14s registrable=%s\n", $h, $psl->suffix($h), $psl->registrableDomain($h) ?? '(none)');
}

The loop checks the longest candidate first, so the first match is automatically the longest rule. Exceptions are checked before anything else, as the algorithm on publicsuffix.org requires. Output with the current list:

www.example.co.uk      suffix=co.uk          registrable=example.co.uk
shop.example.com       suffix=com            registrable=example.com
user.github.io         suffix=github.io      registrable=user.github.io
co.uk                  suffix=co.uk          registrable=(none)
a.b.example.ck         suffix=example.ck     registrable=b.example.ck
www.ck                 suffix=ck             registrable=www.ck
city.kawasaki.jp       suffix=kawasaki.jp    registrable=city.kawasaki.jp
www.xn--bcher-kva.com  suffix=com            registrable=xn--bcher-kva.com

The class expects punycode input. Convert Unicode hosts first as shown in the punycode guide; the list itself is converted while loading because it contains IDN suffixes in UTF-8.

ICANN or private: two different questions

user.github.io shows why the distinction matters. With the private section, github.io is a suffix and user.github.io the registrable domain. That’s correct for cookies, since GitHub hands those subdomains to users. It’s wrong if you want to know what someone bought from a registrar, which is github.io.

That’s what $includePrivate is for. Group domains for registrar purposes with the ICANN section only. For security decisions such as cookie scopes or CORS allowlists, use both.

Using php-domain-parser

If you’d rather not maintain your own parser, use jeremykendall/php-domain-parser. It implements the full algorithm, separates ICANN and private rules and handles IDNs. I tested 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

Parsing the list took about 30 ms on my machine, too slow to repeat per request. The Rules object serialises to roughly 230 KB, and unserialize() took under half a millisecond, so cache it in APCu, Redis or a file. The Pdp\Storage namespace also ships PSR-16 cache and PSR-18 client classes that handle downloading and cache lifetimes for you.

The package can read IANA’s TLD list too. That answers whether a TLD exists at all, which the PSL can’t, because the default rule makes .doesnotexist a valid suffix.

Mistakes I see repeatedly

A few patterns come up in nearly every code review involving domain extraction:

  • Using parse_url() and then splitting on dots. parse_url() gives you the host, nothing more; the suffix logic still has to come from the list.
  • Bundling a copy of the list in the repository and never updating it. Two years later, newer suffixes are treated as ordinary domains.
  • Forgetting the trailing dot. example.co.uk. is a valid fully qualified name, and without rtrim() the last label becomes an empty string.
  • Passing IP addresses. 192.168.0.1 has no public suffix; check with filter_var($host, FILTER_VALIDATE_IP) before calling the parser.

None of these are exotic. They all surface the first time real user input hits the code.

Keeping the list current

The file lives at https://publicsuffix.org/list/public_suffix_list.dat. Keep a local copy and refresh it from cron; weekly is reasonable. Before replacing the old copy, check that the download has a plausible size and contains the ===BEGIN ICANN DOMAINS=== marker, so a truncated transfer can’t turn every host into its own domain. Write to a temporary filename and rename() it into place, which is atomic on the same filesystem, then clear any cached parsed version.

Where you need the registrable domain

Typical uses: grouping links or senders by domain, detecting external links, cookie scopes, mapping customer hostnames to accounts. It also helps with lookups. Someone typing shop.mycompany.co.uk usually means mycompany.co.uk, and that’s the name to pass to an availability check in PHP or an RDAP lookup.

The list doesn’t replace shape validation. Whether exa_mple.co.uk is a legal name at all is for the regex in validating domain names.

Found the registrable domain and want to know if it’s free? The betadomain domain search checks it instantly. Among the 490 extensions there is .co at €34.99 a year, and the extension overview lists the multi-part suffixes on offer.

Step by step

  1. Download the listFetch public_suffix_list.dat from publicsuffix.org, store it locally and refresh it weekly from cron.
  2. Parse the rulesSkip comments and blank lines, store normal rules, wildcards and exceptions in separate arrays, all as punycode.
  3. Find the longest matchWalk the hostname from the longest candidate to the shortest and take the first matching rule, exceptions first.
  4. Build the registrable domainAdd one label to the matched suffix. If the host is itself a suffix, there is no registrable domain.

Found your domain? You can check and register it directly at betadomain – yearly price from €2.99.

FAQ

Why not just take the last two labels?

Because many registries sell names at the third level, such as example.co.uk or example.com.au. The last two labels there are co.uk, a suffix rather than a domain. Without the list, every customer under such a suffix collapses into one domain.

What's the difference between the ICANN and private sections?

The ICANN section holds suffixes run by registries, like co.uk. The private section holds suffixes where companies hand out subdomains, like github.io. Both matter for cookies; for what someone bought from a registrar, only the ICANN section does.

How often should I update the list?

It changes continuously as new suffixes are added. A weekly cron job is a sensible cadence. Keep a local copy and never download it on every request.

Which version of php-domain-parser should I use?

At the time of writing the 6.x series, latest 6.4.0. Its API differs substantially from version 5, so many older tutorials no longer work. Check Packagist for the current release before you install.