All posts

How to Validate an Email Address in PHP: filter_var, DNS and SMTP

··9 min read

To validate an email address in PHP, trim the input, check syntax with filter_var($email, FILTER_VALIDATE_EMAIL) or the egulias/email-validator package, then confirm the domain accepts mail with dns_get_record(), treating a null MX as invalid. Do not use checkdnsrr() alone: it returns true for domains that publish "no mail here". Only an SMTP conversation says anything about the mailbox.

Everything below was run on PHP 8.5 with the intl extension and egulias/email-validator 4.0.4, on 6 October 2026. The outputs are what we got.

Layer 1: syntax with filter_var

filter_var is built in and reasonable, but its edges are not where most people assume.

The PHP manual says FILTER_VALIDATE_EMAIL validates against the addr-spec syntax in RFC 822, except that "comments, whitespace folding, and dotless domain names are not supported, and thus will be rejected." It also carries a warning worth taking literally: "the only true way to confirm an email is valid and exists is to send an email to the address."

Here is what it returned for our test inputs:

Input FILTER_VALIDATE_EMAIL With FILTER_FLAG_EMAIL_UNICODE
o'brien@example.ie valid valid
" jane@gmail.com " (spaces) rejected rejected
john..doe@example.com rejected rejected
jane@localhost rejected rejected
65 as + @gmail.com rejected rejected
jane@example.c valid valid
jane@[127.0.0.1] valid valid
josé@example.com rejected valid
user@münchen.de rejected rejected

Three things to take from that table.

It does not trim. Autofill and copy-paste add trailing spaces all the time, so call trim() first or you reject real customers.

It accepts IP-literal domains and one-letter TLDs. jane@[127.0.0.1] is legal RFC syntax, but you almost certainly do not want it on a signup form. The DNS layer below catches example.c; nothing catches the IP literal except a rule you add yourself.

The Unicode flag only covers the local part. The manual describes FILTER_FLAG_EMAIL_UNICODE as accepting "Unicode characters in the local part". An internationalised domain like münchen.de still fails. If you take international customers, that is a reason to use the library below instead. And do not swap in a regex: we tested five popular email regexes and none scored better than 15 of 23 cases.

Layer 1, better: egulias/email-validator

egulias/email-validator is what Laravel and Symfony use underneath, and it handles internationalised domains and null MX correctly.

composer require egulias/email-validator

Its README lists PHP 8.1 as the minimum, notes that some validations need the intl extension, and names the RFCs it targets: 5321, 5322, 6530, 6531, 6532 and 1035. The validations you will use are RFCValidation, NoRFCWarningsValidation (fails when the address is technically legal but produces warnings) and DNSCheckValidation, which the README is careful to say "does not entail that the email exists".

use Egulias\EmailValidator\EmailValidator;
use Egulias\EmailValidator\Validation\NoRFCWarningsValidation;

$validator = new EmailValidator();
$validator->isValid('john..doe@example.com', new NoRFCWarningsValidation()); // false
$validator->getError()->reason()->description(); // "Concecutive DOT found" (sic)

How the validations compared on the same inputs:

Input RFCValidation NoRFCWarningsValidation + DNSCheckValidation
user@münchen.de valid valid valid
jane@localhost valid rejected (warnings) rejected: "Local, mDNS or reserved domain"
"john doe"@example.com valid rejected (warnings) rejected
65 as + @gmail.com valid rejected valid
jane@example.com valid valid rejected: "Domain accepts no mail (Null MX, RFC7505)"
jane@nonexistent-domain-sv-test-9a8b7c.com valid valid rejected: "No MX or A DNS record was found"

Plain RFCValidation lets a 65-character local part through, even though RFC 5321 caps it at 64. For user input, NoRFCWarningsValidation is the right default.

In Laravel

Laravel's email rule is this library, and its default is the loosest style. The Laravel validation docs say the rule uses egulias/email-validator and that "by default, the RFCValidation validator is applied". The styles map directly: rfc, strict (NoRFCWarningsValidation), dns (DNSCheckValidation), spoof, filter and filter_unicode. The docs add that dns and spoof "require the PHP intl extension".

$request->validate([
    'email' => 'required|email:strict,dns',
]);

Bare 'email' is the RFC-only check from the table above, so jane@localhost passes it.

Layer 2: does the domain accept mail?

checkdnsrr($domain, 'MX') is the snippet everyone copies, and it gives the wrong answer for null-MX domains and DNS failures.

