PHP: Check MX Records with Null MX and A Fallback

Updated: 5 min read Editorial team The PHP Experts
Short answer

To check MX records in PHP, call dns_get_record($domain, DNS_MX) or getmxrr() and sort by pri, lowest first. A single record with an empty target is a null MX (RFC 7505), meaning the domain accepts no mail. With no MX at all, RFC 5321 says mail goes to the domain's A or AAAA address instead.

Nine out of ten MX checks in PHP codebases I’ve seen are a single line: checkdnsrr($domain, 'MX'). That’s right most of the time. The other cases fall into two gaps that mirror each other: domains without MX that still accept mail, and domains with an MX that explicitly refuse it.

Three ways to check an MX record in PHP

PHP gives you three functions, each telling you a bit more than the last:

FunctionReturnsTells failure apart from “no MX”?
checkdnsrr($d, 'MX')true / falseno
getmxrr($d, $hosts, $weights)true / false, arrays by referenceno
dns_get_record($d, DNS_MX)array or falseyes

checkdnsrr() is fine for a quick yes or no. Once you care where mail goes, or you don’t want a resolver outage to look like a missing MX, use dns_get_record(). Its return format is explained in PHP dns_get_record.

Null MX: domains that refuse mail on purpose

RFC 7505 defines the null MX: exactly one MX record with preference 0 and a target of ., the DNS root. It tells senders not to bother, because mail will never be accepted. example.com publishes one. Here’s what PHP returns:

array(1) {
  [0]=>
  array(6) {
    ["host"]=>
    string(11) "example.com"
    ["class"]=>
    string(2) "IN"
    ["ttl"]=>
    int(444)
    ["type"]=>
    string(2) "MX"
    ["pri"]=>
    int(0)
    ["target"]=>
    string(0) ""
  }
}

The target is an empty string, not a dot. getmxrr('example.com', $h, $w) returns true with $h = [""]. Any check that only asks “is there at least one MX?” treats example.com as a working mail domain.

Typical candidates are domains used only for a website and names registered defensively to protect a brand. Publishing a null MX for those is good practice, so expect to meet it in real signup data, not just in tests.

No MX: the implicit A-record fallback

The opposite case is in RFC 5321, section 5.1. If a domain has no MX records, the sender treats the domain itself as an implicit MX with preference 0 and delivers to its A or AAAA address. It’s old behaviour, but still valid. Whether anything listens on port 25 at that address is another matter.

In practice: don’t hard-reject a domain without MX but with an A record in a signup form. Flag it internally and let the confirmation email decide. If it doesn’t arrive, the question answers itself.

A complete mail routing check

This function covers all four outcomes and returns them as an enum. Resolver failures become exceptions, because “we don’t know” isn’t the same as “there’s nothing”.

<?php
declare(strict_types=1);

enum MailRouting: string
{
    case Mx       = 'mx';        // regular MX records
    case Implicit = 'implicit';  // no MX, but A/AAAA (RFC 5321, section 5.1)
    case NullMx   = 'null-mx';   // "MX 0 ." per RFC 7505: refuses all mail
    case None     = 'none';      // neither MX nor address
}

