Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / July 22, 2026

How to validate an email address in PHP

7 minutes read

Qualisend team
A layered diagram of a PHP email address passing through filter_var syntax validation, a checkdnsrr MX lookup, and an SMTP mailbox probe, narrowing to one deliverable verdict

Validating an email address in PHP usually starts and ends with one line: filter_var($email, FILTER_VALIDATE_EMAIL). It's built in, it's fast, and it's genuinely useful — but it answers only the first of the three questions real validation asks. Is the address shaped correctly? Can its domain receive mail? Does the mailbox actually exist? PHP's standard library answers the first two out of the box; the third is a network problem worth handing off. This guide builds PHP email validation in layers, with working code for each, and shows exactly where filter_var stops.

The short answer#

Use filter_var() with FILTER_VALIDATE_EMAIL for syntax, checkdnsrr() or getmxrr() for the MX lookup, and a verification API for the SMTP mailbox check — cheapest first, short-circuiting as soon as one is decisive. Don't try to open an SMTP session from PHP to probe mailboxes yourself: outbound port 25 is blocked on most hosts, and the answer depends on sending-IP reputation and greylisting you don't want to reimplement. Each layer rules addresses out more cheaply than the last; only the final one can rule an address in.

Layer 1: syntax with filter_var#

For PHP, filter_var is the right tool for layer one — it saves you from hand-rolling a regex, and it's better tested than anything you'd paste from Stack Overflow. FILTER_VALIDATE_EMAIL returns the address on success and false on failure, so compare strictly:

function is_valid_syntax(string $email): bool
{
    return strlen($email) <= 320
        && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
is_valid_syntax('jane@example.com'); // true
is_valid_syntax('not-an-email');     // false
is_valid_syntax('a@@b.com');         // false

The strict !== false matters. filter_var returns the filtered string on success, not true, so a loose if (filter_var(...)) works only because a non-empty string is truthy — comparing against false says what you mean and survives refactoring. The length guard is belt-and-braces: an address longer than 320 characters can't be a real one, and it's cheaper to reject early than to hand a giant string to anything downstream.

Know what this filter does and doesn't do. By default it validates against an RFC-derived grammar and rejects internationalized (Unicode) local parts — so 用户@example.com fails unless you add the FILTER_FLAG_EMAIL_UNICODE flag on PHP 7.1 and up. It is a syntax filter, full stop: it never touches DNS, never opens a socket, and has no idea whether example.com exists. Also note that FILTER_SANITIZE_EMAIL is a different beast — it strips disallowed characters and returns a mangled string rather than a yes/no answer, so don't reach for it when you mean to validate.

Why a filter_var pass isn't a green light#

A filter_var pass means the string is shaped like an email address. It says nothing about whether mail will arrive. definitely-fake@gmail.com passes. info@company-that-folded.com passes. typo@gmial.com passes. All three are undeliverable, and no amount of pattern matching — filter_var or the most elaborate regex you can find — will ever tell you so, because syntax and deliverability are different questions. One is a fact about the string; the other is a fact about the internet. This is the same trap that makes regex email validation fail, and filter_var sits on exactly the same side of the line.

If you want to see the gap for yourself, paste a syntactically perfect address into the free email checker and watch a filter_var- approved string come back undeliverable.

Layer 2: can the domain receive mail?#

This is where PHP earns its keep with no dependencies at all. A domain with no mail route can't accept mail for anyone, so this one lookup eliminates dead domains, misspelled company names, and invented TLDs. The quickest check is checkdnsrr(), which returns a boolean for whether a given record type exists:

function has_mail_route(string $domain): bool
{
    // checkdnsrr() returns true if the domain publishes at least one MX record.
    // Fall back to A for domains that accept mail on an implicit MX.
    return checkdnsrr($domain, 'MX') || checkdnsrr($domain, 'A');
}
has_mail_route('gmail.com');               // true
has_mail_route('company-that-folded.com'); // false

Split the domain off the address with strrpos so you always cut at the last @:

$domain = substr($email, strrpos($email, '@') + 1);

When you need the actual mail hosts rather than a yes/no — say, to log them or inspect priorities — reach for getmxrr(). It fills a hosts array and a matching weights array by reference and returns false when there's no MX record at all:

function mail_hosts(string $domain): array
{
    $hosts = [];
    $weights = [];
    if (!getmxrr($domain, $hosts, $weights)) {
        return [];
    }
    array_multisort($weights, $hosts); // lowest weight = highest priority
    return $hosts; // e.g. ['gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com', ...]
}

Layer 3: does the mailbox actually exist?#

Layers one and two can only rule an address out. A domain can publish perfect MX records and still have no mailbox at the address you're holding — noreply-9f2x@gmail.com is valid syntax on a domain with a live mail route, and it's still a mailbox that was never created. Confirming that a specific mailbox exists means the SMTP delivery conversation: connect to the mail host, issue RCPT TO, read the reply, and disconnect before sending anything. There's more to it — how email verification works walks the full pipeline, including catch-all domains that accept every address and defeat a naive probe.

In principle you can script this in PHP with fsockopen() and raw SMTP commands. In practice you shouldn't run it from your application server: most cloud providers block outbound port 25, the answer depends on the reputation of the IP you connect from, and receiving servers greylist and rate-limit unfamiliar senders — so a probe that works in a local test quietly fails, or gets you blocklisted, in production. This is the layer worth delegating.

Doing the full check with an API#

Qualisend's POST /verify runs the whole pipeline — syntax, DNS, and the SMTP mailbox probe — from reputation-managed infrastructure built for it, and returns a verdict. With no framework, a small cURL helper covers every call you'll make:

const QUALISEND_BASE = 'https://app.qualisend.com/api/v1';

function qualisend(string $method, string $path, ?array $body = null): array
{
    $ch = curl_init(QUALISEND_BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . getenv('QUALISEND_API_KEY'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
        CURLOPT_TIMEOUT    => 10,
    ]);

    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($raw === false || $status >= 400) {
        throw new RuntimeException("Qualisend responded {$status}");
    }
    return json_decode($raw, true);
}

function verify(string $email): array
{
    return qualisend('POST', '/verify', ['email' => $email]);
}

If you already use Guzzle, the same request is a few lines shorter and handles JSON and error status codes for you:

use GuzzleHttp\Client;

$http = new Client(['base_uri' => 'https://app.qualisend.com/api/v1/']);

$response = $http->post('verify', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('QUALISEND_API_KEY')],
    'json'    => ['email' => 'jane@example.com'],
    'timeout' => 10,
]);
$result = json_decode((string) $response->getBody(), true);

