bakame/laravel-domain-parser
Laravel wrapper around the PHP Domain Parser library. Easily resolve registrable domains, public suffixes, and subdomains using the Public Suffix List, with a service provider, facade, and validation rule for reliable domain handling in Laravel apps.
1. Install (Laravel 9–11, auto-discovered provider + DomainParser facade).
composer require bakame/laravel-domain-parser
php artisan vendor:publish --tag=domain-parser-config # or --provider="Bakame\LaravelDomainParser\DomainParserServiceProvider"
The published config/domain-parser.php is the first place to look: it holds the PSL source URL / local path, the cache store + TTL, and often a storage path for the downloaded rules file. Read that before writing any code — most surprises live there.
2. Seed the rules cache. The package wraps jeremykendall/php-domain-parser, which needs the Public Suffix List rules loaded (downloaded + cached). Run the package's refresh command once (confirm the exact name with php artisan list | grep -i domain):
php artisan domain-parser:update # warms the cache from the configured source
Do this in your deploy pipeline, not on the first user request.
3. First use case — get the registrable ("root") domain from a host:
use Bakame\LaravelDomainParser\Facades\DomainParser;
DomainParser::resolve('shop.mysite.co.uk')->getRegistrableDomain(); // "mysite.co.uk"
DomainParser::resolve('shop.mysite.co.uk')->getPublicSuffix(); // "co.uk"
DomainParser::resolve('shop.mysite.co.uk')->getSubdomain(); // "shop"
If you only need one-shot parsing, DomainParser::parse($host) returns a parsed domain object directly; use resolve() when you also want suffix/registrability info. Method names are stable against the underlying Pdp\ResolvedDomain, but verify against the facade in your installed version.
Mental model: this is not DNS and not a validator. It answers "what is the effective root here, per the Public Suffix List?" — e.g. foo.github.io → registrable domain foo.github.io, because github.io is itself on the PSL.
Bind it behind your own interface. The facade is convenient but couples app code to a global. Wrap it:
final class RootDomainResolver
{
public function root(string $host): ?string
{
$host = strtolower(rtrim(trim($host), '.'));
if ($host === '' || filter_var($host, FILTER_VALIDATE_IP)) {
return null; // PSL has no meaning for IPs
}
return DomainParser::resolve($host)->getRegistrableDomain()?->toString();
}
}
Inject this everywhere. It gives you one normalization point, one place to swap/cache, and a trivial fake in tests.
Multi-tenant routing. Resolve the host in middleware, look up the tenant by registrable domain, and stash it on the request. This is the canonical use: <tenant>.app.example.com → tenant app.example.com or <tenant> depending on which level you scope to. Combine with getSubdomain() to detect vanity subdomains.
Cookie / session scoping. Set cookie domain to the registrable domain so www.example.com and api.example.com share a session:
$domain = app(RootDomainResolver::class)->root($request->getHost());
config(['session.domain' => $domain ? ".{$domain}" : null]);
Email domain normalization. For signup analytics or blocklist matching, resolve the email domain's registrable domain: [email protected] → example.co.uk. Do this once at import, not on every read.
Batch / analytics jobs. Resolve in a queued job over a list of hosts and persist the root domain as a column. Do not resolve inside Eloquent accessors — it hits the rules cache per row and pollutes serialization.
Offline-safe deploys. Ship a pinned copy of the PSL. Download the .dat file at build time to storage/app/domain-parser/, point config's source at it, and refresh the cache in the deploy script. Your app then never needs egress to publicsuffix.org at runtime, and behaviour is reproducible across environments.
Testing. Fake your RootDomainResolver interface in feature tests. For unit tests of the wrapper itself, use real PSL rules — the parsing is deterministic and cheap once the rules object exists.
foo.github.io, myblog.herokuapp.com, x.s3.amazonaws.com resolve to the subdomain being the registrable domain, because the parent is a listed suffix. Don't use this to infer "one organization = one root".localhost. IP addresses have no registrable domain. resolve() may return null on getRegistrableDomain() for these, or throw depending on version — guard before calling ->toString().getSubdomain() returns null, not '', for example.com. Null-check; don't empty()-test and then assume a string.Example.COM. will bite you. Lowercase and rtrim('.') before resolving — do it in your wrapper, once.idn_to_ascii) before resolution. Decide whether you store the ASCII or Unicode form; be consistent.cache.store.domain-parser:update (daily or weekly) is enough. Don't refresh on every boot./health so you can confirm prod is on the snapshot you think it is.*.github.io page is its own root here).Pdp\Rules under the hood, so if you need finer control, resolve that contract from the container rather than the facade and hand it to your own service.How can I help you explore Laravel packages today?