A DNS timeout is not an invalid email address
What Laravel's email:dns rule actually does with NXDOMAIN, Null MX, SERVFAIL and timeouts, and how to handle each one in a signup form
Laravel makes it very easy to check that an email address has a working domain behind it. You add email:rfc,dns to a rule and move on. Most of the time that is fine. The trouble is the small number of times the DNS lookup does not get an answer at all, because the rule treats "I couldn't find out" exactly like "this domain does not exist". On a signup form, that means a person with a perfectly normal address sees "The email field must be a valid email address" because a resolver had a slow second.
This is a look at what the rule does, line by line, and a pattern for treating the three different outcomes differently.
What email:dns does
The rule lives in Illuminate\Validation\Concerns\ValidatesAttributes::validateEmail() (lines 940 to 967 on the 12.x branch). It maps each option to a class from the egulias/email-validator package. The dns option becomes new DNSCheckValidation() (line 954), and all the chosen checks are combined with MultipleValidationWithAnd, so any single failure fails the whole rule.
Everything below was read from Laravel 12.x, egulias/email-validator 4.x and PHP 8.4 on Linux. Older versions may differ in the details.
The interesting part is in DNSCheckValidation (4.x branch):
isValid()takes everything after the last@. If the domain has a single label, or its last label is on the reserved list (test,example,invalid,localhost,local,intranet,internal,private,corp,home,lan), it fails straight away withLocalOrReservedDomain(lines 88 to 95). No DNS query is made. Sosomeone@shop.invalidis rejected without touching the network.checkDns()converts the domain to ASCII and then walks it from the right. Fora@mail.shop.co.ukit triesco.uk, thenshop.co.uk, thenmail.shop.co.uk, and returns true as soon as one level passes (lines 118 to 136). A side effect worth knowing:anything@made-up-name.gmail.compasses, becausegmail.comis checked first and passes.validateDnsRecords()asks for A and MX records together (DNS_A + DNS_MX, line 148). If that query errors, the result isUnableToGetDNSRecordand the check fails (lines 150 to 153). It then asks for AAAA separately and quietly ignores an error there (lines 157 to 162).If there are no A, AAAA or MX records at all, it fails with
NoDNSRecord(lines 165 to 168).Each record goes through
validateMxRecord(). Non-MX records pass, so a domain with only an A record is accepted, matching the implicit MX fallback in RFC 5321. An MX record whose target is empty or.is a Null MX, and fails withDomainAcceptsNoMail(lines 202 to 205).
The query itself goes through DNSGetRecordWrapper::getRecords(), which calls PHP's dns_get_record() with an error handler that turns any warning into an exception and then into an "error" result.
So what does dns_get_record() do in each case? In php-src (ext/standard/dns.c, PHP 8.4), HOST_NOT_FOUND and NO_DATA are skipped with continue, so NXDOMAIN gives an empty array. TRY_AGAIN raises the warning "A temporary server error occurred." and NO_RECOVERY raises "An unexpected server failure occurred.", and the function returns false. On Linux, where PHP asks the system resolver library, a timeout or a SERVFAIL normally comes back as TRY_AGAIN, so it lands in the first of those branches. Other platforms and resolver set-ups can behave differently, so it is worth testing yours.
Putting it together:
| What DNS said | What the rule reports | Rule result |
|---|---|---|
| NXDOMAIN | NoDNSRecord |
fails |
| No MX, but an A or AAAA record | accepted | passes |
Null MX (0 .) |
DomainAcceptsNoMail |
fails |
| SERVFAIL or timeout | UnableToGetDNSRecord |
fails |
The last row is the problem. Inside egulias the reasons are distinct, but Laravel only sees a boolean, so all three failures produce the same message.
Three different answers
They mean different things and deserve different responses.
The domain does not exist. NXDOMAIN is an authoritative answer. Nobody can receive mail there today. Very often it is a typo, gmial.com or hotmial.co.uk. Ask the person to correct it.
The domain says it takes no mail. A Null MX record, defined in RFC 7505, is the domain owner stating that no mail should be sent there. example.com publishes one. This is also an authoritative answer, and the right response is the same: ask for a different address, ideally saying why.
We couldn't find out. A timeout or SERVFAIL tells you about the path between your server and the domain's name servers, not about the address. Resolvers time out, upstream servers have bad minutes, DNSSEC misconfigurations come and go. Rejecting here turns a temporary problem on your side into a lost signup. Accept the address, mark it as unconfirmed, and check again later.
A Laravel pattern
The plain PHP part needs a lookup that tells the outcomes apart and has a timeout you control. dns_get_record() cannot report the status code, and its timeout comes from the system resolver settings (options timeout: and attempts: in /etc/resolv.conf). One way round both is a DNS over HTTPS JSON endpoint, which returns the DNS status as a number (0 for NOERROR, 2 for SERVFAIL, 3 for NXDOMAIN).
<?php
namespace App\Support;
class MailDomainCheck
{
public static function classify(string $domain, float $timeout = 3.0): string
{
$mx = self::query($domain, 'MX', $timeout);
if ($mx['status'] === null) return 'unknown'; // timeout or network error
if ($mx['status'] === 3) return 'does_not_exist'; // NXDOMAIN
if ($mx['status'] !== 0) return 'unknown'; // SERVFAIL and friends
$records = array_filter($mx['answers'], fn ($r) => $r['type'] === 15);
foreach ($records as $r) {
if (preg_match('/^0\s+\.$/', trim($r['data']))) return 'accepts_no_mail';
}
if ($records !== []) return 'ok';
$a = self::query($domain, 'A', $timeout);
if ($a['status'] === null) return 'unknown';
$aaaa = self::query($domain, 'AAAA', $timeout);
$addresses = array_filter(
array_merge($a['answers'], $aaaa['answers']),
fn ($r) => in_array($r['type'], [1, 28], true)
);
return $addresses ? 'ok' : 'does_not_exist';
}
private static function query(string $name, string $type, float $timeout): array
{
$url = 'https://dns.google/resolve?' . http_build_query(['name' => $name, 'type' => $type]);
$context = stream_context_create(['http' => [
'timeout' => $timeout,
'ignore_errors' => true,
'header' => "Accept: application/dns-json\r\n",
]]);
$body = @file_get_contents($url, false, $context);
if ($body === false) {
return ['status' => null, 'answers' => []];
}
$json = json_decode($body, true);
return ['status' => $json['Status'] ?? null, 'answers' => $json['Answer'] ?? []];
}
}
Calling MailDomainCheck::classify() from the command line against a few known domains gave:
gmail.com ok
example.com accepts_no_mail
no-such-name-7f3a9q.com does_not_exist
neverssl.com ok (no MX, has an A record)
dnssec-failed.org unknown
gmail.com, 1 ms timeout unknown
dnssec-failed.org is deliberately broken, so a validating resolver returns SERVFAIL for it, which makes it a handy test case. If your company already runs its own resolver, swap the HTTPS call for something like Net_DNS2 with a short timeout; the important part is keeping "unknown" separate.
Then a rule that only fails on the two authoritative answers:
<?php
namespace App\Rules;
use App\Support\MailDomainCheck;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;
class MailDomainAcceptsMail implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$domain = Str::lower(Str::afterLast((string) $value, '@'));
match (MailDomainCheck::classify($domain)) {
'does_not_exist' => $fail('We could not find the domain :input. Is there a typo?'),
'accepts_no_mail' => $fail('That domain does not accept email. Please use another address.'),
default => null,
};
}
}
Use it as ['required', 'email:rfc', new MailDomainAcceptsMail], leaving out dns so the two checks don't disagree. After creating the user, store whether the domain answered and queue a recheck when it didn't:
<?php
namespace App\Jobs;
use App\Models\User;
use App\Support\MailDomainCheck;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
class RecheckMailDomain implements ShouldQueue
{
use Queueable;
public $tries = 4;
public $backoff = [600, 3600, 21600];
public function __construct(public User $user) {}
public function handle(): void
{
$result = MailDomainCheck::classify(Str::afterLast($this->user->email, '@'));
if ($result === 'unknown') {
$this->release($this->backoff[$this->attempts() - 1] ?? 21600);
return;
}
$this->user->forceFill(['email_domain_status' => $result])->save();
}
}
What you do with a final does_not_exist is a product decision. Usually it is a banner asking the person to update their address, not a deleted account.
Takeaway
Treat DNS results as three answers, not two. "It doesn't exist" and "it takes no mail" are reasons to ask for a correction. "We couldn't tell" is a reason to accept the address and look again later.
I built Orisift, which returns these as separate findings (including which DNS answers it could not get) so a form can choose to continue, ask for a correction or retry. The docs are here: https://orisift.com/x?m=post&c=hashnode-laravel-dns&to=%2Fdocs
