How to validate email addresses in PHP

Build an email validator in PHP step by step: syntax checks, MX lookups, disposable-domain detection, typo suggestions, a JSON sign-up endpoint and a Laravel validation rule.

What we'll build

A regular expression or filter_var() can tell you an address looks right. Neither can tell you whether the domain exists, whether it accepts mail, or whether it's a throwaway inbox. In this guide we'll build a validate_email() function in PHP that checks all of those and returns one of four statuses:

StatusMeaningTypical action
validWell formed, and the domain accepts mailAccept
invalidBad syntax, no such domain, or no mail serverReject and ask the user to fix it
riskyDisposable, a likely typo, a role account, or no MX recordsYour call: block, warn, or flag
unknownDNS failed temporarilyAccept and check again later

You'll need PHP 8.1 or later with the intl extension, which most installs have. Install NetDNS2 for DNS lookups with Composer:

composer require pear/net_dns2

Want to see the end result first? The free email checker runs the same checks.

1. Check the syntax

Start with the check that needs no network. Instead of one huge regular expression, split the address at the last @ and check each part separately. The rules stay readable, and you can say exactly what's wrong:

syntax.php
<?php

// RFC 5322 "atext": the characters allowed in each dot-separated part of the local part.
const EMAIL_ATOM = '/^[a-z0-9!#$%&\'*+\/=?^_`{|}~-]+$/iD';
// A DNS label: letters, digits and hyphens, not starting or ending with a hyphen.
const DNS_LABEL = '/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/D';

