Tutorial · PHP & Laravel

PHP email validation: from filter_var to a mailbox check

PHP has email validation built in, but it stops at the format. This guide goes all the way to the mailbox: filter_var and a stricter regex, a DNS check that handles null MX, an API call with cURL, and the same checks as a Laravel validation rule.

Updated October 29, 2026 · 11 min read

Validating an email address in PHP with filter_var and FILTER_VALIDATE_EMAIL

PHP email validation starts with one built-in function, filter_var($email, FILTER_VALIDATE_EMAIL), and for many projects it ends there. That function checks the format and nothing else: an address on a domain that doesn’t exist, or for a mailbox that was never created, passes. This guide adds the missing checks in the order they run, with code we ran on PHP 8.5: a stricter format check, a DNS check, a mailbox check through an API, and the same steps as a Laravel validation rule.

1. Format check with filter_var

<?php
$email = is_string($_POST['email'] ?? null) ? trim($_POST['email']) : '';

if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
    http_response_code(422);
    exit('Please enter a valid email address.');
}

Trim before you validate: in our test filter_var rejected an address with a space in front, and people paste addresses with spaces. The is_string() check keeps a request with email[]=… from turning into an array, which trim() would reject with a TypeError.

According to the PHP manual, FILTER_VALIDATE_EMAIL checks the address syntax of RFC 822, without comments, folding whitespace and dotless domain names. In practice that leaves a few gaps (full results in the table in step 2):

2. A stricter check with preg_match

For a rule you control, use the tested pattern from our email regex guide. normalizeEmail trims the input and converts an internationalized domain to punycode with idn_to_ascii() from the intl extension:

<?php // validate.php
const EMAIL_REGEX = '/\A(?=.{1,254}\z)(?=.{1,64}@)[A-Za-z0-9!#$%&\'*+\/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&\'*+\/=?^_`{|}~-]+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{2,59})\z/';

function isValidEmail(string $email): bool
{
    return preg_match(EMAIL_REGEX, $email) === 1;
}