We ran it against example.com, which publishes a null MX (0 .), the RFC 7505 way of saying "this domain never receives mail". checkdnsrr returned true. It is behaving as documented: the manual says it "returns true if any records are found", and a null MX is a record. getmxrr was no better: it returned true with a single empty-string host.

The second problem is in the same sentence of the checkdnsrr manual: it returns false "if no records were found or if an error occurred". A DNS timeout and a dead domain look identical. And the getmxrr manual notes it "should not be used for the purposes of address verification", because when no MX exists the host itself should be used as the mail exchanger.

dns_get_record() fixes the first problem and partly fixes the second, because its manual says it returns an array or "false on failure":

<?php
declare(strict_types=1);

/**
 * Mail hosts for a domain, in priority order.
 * [] = domain takes no mail. null = DNS gave no usable answer (unknown).
 */
function mailHosts(string $domain): ?array
{
    $ascii = idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
    if ($ascii === false) {
        return [];
    }

    $records = @dns_get_record($ascii . '.', DNS_MX);
    if ($records === false) {
        return null;
    }

    if ($records === []) {
        $a = @dns_get_record($ascii . '.', DNS_A | DNS_AAAA);
        if ($a === false) {
            return null;
        }
        return $a === [] ? [] : [$ascii];
    }

    usort($records, fn(array $x, array $y): int => $x['pri'] <=> $y['pri']);
    if (count($records) === 1 && $records[0]['target'] === '') {
        return [];
    }
    return array_column($records, 'target');
}
Domain mailHosts()
gmail.com five hosts, gmail-smtp-in.l.google.com first
example.com [] (null MX)
nonexistent-domain-sv-test-9a8b7c.com [] (no records)
gmial.com ['gmial.com'] (no MX, A record fallback)
münchen.de ['xn--mnchen-3ya.de']

Notes on the choices:

  • idn_to_ascii needs intl. Without converting münchen.de to its punycode form, lookups for international domains are unreliable.
  • The trailing dot makes the name fully qualified, so the system resolver does not try appending local search domains.
  • There is no timeout parameter. dns_get_record uses the system resolver's settings. If a slow lookup holding a signup request open is a concern, set the timeout in the resolver configuration or use a DNS library that exposes one.
  • gmial.com passes. A domain with only an A record can legally receive mail, so it is not invalid, but on a signup form it is usually a typo of gmail.com. Flag it rather than accept it silently.

Putting syntax and DNS together

Trim, validate strictly, then look up the domain, and keep "unknown" separate from "invalid".

use Egulias\EmailValidator\EmailValidator;
use Egulias\EmailValidator\Validation\NoRFCWarningsValidation;

function checkEmail(string $raw): array
{
    $email = trim($raw);
    $validator = new EmailValidator();
    if (!$validator->isValid($email, new NoRFCWarningsValidation())) {
        return ['status' => 'invalid', 'reason' => $validator->getError()?->reason()->description() ?? 'syntax'];
    }

    $domain = substr($email, strrpos($email, '@') + 1);
    $hosts = mailHosts($domain);
    if ($hosts === null) {
        return ['status' => 'unknown', 'reason' => 'DNS lookup failed'];
    }
    if ($hosts === []) {
        return ['status' => 'invalid', 'reason' => 'domain does not accept mail'];
    }
    return ['status' => 'deliverable-domain', 'email' => $email, 'mx' => $hosts[0]];
}

Jane.Doe@gmail.com came back as deliverable-domain with the spaces removed; jane@example.com and the non-existent domain came back invalid; the 65-character local part came back invalid. The status is deliberately not called valid, because nothing has asked about the mailbox yet. On a signup form, treat unknown as "let them through and recheck later", not as a rejection.

Layer 3: asking the mail server

A PHP socket can run EHLO, MAIL FROM and RCPT TO against the MX host and quit before any message is sent.

function probeRcpt(string $mxHost, string $rcpt, string $heloName, string $mailFrom, float $timeout = 10.0): array
{
    $socket = @stream_socket_client("tcp://{$mxHost}:25", $errno, $errstr, $timeout);
    if ($socket === false) {
        return ['status' => 'unknown', 'reason' => "connect failed: {$errstr} (is outbound port 25 blocked?)"];
    }
    stream_set_timeout($socket, (int) ceil($timeout));

    try {
        $steps = [null, "EHLO {$heloName}", "MAIL FROM:<{$mailFrom}>", "RCPT TO:<{$rcpt}>"];
        foreach ($steps as $i => $command) {
            if ($command !== null) {
                fwrite($socket, $command . "\r\n");
            }
            [$code, $text] = readReply($socket);
            if ($code === 0) {
                return ['status' => 'unknown', 'reason' => 'timeout or connection closed'];
            }
            if ($i < 3 && $code >= 400) {
                return ['status' => 'unknown', 'reason' => "reply {$code} before RCPT", 'reply' => $text];
            }
        }
    } finally {
        @fwrite($socket, "QUIT\r\n");
        fclose($socket);
    }

    if ($code === 250) {
        return ['status' => 'accepted', 'reply' => $text];
    }
    if ($code >= 550 && $code <= 553 && preg_match('/^5\.1\.\d/', $text)) {
        return ['status' => 'rejected', 'reply' => $text];
    }
    return ['status' => 'unknown', 'reason' => "RCPT reply {$code}", 'reply' => $text];
}