/** @return array{routing: MailRouting, hosts: list<string>} */
function mailRouting(string $domain): array
{
    $ascii = idn_to_ascii($domain, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
    if ($ascii === false) {
        throw new InvalidArgumentException("Invalid domain: $domain");
    }

    $mx = @dns_get_record($ascii, DNS_MX);
    if ($mx === false) {
        throw new RuntimeException("DNS failure for $ascii, result unknown");
    }

    if ($mx !== []) {
        usort($mx, fn(array $a, array $b) => $a['pri'] <=> $b['pri']);
        $hosts = array_column($mx, 'target');
        if (count($hosts) === 1 && ($hosts[0] === '' || $hosts[0] === '.')) {
            return ['routing' => MailRouting::NullMx, 'hosts' => []];
        }
        return ['routing' => MailRouting::Mx, 'hosts' => $hosts];
    }

    if (checkdnsrr($ascii, 'A') || checkdnsrr($ascii, 'AAAA')) {
        return ['routing' => MailRouting::Implicit, 'hosts' => [$ascii]];
    }
    return ['routing' => MailRouting::None, 'hosts' => []];
}

foreach (['php.net', 'example.com', 'github.com', 'gibt-es-sicher-nicht-4711.com', 'bücher.de'] as $d) {
    $r = mailRouting($d);
    printf("%-30s %-9s %s\n", $d, $r['routing']->value, implode(', ', $r['hosts']));
}

Output on PHP 8.5:

php.net                        mx        php-smtp4-ip4.php.net
example.com                    null-mx
github.com                     mx        github-com.mail.protection.outlook.com
gibt-es-sicher-nicht-4711.com  none
bücher.de                      implicit  xn--bcher-kva.de

bücher.de has an address but no MX, so it falls back. The punycode conversion at the top matters here: without it, the lookup would query a name that doesn’t exist in DNS. The punycode guide explains the flags.

Checking that MX targets resolve

An MX record is only as good as the host it points to. After a provider switch, an old record often survives with a target that no longer exists. Senders try it first, time out, then move on. Mail arrives late rather than not at all, which makes it hard to notice.

<?php
declare(strict_types=1);

/** @param list<string> $hosts @return array<string, bool> */
function resolvableTargets(array $hosts): array
{
    $result = [];
    foreach ($hosts as $host) {
        $result[$host] = checkdnsrr($host, 'A') || checkdnsrr($host, 'AAAA');
    }
    return $result;
}

print_r(resolvableTargets(mailRouting('github.com')['hosts']));

A false in that list belongs in a ticket, not in a form rejection, since the other servers may still work.

Priorities deserve a sentence too. Lower values are tried first, and equal values share load randomly. A backup MX at 20 only takes over when every server at 10 fails. Set it the wrong way round and mail takes a detour through the backup every time.

One more misconfiguration that shows up regularly: an MX pointing at an IP address rather than a hostname. RFC 5321 doesn’t allow that, and some senders refuse delivery.

Caching MX results sensibly

MX lookups in a signup form run on every submission, and dns_get_record() has no timeout of its own. Cache the routing result per domain, keyed on the punycode form. An hour is a reasonable lifetime for positive results; negative results such as none deserve a shorter one, perhaps five minutes, because a domain that was just registered may not be in DNS yet. Popular mailbox providers can go into a static allowlist so they never hit the resolver at all.

What an MX check can’t tell you

A valid MX doesn’t mean [email protected] exists. Some developers go further, open an SMTP session and try RCPT TO. I’d advise against it. Many servers accept every address, others greylist, and doing it at volume looks exactly like a spammer’s harvesting run, which gets your IP blocklisted.

For forms, syntax validation plus an MX check with null MX detection plus a confirmation email is as far as it’s worth going. The PHP email validation guide puts those pieces together. To catch MX changes after setup, add the domain to a DNS monitoring script.

Setting up a mail domain of your own? At betadomain you manage MX and every other record yourself in the portal. Check your preferred name, compare the annual prices across extensions or go straight for an .email domain at €29.99 a year.

Found your domain? You can check and register it directly at buy a domain – yearly price from €2.99.

FAQ

Should I use getmxrr() or dns_get_record()?

getmxrr() fills two arrays with hosts and weights and returns a boolean. dns_get_record() returns full records with TTL and distinguishes no result from a resolver failure. For new code, dns_get_record() is the better choice.

Does a valid MX record mean an email address exists?

No. It only means the domain can receive mail. Whether a mailbox exists is known to the mail server alone, and many servers won't reveal it to fight address harvesting. A confirmation email is the only reliable test.

What does a null MX look like in PHP?

In DNS it's written as MX 0 followed by a single dot. dns_get_record() returns one record with pri 0 and an empty target string, and getmxrr() returns true with one empty hostname. A check for a non-empty array therefore misses it.

Should I reject domains without an MX record?

Not automatically. RFC 5321 allows delivery to the A record when no MX exists. It's rare in practice, so flag it rather than block it. Reject when there's a null MX or no MX, A or AAAA at all.