// Trims the input and converts an internationalized domain to punycode (needs ext-intl).
function normalizeEmail(string $input): string
{
    $email = trim($input);
    $at = strrpos($email, '@');
    if ($at === false || $at === 0) {
        return $email; // isValidEmail rejects it
    }
    $domain = idn_to_ascii(substr($email, $at + 1), IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
    return $domain === false ? $email : substr($email, 0, $at + 1) . $domain;
}

The pattern is anchored with \A and \z. With ^ and $, preg_match accepts an address followed by a line break, because $ also matches before a final newline. The rules behind each part of the pattern are in the guide to the email address format.

Every address below went through each check on PHP 8.5.12 and, for the two Laravel columns, illuminate/validation 13.39. ✓ means the check accepts the address, ✗ that it rejects it.

AddressShould pass?filter_varRegexnormalizeEmail + regexLaravel emailLaravel email:strict,filter
jane@example.comYes✓✓✓✓✓
jane.doe+news@example.comYes✓✓✓✓✓
o'brien@example.ieYes✓✓✓✓✓
jane@xn--bcher-kva.exampleYes✓✓✓✓✓
jane@bücher.exampleAfter IDN conversion✗✗✓✓✗
jane.@example.comNo✗✗✗✗✗
jane..doe@example.comNo✗✗✗✗✗
jane@exampleNo✗✗✗✓✗
jane@example.cNo✓✗✗✓✓
jane@-example.comNo✗✗✗✗✗
jane@exa_mple.comNo✗✗✗✗✗
jane doe@example.comNo✗✗✗✗✗
"jane doe"@example.comNo (RFC-valid)✗✗✗✓✗
jane@[192.0.2.10]No (RFC-valid)✓✗✗✓✗
josé@example.comNo (needs SMTPUTF8)✗✗✗✓✗
aaa…a@example.com (65 a’s)No✗✗✗✓✗
jane@example.com with spaces around itAfter trimming✗✗✓✗✗
jane@example.com + line breakNo✗✗✓ (trimmed)✗✗

normalizeEmail strips the spaces and the line break, which is what you want for pasted input. Laravel’s plain email rule is the most permissive check in the table; step 6 shows which options tighten it.

3. Does the domain accept email?

The usual PHP answer is checkdnsrr($domain, 'MX'), and it has a blind spot: it only asks whether an MX record exists. A domain that receives no email can say so with a null MX, a single MX record whose target is . (RFC 7505). checkdnsrr('example.com', 'MX') returned true in our test although example.com publishes exactly that record, and getmxrr() returned it as one host with an empty name. Read the records with dns_get_record() instead:

<?php // mx.php
// true: the domain accepts mail; false: it doesn't; null: DNS gave no clear answer.
function acceptsMail(string $domain): ?bool
{
    $mx = @dns_get_record($domain, DNS_MX);
    if ($mx === false) {
        return null; // the lookup failed: unknown, don't reject
    }
    if ($mx !== []) {
        // A single MX record with an empty target is a null MX (RFC 7505): no mail.
        return !(count($mx) === 1 && $mx[0]['target'] === '');
    }
    // No MX record: senders deliver to the domain's A or AAAA record (implicit MX).
    $addresses = @dns_get_record($domain, DNS_A | DNS_AAAA);
    return $addresses === false ? null : $addresses !== [];
}

Run against public DNS:

DomainDNScheckdnsrr($d, 'MX')acceptsMail
gmail.comFive MX recordstruetrue
example.comNull MXtruefalse
neverssl.comA record, no MXfalsetrue
A domain that doesn’t existNXDOMAINfalsefalse
dnssec-failed.orgSERVFAILfalsefalse

The last row shows a limit of PHP’s DNS functions: they use the system resolver, have no timeout setting, and in our test dns_get_record() returned an empty array for a SERVFAIL just as for a domain that doesn’t exist. So false can occasionally mean “DNS failed”. Use the result to stop obvious typos in the domain, and leave the final decision to the mailbox check, which reports a dead domain as invalid with the reason invalid_mx. The MX record guide explains the records, and the MX lookup tool checks a domain by hand.

4. Check the mailbox with the API

Only the recipient’s mail server knows whether anna@example.org exists. Asking it yourself needs outbound port 25, which most cloud providers block, and catch-all domains accept every address anyway. The email validation API runs the syntax, DNS, MX and mailbox checks in one request without sending an email. With PHP’s cURL extension:

<?php // check-mailbox.php
// Returns the API's result, or null when the check couldn't be done
// (API unreachable or slow, wrong key, quota used up). Needs ext-curl.
function checkMailbox(string $email): ?array
{
    $key = getenv('EMAILVALIDATION_KEY');
    if ($key === false || $key === '') {
        throw new RuntimeException('EMAILVALIDATION_KEY is not set');
    }

    $url = 'https://api.emailvalidation.io/v1/info?' . http_build_query(['email' => $email, 'timeout' => 8]);
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 10, // the API answers within 8 seconds
        CURLOPT_HTTPHEADER => ['apikey: ' . $key],
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

    if ($body === false || $status !== 200) {
        error_log(sprintf('email check skipped: HTTP %d %s', $status, $body === false ? curl_error($ch) : $body));
        return null;
    }
    return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}

What the code takes care of:

The response has 21 fields. Decide on status:

statusExample sub_statusrecommended_actionWhat to do
validnull, role_based, possible_typosend, send_with_cautionAccept; show did_you_mean if it isn’t empty.
invalidmailbox_not_found, no_dns_entriessuppressReject and ask the user to check the address.
catch_allaccept_allsend_with_cautionAccept. The domain takes every address, so the mailbox can’t be confirmed.
riskymailbox_fullretry_laterAccept; the mailbox is full right now.
do_not_maildisposablesuppressAsk for a permanent address.
unknowntimeout_exceeded, greylistedretry_laterAccept. The mail server gave no clear answer.

The older fields state and reason carry the same verdict in three states; the result statuses page maps one to the other, and catch-all email explains why some domains can’t be verified per mailbox.

The official SDK (composer require everapi/emailvalidation-php, built on Guzzle) sends the same request: (new \Emailvalidation\EmailvalidationClient($key, ['timeout' => 10]))->validate($email). In version 1.0 it throws EmailvalidationException for network errors and timeouts, but returns the error body of a 401 or 429 as a normal array, so check that the result has a status before you use it.

5. Put it together in a sign-up script

<?php // signup.php
require __DIR__ . '/validate.php';
require __DIR__ . '/check-mailbox.php';

$email = normalizeEmail(is_string($_POST['email'] ?? null) ? $_POST['email'] : '');
if (!isValidEmail($email)) {
    http_response_code(422);
    exit('Please enter a valid email address.');
}

$result = checkMailbox($email); // null: the API couldn't answer, so accept the address
if (($result['status'] ?? null) === 'invalid') {
    http_response_code(422);
    exit("This email address doesn't exist. Please check it.");
}
if (($result['status'] ?? null) === 'do_not_mail') {
    http_response_code(422);
    exit('Please use a permanent email address.');
}

// create the account ...
http_response_code(201);
echo 'Thanks for signing up.';

We served the script with PHP’s built-in web server, pointed checkMailbox at a local mock of the API that returns the documented response format, and posted form data:

Posted emailResponse
anna@example.org201
anna@example.org with spaces around it201
jane@bücher.example.com201, sent to the API as jane@xn--bcher-kva.example.com
jane@example422, “Please enter a valid email address.”
An address the API reports as invalid422, “This email address doesn’t exist. Please check it.”
An address the API reports as do_not_mail422, “Please use a permanent email address.”
An address on a catch-all domain201
No email field, or email[]=…422
Any address while the API answers 429201, logged

The API check fails open on purpose: if the API is slow, down or out of quota, the sign-up goes through. A validation outage should never cost you a customer.

6. Laravel email validation

Laravel’s email rule takes a list of validators, all of which must pass. The ones that matter:

RuleWhat it adds
email or email:rfcRFC validation (egulias/email-validator). The default.
email:strictRFC validation without the warnings it tolerates: in our test it rejected jane@example, quoted local parts, IP addresses in brackets and 65-character local parts.
email:filterPHP’s filter_var, as in step 1.
email:dnsChecks the domain’s MX records; it rejected example.com (null MX) and accepted neverssl.com (A record only).
email:spoofRejects addresses with deceptive Unicode characters that imitate other letters (needs the intl extension).

email:strict,filter,dns combines the strict format check with a DNS check. To check the mailbox too, add a custom rule:

<?php // app/Rules/Deliverable.php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class Deliverable implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        try {
            $result = Http::withHeaders(['apikey' => config('services.emailvalidation.key')])
                ->connectTimeout(5)
                ->timeout(10)
                ->get('https://api.emailvalidation.io/v1/info', ['email' => $value, 'timeout' => 8])
                ->throw()
                ->json();
        } catch (ConnectionException|RequestException $e) {
            Log::warning('email check skipped: ' . $e->getMessage());
            return; // API down, slow or out of quota: let the address through
        }

        match ($result['status'] ?? null) {
            'invalid' => $fail("This email address doesn't exist. Please check it."),
            'do_not_mail' => $fail('Please use a permanent email address.'),
            default => null,
        };
    }
}