The local checks come back immediately, with the SMTP probe queued:

{
  "job_id": "6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f",
  "probe_queued": true,
  "result": {
    "email": "jane@example.com",
    "status": "deliverable",
    "score": 95,
    "sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false },
    "did_you_mean": null,
    "smtp": "pending",
    "reason": null
  }
}

For live signup validation the immediate result is usually enough to act on — reject undeliverable, offer the did_you_mean correction, flag disposable. When you need the SMTP-confirmed verdict, poll the job until the probe finishes:

function verify_and_wait(string $email, int $tries = 10, float $delay = 1.5): ?array
{
    $jobId = verify($email)['job_id'];

    for ($i = 0; $i < $tries; $i++) {
        $job = qualisend('GET', "/jobs/{$jobId}?include=results");
        if ($job['status'] === 'completed') {
            return $job['results'][0]; // ['status' => ..., 'score' => ..., 'reason' => ...]
        }
        usleep((int) ($delay * 1_000_000));
    }
    return null; // still processing — treat as unknown, retry later
}

Putting the layers together#

Cheapest first, stop as soon as you have an answer:

function validate_email(string $email): array
{
    if (!is_valid_syntax($email)) {
        return ['status' => 'undeliverable', 'reason' => 'invalid_email'];
    }

    $domain = substr($email, strrpos($email, '@') + 1);
    if (!has_mail_route($domain)) {
        return ['status' => 'undeliverable', 'reason' => 'invalid_domain'];
    }

    return verify($email)['result']; // deliverable | risky | undeliverable | unknown
}

The two local layers cost nothing and catch most junk instantly; the API layer runs only on addresses worth the network round-trip. That ordering is the whole trick — the same shape you'll find in the Node.js and Python versions of this guide, because the layering, not the language, is what makes validation work.

Frequently asked questions#

Is filter_var enough to validate an email address in PHP?#

For syntax, yes — filter_var($email, FILTER_VALIDATE_EMAIL) is the right layer- one check and a better bet than a hand-rolled regex. But it validates shape, not deliverability: it never resolves DNS or contacts a mail server, so a pass means "looks like an email," not "will deliver." Pair it with an MX lookup and an SMTP mailbox check before you trust the address.

What's the difference between checkdnsrr() and getmxrr()?#

checkdnsrr() answers a yes/no question — does the domain publish a record of a given type? — and returns a boolean, which is all you need to confirm a mail route exists. getmxrr() goes further: it fills arrays with the actual MX hostnames and their weights by reference, so use it when you want to inspect or sort the mail servers rather than just confirm they're there. Both return false when there's no matching record.

Can I verify a mailbox in PHP without an external service?#

Partly. checkdnsrr() and getmxrr() confirm the domain accepts mail, which rules out dead domains for free and needs nothing beyond the standard library. Confirming the mailbox means an SMTP conversation, which you can attempt with fsockopen() but shouldn't run from your app server — port 25 is widely blocked and the result depends on your IP's reputation. That's the layer a verification service exists to handle.

Should I run these checks at signup or when cleaning a list?#

Both, at different depths. Run syntax and the MX lookup synchronously at signup — they're fast enough to block the request and give instant feedback — and act on the immediate API verdict there too. Reserve the full SMTP-confirmed result for list cleaning and slower back-office work; the serverless signup guide shows the pattern end to end.


Ready to add the SMTP layer? The free plan includes 100 credits that run the complete pipeline, and the API reference has the full /verify and /jobs endpoints with copy-paste examples in seven languages.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started