/** Lower-cases a domain and converts international names to punycode, or returns null. */
function to_ascii_domain(string $domain): ?string
{
    $ascii = idn_to_ascii(rtrim(trim($domain), '.'), IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
    return $ascii === false ? null : strtolower($ascii);
}

function check_syntax(string $input): array
{
    $email = trim($input);
    if (strlen($email) > 254) {
        return ['ok' => false, 'reason' => 'too_long'];
    }

    $at = strrpos($email, '@');
    if ($at === false || $at === 0 || $at === strlen($email) - 1) {
        return ['ok' => false, 'reason' => 'missing_at_or_part'];
    }

    $local = substr($email, 0, $at);
    if (strlen($local) > 64) {
        return ['ok' => false, 'reason' => 'local_part_too_long'];
    }
    // Splitting on "." also rejects leading, trailing and doubled dots (they create empty atoms).
    foreach (explode('.', $local) as $atom) {
        if (!preg_match(EMAIL_ATOM, $atom)) {
            return ['ok' => false, 'reason' => 'invalid_local_part'];
        }
    }

    $domain = to_ascii_domain(substr($email, $at + 1));
    $labels = $domain === null ? [] : explode('.', $domain);
    if (count($labels) < 2 || ctype_digit(end($labels))) {
        return ['ok' => false, 'reason' => 'invalid_domain'];
    }
    foreach ($labels as $label) {
        if (!preg_match(DNS_LABEL, $label)) {
            return ['ok' => false, 'reason' => 'invalid_domain'];
        }
    }

    return ['ok' => true, 'local' => $local, 'domain' => $domain, 'normalized' => strtolower($local) . '@' . $domain];
}

A few details matter here:

  • idn_to_ascii() from the intl extension converts international domains to punycode (münchen.de becomes xn--mnchen-3ya.de), which is the form DNS needs. It returns false for names it can't convert.
  • The patterns end with the D modifier. Without it, $ also matches just before a trailing newline, so "jane\n" would pass.
  • Splitting the local part on dots means .jane, jane. and ja..ne all produce an empty part that fails, with no extra rules.
  • The length limits come from RFC 5321: 64 bytes for the local part and 254 characters overall.

Only lower-case the local part when comparing addresses, to stop duplicate sign-ups. Store what the user typed, because a few mail servers still treat Jane and jane as different mailboxes.

Using filter_var()? filter_var($email, FILTER_VALIDATE_EMAIL) is a fine replacement for this step. Like this function, it only checks the format, so you still need the steps below.

2. Check the domain's MX records

A well-formed address is no use if its domain doesn't exist or doesn't accept mail. The domain's MX (mail exchanger) records name the servers that receive its mail.

PHP's built-in checkdnsrr() returns false whether the domain doesn't exist or DNS simply failed, and you need to treat those two cases differently. NetDNS2 throws an exception carrying the DNS response code, so each case gets the right status:

dns_check.php
<?php

require __DIR__ . '/vendor/autoload.php';

function dns_resolver(): \NetDNS2\Resolver
{
    // Uses the servers in /etc/resolv.conf, with a short timeout so a slow DNS server can't stall a sign-up.
    static $resolver = null;
    return $resolver ??= new \NetDNS2\Resolver(['timeout' => 3]);
}

function has_address(string $domain): bool
{
    foreach (['A', 'AAAA'] as $type) {
        try {
            if (count(dns_resolver()->query($domain, $type)->answer) > 0) {
                return true;
            }
        } catch (\NetDNS2\Exception) {
            // Try the next record type.
        }
    }
    return false;
}

function check_domain(string $domain): array
{
    try {
        $records = dns_resolver()->query($domain, 'MX')->answer;
    } catch (\NetDNS2\Exception $e) {
        if ($e->getCode() === \NetDNS2\ENUM\Error::DNS_NXDOMAIN->value) {
            return ['status' => 'invalid', 'reason' => 'domain_not_found'];
        }
        // SERVFAIL, timeouts and other DNS trouble are temporary. Don't blame the user.
        return ['status' => 'unknown', 'reason' => 'dns_error'];
    }

    $mx = array_values(array_filter($records, fn ($rr) => $rr instanceof \NetDNS2\RR\MX));
    // RFC 7505 "null MX": a single record pointing at "." means "this domain accepts no mail".
    if (count($mx) === 1 && in_array((string) $mx[0]->exchange, ['', '.'], true)) {
        return ['status' => 'invalid', 'reason' => 'domain_rejects_mail'];
    }
    if (count($mx) > 0) {
        usort($mx, fn ($a, $b) => $a->preference <=> $b->preference);
        return ['status' => 'ok', 'mx' => array_map(fn ($rr) => (string) $rr->exchange, $mx)];
    }

    // No MX records: mail servers fall back to the domain's own A/AAAA record ("implicit MX").
    return has_address($domain)
        ? ['status' => 'risky', 'reason' => 'no_mx_records']
        : ['status' => 'invalid', 'reason' => 'no_mail_server'];
}
ResultMeaningStatus
MX records returnedThe domain accepts mailok
One MX record with an empty name"Null MX" (RFC 7505): the domain accepts no mailinvalid
Exception with code DNS_NXDOMAINThe domain doesn't existinvalid
Empty answerThe domain exists but has no MX records, so check A/AAAArisky or invalid
Any other exceptionSERVFAIL or a timeout: DNS trouble, not the user's faultunknown

Two things are easy to get wrong. Keep the resolver's timeout short, so a slow DNS server can't hold up a sign-up. And never treat a timeout as "invalid", or you'll turn away real users whenever DNS has a bad moment.

3. Cache DNS lookups

Most of your users share a handful of domains like gmail.com and outlook.com. A static array avoids repeat lookups within one request:

dns_cache.php
<?php

require_once __DIR__ . '/dns_check.php';

function check_domain_cached(string $domain): array
{
    static $cache = [];
    $hit = $cache[$domain] ?? null;
    if ($hit && $hit['expires'] > time()) {
        return $hit['result'];
    }

    $result = check_domain($domain);
    // Don't remember temporary failures.
    if ($result['status'] !== 'unknown') {
        $cache[$domain] = ['result' => $result, 'expires' => time() + 3600];
    }
    return $result;
}

With PHP-FPM each request starts with an empty cache, so this only helps when one request checks many addresses, such as a CSV import. To share results between requests, store them in APCu or Redis with the same one-hour expiry. Either way, don't cache failed lookups.

4. Detect disposable domains

Temporary inboxes from services like Mailinator pass every check so far: the syntax is fine and the domains have working mail servers. The only way to catch them is a list. Download the community-maintained disposable-email-domains list next to your code:

curl -fsSo disposable_email_blocklist.conf https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf
disposable.php
<?php

function is_disposable(string $domain): bool
{
    static $blocklist = null;
    // One domain per line. array_flip() turns the list into keys, so each lookup is instant.
    $blocklist ??= array_flip(file(__DIR__ . '/disposable_email_blocklist.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));

    // Check the domain and each parent, so "x.mailinator.com" matches "mailinator.com".
    $labels = explode('.', $domain);
    for ($i = 0; $i < count($labels) - 1; $i++) {
        if (isset($blocklist[implode('.', array_slice($labels, $i))])) {
            return true;
        }
    }
    return false;
}

Checking parent domains matters because many disposable services hand out addresses on random subdomains. New disposable domains appear every week, so run the download from a daily cron job.

The list also contains some typo domains, such as gmial.com and hotmial.com. Someone registered them to collect mail meant for Gmail and Hotmail users, which is why step 6 still returns a did_you_mean suggestion for a disposable address. Read more about how disposable email detection works.

5. Flag role accounts and suggest typo fixes

Two more signals are worth having. Role accounts like info@ and support@ reach a shared inbox rather than a person. And typos like gamil.com are one of the commonest reasons a sign-up email never arrives:

extras.php
<?php

const ROLE_ACCOUNTS = [
    'admin', 'billing', 'contact', 'help', 'hello', 'info', 'marketing', 'no-reply',
    'noreply', 'office', 'postmaster', 'sales', 'support', 'team', 'webmaster',
];

function is_role_account(string $local): bool
{
    // Ignore "+tag" sub-addressing: "support+billing" is still "support".
    return in_array(explode('+', strtolower($local))[0], ROLE_ACCOUNTS, true);
}

// Real providers people mistype. Include close neighbours (mail.com, ymail.com, gmx.com…)
// too, or they'd be "corrected" to the provider one letter away.
const POPULAR_DOMAINS = [
    'gmail.com', 'googlemail.com', 'yahoo.com', 'ymail.com', 'yahoo.co.uk', 'hotmail.com',
    'hotmail.co.uk', 'outlook.com', 'live.com', 'msn.com', 'icloud.com', 'me.com', 'mac.com',
    'aol.com', 'mail.com', 'gmx.com', 'gmx.de', 'proton.me', 'protonmail.com',
];

/** "gmial.com" → "gmail.com"; null when the domain is fine or nothing is close. */
function suggest_domain(string $domain): ?string
{
    if (in_array($domain, POPULAR_DOMAINS, true)) {
        return null;
    }
    $best = null;
    $bestDistance = PHP_INT_MAX;
    foreach (POPULAR_DOMAINS as $candidate) {
        // Damerau-Levenshtein would count "gmial" → "gmail" as one edit; levenshtein() counts two.
        $distance = levenshtein($domain, $candidate);
        if ($distance < $bestDistance) {
            [$best, $bestDistance] = [$candidate, $distance];
        }
    }
    return $bestDistance <= (strlen($domain) <= 6 ? 1 : 2) ? $best : null;
}

PHP's built-in levenshtein() does the work. It counts two swapped letters (gmial → gmail) as two edits, which the two-edit limit for longer domains still allows. Short domains only get one edit's leeway, because a two-letter difference on me.com is a different domain.

The list of domains matters as much as the algorithm. Real providers that sit one letter apart, like mail.com and gmail.com, must both be in it, or users of one get "corrected" to the other. Even then, some genuine domains will look like typos (mx.com is one edit from me.com), so show the result as a suggestion and let the user decide.

6. Put it together

Now run the checks from cheapest to most expensive and return a single verdict:

validate_email.php
<?php

require_once __DIR__ . '/syntax.php';
require_once __DIR__ . '/dns_cache.php';
require_once __DIR__ . '/disposable.php';
require_once __DIR__ . '/extras.php';

/**
 * Returns ['status' => ..., 'reason' => ..., 'email' => ..., 'did_you_mean' => ...]
 * where status is "valid", "invalid", "risky" or "unknown".
 */
function validate_email(string $input): array
{
    $syntax = check_syntax($input);
    if (!$syntax['ok']) {
        return ['status' => 'invalid', 'reason' => $syntax['reason'], 'email' => null, 'did_you_mean' => null];
    }

    ['local' => $local, 'domain' => $domain] = $syntax;
    $suggestion = suggest_domain($domain);
    $base = ['email' => $syntax['normalized'], 'did_you_mean' => $suggestion ? "$local@$suggestion" : null];

    $dns = check_domain_cached($domain);
    if ($dns['status'] === 'invalid' || $dns['status'] === 'unknown') {
        return $base + ['status' => $dns['status'], 'reason' => $dns['reason']];
    }

    // The domain can receive mail. Now look for risk signals, most serious first.
    if (is_disposable($domain)) {
        return $base + ['status' => 'risky', 'reason' => 'disposable'];
    }
    if ($suggestion) {
        return $base + ['status' => 'risky', 'reason' => 'possible_typo'];
    }
    if ($dns['status'] === 'risky') {
        return $base + ['status' => 'risky', 'reason' => $dns['reason']];
    }
    if (is_role_account($local)) {
        return $base + ['status' => 'risky', 'reason' => 'role_based'];
    }
    return $base + ['status' => 'valid', 'reason' => 'mail_server_found'];
}

Here's what it returns for a range of inputs:

Inputstatusreasondid_you_mean
jane@gmail.comvalidmail_server_foundnull
joe@gamil.comriskypossible_typojoe@gmail.com
test@mailinator.comriskydisposablenull
support@acme.ioriskyrole_basednull
x@no-such-domain.exampleinvaliddomain_not_foundnull
not-an-emailinvalidmissing_at_or_partnull

7. Use it in a sign-up endpoint or Laravel

A typical sign-up policy is to reject invalid and disposable addresses, accept everything else, and pass the typo suggestion back so the front end can ask "Did you mean …?". As a plain PHP endpoint that takes JSON:

signup.php
<?php

require __DIR__ . '/validate_email.php';

header('Content-Type: application/json');

$body = json_decode(file_get_contents('php://input'), true);
$result = validate_email(is_array($body) && is_string($body['email'] ?? null) ? $body['email'] : '');

if ($result['status'] === 'invalid' || $result['reason'] === 'disposable') {
    http_response_code(422);
    echo json_encode([
        'error' => $result['reason'] === 'disposable'
            ? 'Please use a permanent email address.'
            : "That email address can't receive mail.",
        'did_you_mean' => $result['did_you_mean'],
    ]);
    exit;
}

// "unknown" means DNS had a hiccup. Accept the address and check it again later.
// create_user($result['email']);
http_response_code(201);
echo json_encode(['ok' => true, 'email' => $result['email'], 'did_you_mean' => $result['did_you_mean']]);

In Laravel, the built-in email:rfc,dns rule already covers steps 1 and 2. A custom rule adds the disposable check:

app/Rules/DeliverableEmail.php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class DeliverableEmail implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $result = validate_email((string) $value);

        if ($result['status'] === 'invalid') {
            $fail("That email address can't receive mail.");
        } elseif ($result['reason'] === 'disposable') {
            $fail('Please use a permanent email address.');
        }
    }
}
use App\Rules\DeliverableEmail;

$request->validate([
    'email' => ['required', 'email:rfc,dns', new DeliverableEmail],
]);

Load validate_email.php through the autoload.files section of composer.json so the rule can call it. Adjust the policy to your product: a B2B form might also reject free providers, while a newsletter might accept role accounts.

8. Test it

The syntax check has no side effects, so it's easy to test with PHPUnit:

SyntaxTest.php
<?php

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

require_once __DIR__ . '/syntax.php';

final class SyntaxTest extends TestCase
{
    #[DataProvider('normalAddresses')]
    public function testAcceptsNormalAddresses(string $email): void
    {
        $this->assertTrue(check_syntax($email)['ok']);
    }

    #[DataProvider('malformedAddresses')]
    public function testRejectsMalformedAddresses(string $email): void
    {
        $this->assertFalse(check_syntax($email)['ok']);
    }

    public static function normalAddresses(): array
    {
        return [['jane@example.com'], ['jane.doe+news@example.co.uk'], ['user@münchen.de']];
    }

    public static function malformedAddresses(): array
    {
        return [[''], ['jane'], ['@example.com'], ['jane@'], ['a..b@example.com'], ['jane@example'], ['jane@-example.com']];
    }
}

For the DNS checks, give the resolver a local test DNS server with ['nameservers' => ['127.0.0.1'], 'dns_port' => 5353]. Then your test suite doesn't depend on the internet. That's how we tested every result on this page.

What about checking that the mailbox exists?

You could go further with fsockopen(): connect to the domain's mail server on port 25 and ask whether a specific mailbox exists with RCPT TO. We don't recommend doing that inside a request. Many hosting providers block outbound port 25, large providers often accept every recipient at that stage, and catch-all domains and greylisting make the answer inconclusive.

The email validation guide covers SMTP verification in more depth. For most sign-up forms, the checks above plus a confirmation email are the best balance of accuracy and speed.

Or use an email validation API

Everything above works, but you have to maintain it: the disposable list goes stale, the typo list needs tuning, and DNS edge cases keep turning up. The validateemail.io API runs all of these checks in one request:

api_client.php
<?php

const VALIDATEEMAIL_API = 'https://validateemail.io/v1';

function validateemail_request(string $path, ?array $body = null, int $timeout = 5): array
{
    $ch = curl_init(VALIDATEEMAIL_API . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('VALIDATEEMAIL_KEY'), 'Content-Type: application/json'],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $error = curl_error($ch);
    curl_close($ch);

    if ($response === false || $status !== 200) {
        throw new RuntimeException($error ?: "HTTP $status");
    }
    return json_decode($response, true);
}

function validate_with_api(string $email): array
{
    try {
        return validateemail_request('/validate?email=' . rawurlencode($email));
    } catch (RuntimeException $e) {
        // Never block a sign-up because the validator is slow or unreachable.
        error_log('Email validation unavailable: ' . $e->getMessage());
        return ['status' => 'unknown', 'reason' => 'validator_unavailable'];
    }
}

/** Validates any number of addresses, 100 per request. */
function validate_list(array $emails): array
{
    $results = [];
    foreach (array_chunk($emails, 100) as $chunk) {
        $results = array_merge($results, validateemail_request('/validate/batch', ['emails' => $chunk], 30)['results']);
    }
    return $results;
}

If the API is slow or unreachable, validate_with_api() returns unknown instead of throwing, so a network problem never blocks a sign-up. The response has the same status, reason and did_you_mean fields used in this guide, plus a score and the result of every individual check.

Frequently asked questions

Is filter_var() enough to validate an email in PHP?

filter_var($email, FILTER_VALIDATE_EMAIL) is a reasonable format check. It can't tell you whether the domain exists or accepts mail, or whether the address is disposable or mistyped, so on the server pair it with an MX lookup and the other checks in this guide.

How do I check if an email domain exists in PHP?

Look up its MX records. checkdnsrr($domain, 'MX') gives a yes or no, but it returns false both for a domain that doesn't exist and for a DNS failure. A DNS library such as NetDNS2 reports NXDOMAIN separately, so you can reject missing domains and retry temporary failures.

What does Laravel's email:dns rule check?

email:rfc,dns checks the format and that the domain has DNS records for mail. It doesn't detect disposable inboxes or typos, which is what the custom rule in this guide adds.

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

You can try by opening a socket to the domain's mail server on port 25 and sending RCPT TO. In practice port 25 is often blocked, large providers accept every recipient at that stage, and catch-all domains accept anything, so the answer is often unreliable. A confirmation email is still the only definitive test.