Put the key in .env as EMAILVALIDATION_KEY and read it in config/services.php, so config:cache keeps working:

'emailvalidation' => [
    'key' => env('EMAILVALIDATION_KEY'),
],

Then use the rule in a controller or form request. bail stops at the first failing rule, so an address that fails the format check never reaches the API:

$request->validate([
    'email' => ['bail', 'required', 'email:strict,filter', new Deliverable],
]);

We ran the rule with illuminate/validation and illuminate/http 13.39 against the mock: an existing address passed, invalid and do_not_mail addresses failed with the two messages, a catch-all address passed, jane@example failed the format check without an API request, and an API that answered 429 or took longer than 10 seconds let the address through with a warning in the log. The rule needs Laravel 10 or later, where ValidationRule was introduced.

WordPress: WooCommerce and Gravity Forms

The same API call works inside WordPress plugins. For shops, WooCommerce email verification at checkout adds it to both the classic checkout and the Checkout block; for contact and lead forms, the Gravity Forms email validation filter shows an error on the Email field before the entry is saved.

  1. On input: normalizeEmail, then isValidEmail, or in Laravel email:strict,filter.
  2. Optionally a DNS check with acceptsMail or email:dns to stop dead domains without an API call.
  3. On submit: the mailbox check. Block invalid and do_not_mail, accept everything else, and accept the address when the API can’t be reached.
  4. For an existing list, upload it to the bulk email verifier or the bulk endpoint instead of calling /v1/info in a loop: the free plan allows 10 requests a minute and 5 checks at a time, paid plans 25 at a time with no per-minute limit.