/** Reads one (possibly multi-line) SMTP reply. Returns [0, ''] on timeout. */
function readReply($socket): array
{
    $text = '';
    while (($line = fgets($socket, 1024)) !== false) {
        $text = rtrim(substr($line, 4));
        if (strlen($line) < 4 || $line[3] !== '-') {
            return [(int) substr($line, 0, 3), $text];
        }
    }
    return [0, ''];
}

Against a made-up Gmail address, from a connection with port 25 open, it returned rejected in 0.3 seconds with the reply beginning 5.1.1 https://support.google.com/mail/?p=NoSuchUser, Gmail's 550 5.1.1 for a mailbox that does not exist. Pointed at an unroutable address with a 3-second timeout, it returned unknown after 3.0 seconds, which is what a blocked port 25 looks like on most cloud hosts.

The rules in that function matter more than the socket code:

  1. Only 5.1.x means no such mailbox. A 550 5.7.1 is a policy rejection of your IP or HELO name, not of the address.
  2. accepted is not valid. On a catch-all domain every address is accepted, so probe a random address at the same domain first.
  3. Greylisting and timeouts are unknown. A 450 or 451 is not evidence either way.

Running this at volume also needs a clean IP with reverse DNS, a real HELO name on a domain you control, and per-domain rate limits, which is why most PHP apps stop at DNS on the signup form and verify stored lists separately. For a list sitting in a database, exporting to CSV and running it through a verifier such as SimpleVerifier (CSV uploads up to 100,000 rows, results back as valid, risky and invalid) avoids running the SMTP side yourself.

What a syntax check can and cannot tell you

Syntax and DNS checks catch typos and dead domains; they cannot catch a well-formed address on a live domain whose mailbox does not exist. nobody-here-12345@gmail.com passes every check in this post up to Layer 3.

  • Trim first, then use egulias/email-validator with NoRFCWarningsValidation, or email:strict,dns in Laravel.
  • Do not trust checkdnsrr alone. Use dns_get_record and treat a single empty-target MX as invalid.
  • Keep DNS failure as unknown, separate from "domain takes no mail".
  • Count only 5.1.x as a mailbox rejection if you probe SMTP.

The same checks in other languages are in validating email in Python and validating email in JavaScript. To see what a full mailbox check returns for one address, use the free email verifier.

Common questions

Is filter_var with FILTER_VALIDATE_EMAIL good enough to validate an email in PHP?

For syntax, mostly. It rejects consecutive dots, dotless domains and local parts over 64 characters, but in our test on PHP 8.5 it also accepted jane@example.c and jane@[127.0.0.1], and it rejected internationalised domains such as münchen.de even with FILTER_FLAG_EMAIL_UNICODE. It never checks DNS or the mailbox.

Why does checkdnsrr return true for example.com?

Because example.com publishes a null MX record, which is still an MX record. checkdnsrr only reports whether any record of the type exists. A null MX, defined in RFC 7505, is the domain saying it accepts no mail, so use dns_get_record and treat a single MX with an empty target as invalid.

What does Laravel's email:rfc,dns rule check?

Laravel's email rule uses the egulias/email-validator package. rfc applies RFCValidation, dns applies DNSCheckValidation, which checks the domain can receive mail, and strict fails on RFC warnings. Laravel's docs note that dns and spoof require the PHP intl extension. None of the styles checks that the mailbox exists.

Can PHP check whether a mailbox exists without sending an email?

Yes, by opening a socket to the domain's MX on port 25 and sending EHLO, MAIL FROM and RCPT TO, then quitting. It only works from a host that allows outbound port 25, and only a 5.1.x rejection means the mailbox does not exist. Acceptance on a catch-all domain proves nothing.

Verify unlimited addresses for $29.99/month

Real SMTP mailbox checks. No credits, no per-email fees.

Get Started