pear/net_dns2
Net_DNS2 is a pure-PHP DNS resolver for querying A, AAAA, MX, NS, TXT, SRV, SOA and other records over UDP or TCP. It supports IPv4/IPv6, custom nameservers, caching, and detailed response objects, so PHP apps can perform DNS lookups without external extensions.
pear/net_dns2 is a PEAR-style, non-namespaced PHP DNS client. In Laravel terms it replaces bare dns_get_record() when you need control over which nameserver answers, access to record types PHP hides (SRV, CAA, NAPTR, TLSA), DNSSEC records, or RFC 2136 dynamic updates.
Install it and start with the smallest possible query:
composer require pear/net_dns2
use Net_DNS2_Resolver;
use Net_DNS2_Exception;
$resolver = new Net_DNS2_Resolver([
'nameservers' => ['1.1.1.1', '8.8.8.8'],
'timeout' => 2,
'retries' => 2,
]);
try {
$response = $resolver->query('example.com', 'MX');
} catch (Net_DNS2_Exception $e) {
report($e);
return [];
}
foreach ($response->answer as $rr) {
// $rr->exchange, $rr->preference, $rr->ttl
}
Where to look first:
Net_DNS2_Resolver::$options — nameservers, timeout, retries, dns_port, use_tcp, dnssec, cache_type, strict_query.src/Net/DNS2/RR/*.php — one class per record type (Net_DNS2_RR_MX, _TXT, _SRV, _CNAME). Field names live here; this is your real API reference.$response->header->rcode and $response->answer — the two things you check on every call.First real use case for a Laravel app: validating an email domain before you accept a signup, or before you hand an address to your mail provider.
public function domainAcceptsMail(string $domain): bool
{
$domain = idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $domain;
if ($this->lookup($domain, 'MX')) {
return true;
}
// RFC 5321 implicit MX: no MX record means fall back to A/AAAA
return (bool) ($this->lookup($domain, 'A') ?: $this->lookup($domain, 'AAAA'));
}
That single method usually pays for the dependency, because dns_get_record() on a misconfigured host returns nothing and you can't tell "no MX" from "resolver timed out."
Don't sprinkle new Net_DNS2_Resolver() through controllers. Wrap it behind your own class so tests can swap it — the underlying classes are concrete and network-bound, which makes them untestable directly.
// config/dns.php
return [
'nameservers' => array_filter(explode(',', env('DNS_NAMESERVERS', '1.1.1.1,8.8.8.8'))),
'timeout' => (int) env('DNS_TIMEOUT', 2),
'retries' => (int) env('DNS_RETRIES', 2),
'ttl_floor' => 60,
];
// app/Providers/DnsServiceProvider.php
public function register(): void
{
$this->app->singleton(DnsClient::class, fn ($app) => new DnsClient(
$app['config']->get('dns')
));
}
DnsClient holds a Net_DNS2_Resolver and normalises errors into your own exception or an empty result. Everything downstream type-hints DnsClient. In tests, bind a fake that returns canned Net_DNS2_Packet_Response objects or plain arrays.
The classic SaaS custom-domain flow: user adds a record, you poll until it appears.
public function hasTxt(string $name, string $expected): bool
{
foreach ($this->lookup($name, 'TXT') as $rr) {
// ->text is an array of 255-byte chunks; concatenate before comparing
if (hash_equals($expected, implode('', $rr->text))) {
return true;
}
}
return false;
}
Do this in a queued job with ->release(30) between attempts, not in a synchronous request. Verification flows are inherently racy with propagation.
Two options, pick one deliberately:
Built-in file cache — respects TTLs, zero code, survives requests:
'cache_type' => 'file',
'cache_file' => storage_path('framework/cache/net_dns2.cache'),
'cache_size' => 50000,
Caveat: it's a flat file with no locking guarantees under high concurrency, and it's per-host.
Laravel cache — you control the key, TTL, and can negative-cache:
public function lookup(string $name, string $type): array
{
$key = 'dns:'.strtolower($name).':'.$type;
return Cache::remember($key, now()->addSeconds(300), function () use ($name, $type) {
// return a plain serialisable array, NOT the RR objects
});
}
Never cache the raw Net_DNS2_RR_* objects — they don't serialise cleanly and will bloat Redis. Map to ['type' => 'MX', 'preference' => 10, 'exchange' => '...'] arrays at the boundary. Negative-cache empty results for 30–60s only, so a user who just added their TXT record isn't stuck for five minutes.
Any loop over more than a handful of domains should be a job. Pair it with Laravel's rate limiter so you don't get throttled by Cloudflare or Google:
public function middleware(): array
{
return [new RateLimited('dns-lookups')];
}
'dnssec' => true to request the DO bit and get RRSIG/DNSKEY/DS records back in $response->answer. Reading signatures is trivial; validating the chain requires configuring trust anchors explicitly and is not on by default. Don't tell auditors you validate DNSSEC unless you've configured anchors and checked $response->header->ad semantics yourself.SRV lookups (_sip._tcp.example.com) are the clean way to do service discovery for a multi-tenant setup; sort by priority then weight manually — the library returns them in wire order.Net_DNS2_Updater speaks RFC 2136 with TSIG. Useful if you run your own BIND/PowerDNS and want to provision records from Laravel instead of shelling out.Net_DNS2_Resolver, Net_DNS2_Exception, Net_DNS2_RR_* — no namespace. Composer loads them via classmap so autoloading works fine, but if you catch (\Exception) in a broad handler you'll swallow DNS failures silently. Catch Net_DNS2_Exception specifically and log the code.$response->header->rcode === 'NXDOMAIN' and an empty answer array. So does SERVFAIL, REFUSED, and a valid name with no records of that type. Check rcode explicitly, or you'll treat "server is broken" as "record absent." Turning on strict_query makes empty answer sections throw instead.answer ≠ domain doesn't exist. Always check rcode === 'NOERROR' before concluding a record is missing. CNAME chains also mean the answer you want may be several entries deep — iterate $response->answer looking for $rr->type === 'MX' rather than indexing [0].timeout to 5 and retries to 2. Log timeouts distinctly from NXDOMAIN — they mean different things operationally and you'll want separate alerting.nameservers is empty, the library reads /etc/resolv.conf and falls back to public resolvers. In Docker/Kubernetes this is usually 127.0.0.11 or a ClusterIP, which may behave differently than your local machine. Always set nameservers explicitly via env.idn_to_ascii() before every query; passing münchen.de yields a lookup error, not a helpful message.'use_tcp' => true once to rule truncation out.cache_type => 'file', add a scheduled task to delete the file periodically, or prefer the Laravel cache driver where expiry is managed for you.Mockery::mock('Net_DNS2_Resolver') works but is brittle (the code calls query() and reads public properties). The durable approach is your own DnsClient interface plus a fake that returns arrays; keep a single integration test tagged @group network that exercises the real resolver against example.com.How can I help you explore Laravel packages today?