The free plan includes 100 validations a month; see email validation API pricing for more. The same checks in other languages: JavaScript email validation and Python email validation.

Sources

Checked October 29, 2026: PHP manual: validate filters, checkdnsrr, dns_get_record, idn_to_ascii, curl_close; Laravel documentation: validation, email rule, custom rules, HTTP client; RFC 7505 (null MX); emailvalidation.io API documentation: validation endpoint, rate limits. Code run with PHP 8.5.12 (cURL and intl extensions), illuminate/validation and illuminate/http 13.39, against public DNS and a local mock of the API.

Validate email in other languages

Frequently asked questions

Is filter_var FILTER_VALIDATE_EMAIL enough?

It is a solid format check, but in our test it accepted jane@example.c and jane@[192.0.2.10], and it can’t see whether the domain exists or the mailbox was ever created. Trim the input first, and for sign-ups and imports add a mailbox check, for example with an email validation API.

Why does checkdnsrr return true for a domain that doesn’t accept email?

A domain that receives no email can publish a null MX: one MX record whose target is . (RFC 7505). checkdnsrr($domain, 'MX') only asks whether an MX record exists, so it returns true for those domains, example.com included. Read the records with dns_get_record() and treat a single record with an empty target as “no mail”.

How do I validate email DNS in Laravel?

Use the rule email:rfc,dns or add dns to the validators you already list, for example email:strict,filter,dns. The dns check rejected a null MX domain in our test. It doesn’t check whether the mailbox exists; a custom rule that calls an API does.

Which Laravel email rule should I use?

email alone runs the RFC validation, which accepted jane@example, quoted local parts and IP addresses in brackets in our test. email:strict,filter rejected all three and matched our regex on every test address except jane@example.c.

Do I still need curl_close()?

No. A cURL handle has been an object that PHP frees by itself since PHP 8.0, and PHP 8.5 deprecates curl_close(). Calling it on PHP 8.5 logs a deprecation notice.

Email validation API

Validate emails in your app

emailvalidation.io checks syntax, MX records and the mailbox over SMTP, flags disposable, role and free addresses and returns a quality score, in one request.

/v1/info Email validation API Read the documentation

100 free validations every month. No credit card required.

GET https://api.emailvalidation.io/v1/info?email=support@emailvalidation.io

{
  "email": "support@emailvalidation.io",
  "user": "support",
  "tag": "",
  "domain": "emailvalidation.io",
  "format_valid": true,
  "mx_found": true,
  "smtp_check": true,
  "catch_all": null,
  "role": true,
  "disposable": false,
  "free": false,
  "score": 0.64,
  "state": "deliverable",
  "reason": "low_quality",
  "did_you_mean": "",
  "catch_all_resolution": null,
  "bounce_probability": null,
  "status": "valid",
  "sub_status": "role_based",
  "explanation": "The mailbox exists, but it is a role address (such as info@ or support@), usually read by a team rather than one person.",
  "recommended_action": "send_with_caution"
}

Keep reading

Start using our email validation software today!

Get 100 validations per month for free