layershifter/tld-extract
Lightweight PHP library that extracts the top-level domain, domain name, and subdomain from URLs and email addresses using an accurate public suffix list. Simple static API returns structured results, making it easy to validate and parse domains in any Laravel or plain PHP project.
Install
composer require layershifter/tld-extract
There is no service provider, no config file and no facade — it is a plain PHP library you instantiate yourself. That is the whole setup.
First use case: split a host into its parts
use LayerShifter\TLDExtract\Extract;
$extract = new Extract();
$result = $extract->parse('https://shop.example.co.uk/products/1?ref=newsletter');
$result->getSubdomain(); // 'shop'
$result->getHostname(); // 'example'
$result->getSuffix(); // 'co.uk'
$result->getFullHost(); // 'shop.example.co.uk'
$result->getRegistrableDomain(); // 'example.co.uk'
$result->isValid(); // true
Where to look first
src/Extract.php — the parser entry point (parse(), constructor arguments for custom suffix lists).src/Result.php — the value object with the getters above.data/ — the bundled Public Suffix List snapshot. Check its date; it is frozen at the time of the last release.tests/ — the fastest source of truth for edge-case behaviour (IPs, IDNs, wildcards).Do this first in a Laravel app — bind it once so you do not reload the suffix list on every parse:
// AppServiceProvider::register()
$this->app->singleton(\LayerShifter\TLDExtract\Extract::class, fn () => new \LayerShifter\TLDExtract\Extract());
Before you build anything on top of it, be aware the repo is archived and marked DEPRECATED (last release 2019-02-11). For a greenfield project, reach for jeremykendall/php-domain-parser or league/uri instead. Everything below applies when you are maintaining a codebase that already depends on it, or when you deliberately want a tiny zero-dependency PSL splitter and accept the frozen list.
Validation rule for user-supplied URLs
The most common day-to-day use is rejecting junk before it hits the database:
use LayerShifter\TLDExtract\Extract;
use Illuminate\Contracts\Validation\Rule;
class PublicHostname implements Rule
{
public function __construct(private Extract $extract) {}
public function passes($attribute, $value): bool
{
$result = $this->extract->parse((string) $value);
return $result->isValid() && $result->getSuffix() !== null;
}
public function message(): string
{
return 'The :attribute must be a public hostname.';
}
}
Note the extra getSuffix() !== null check: isValid() is true for plenty of things you probably do not want stored as a "domain".
Registrable domain for cookie scoping and tenant detection
$host = $request->getHost(); // 'acme.app.example.co.uk'
$domain = $extract->parse($host)->getRegistrableDomain(); // 'example.co.uk'
// share a session cookie across subdomains
config(['session.domain' => '.'.$domain]);
// resolve the tenant from the subdomain
$subdomain = $extract->parse($host)->getSubdomain(); // 'acme.app'
$tenant = explode('.', $subdomain)[0];
This is the pattern that pays for itself: never hand-roll substr()/explode('.') logic for cookie domains, co.uk will break it.
Normalise and canonicalise before persisting
$result = $extract->parse($input);
$record->host = $result->getFullHost(); // lowercased, port/path stripped
$record->domain = $result->getRegistrableDomain();
$record->tld = $result->getSuffix();
Storing the three fields separately makes reporting ("group by registrable domain") trivial and avoids re-parsing in list views.
Reuse one instance in imports and jobs
Extract reads and indexes the suffix list at construction. In a queued job or an artisan import loop, construct once outside the loop — resolved from the container it is already a singleton, so inject it and you are done.
Memoise per request where it is hot
class DomainResolver
{
private array $cache = [];
public function __construct(private Extract $extract) {}
public function registrable(string $host): ?string
{
return $this->cache[$host] ??= $this->extract->parse($host)->getRegistrableDomain();
}
}
Useful when a Blade partial or an API resource serialises hundreds of rows that each carry a hostname.
Custom suffix list
The constructor accepts paths to your own suffix files, which is how you patch the frozen list without forking:
$extract = new Extract(
base_path('resources/psl/public_suffix_list.dat'),
base_path('resources/psl/registrable_domains.dat')
);
Use absolute paths. Pair this with a scheduled job that pulls the upstream PSL and rewrites the file, so a 2019 snapshot does not silently misclassify newer suffixes as part of the registrable domain.
Wrap it in your own value object
Given the package is archived, insulate your codebase:
final class Host
{
// getters you actually use: subdomain(), registrableDomain(), suffix()
}
Then a future swap to jeremykendall/php-domain-parser is a single class rewrite instead of a grep across the app.
foo.bar.newtld may come back with newtld as the hostname instead of the suffix. Test against the specific TLDs your app sees and override the data file if needed.isValid() is not "is a public domain". It is closer to "the string parsed into something hostname-shaped". Always check getSuffix() too, and decide explicitly whether IPs, localhost, and single-label hosts are acceptable in your context.null when the input cannot be parsed. Null-safe operators or explicit ?? fallbacks everywhere, or you will get null cast to '' in string columns.example.co.uk:8080 is not the same as a full URL, and inputs without a scheme can be ambiguous. Normalise inputs (trim, lowercase) before parsing rather than after.ext-intl. Punycode/Unicode conversion depends on it. Store the ASCII form as the canonical key and keep the Unicode form for display; comparing mixed forms in where() clauses will silently miss rows.Example.CO.UK. is an FQDN. The parser lowercases, but a trailing dot can leak into getFullHost() — rtrim it if you use the result as a lookup key.*.ck, !www.ck) are the classic place naive parsers fail and this one usually gets right — but only as of the snapshot date. Put these in your test fixtures.Extract per call in a loop is the one real performance trap. Register it as a singleton, inject it, never new it inside a method that runs per row.Extract. If you need behaviour changes, wrap rather than subclass — subclassing ties you harder to an abandoned package.getSuffix() first. If the suffix is wrong, the data file is wrong; if the suffix is right but the hostname is wrong, the input normalisation is wrong. Those are the only two failure modes in practice.How can I help you explore Laravel packages today?