Skip to main content

Command Palette

Search for a command to run...

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

Updated
•7 min read•View as Markdown
E
I'm building Orisift, a developer API that checks phone numbers, email domains and IP addresses against reference data, and says what it couldn't find out. I write about the small standards details that decide how forms behave.

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):

  1. 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 with LocalOrReservedDomain (lines 88 to 95). No DNS query is made. So someone@shop.invalid is rejected without touching the network.

  2. checkDns() converts the domain to ASCII and then walks it from the right. For a@mail.shop.co.uk it tries co.uk, then shop.co.uk, then mail.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.com passes, because gmail.com is checked first and passes.

  3. validateDnsRecords() asks for A and MX records together (DNS_A + DNS_MX, line 148). If that query errors, the result is UnableToGetDNSRecord and the check fails (lines 150 to 153). It then asks for AAAA separately and quietly ignores an error there (lines 157 to 162).

  4. If there are no A, AAAA or MX records at all, it fails with NoDNSRecord (lines 165 to 168).

  5. 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 with DomainAcceptsNoMail (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