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):
- It accepts one-letter top-level domains (
jane@example.c) and IP addresses in brackets (jane@[192.0.2.10]). - It rejects internationalized domains in Unicode (
jane@bücher.example) unless you convert them to punycode first. - With the flag
FILTER_FLAG_EMAIL_UNICODE, it also accepts non-ASCII local parts such asjosé@example.com, which only reach mail servers that support SMTPUTF8.
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.
| Address | Should pass? | filter_var | Regex | normalizeEmail + regex | Laravel email | Laravel email:strict,filter |
|---|---|---|---|---|---|---|
jane@example.com | Yes | ✓ | ✓ | ✓ | ✓ | ✓ |
jane.doe+news@ | Yes | ✓ | ✓ | ✓ | ✓ | ✓ |
o'brien@ | Yes | ✓ | ✓ | ✓ | ✓ | ✓ |
jane@ | Yes | ✓ | ✓ | ✓ | ✓ | ✓ |
jane@ | After IDN conversion | ✗ | ✗ | ✓ | ✓ | ✗ |
jane | No | ✗ | ✗ | ✗ | ✗ | ✗ |
jane..doe@ | No | ✗ | ✗ | ✗ | ✗ | ✗ |
jane@example | No | ✗ | ✗ | ✗ | ✓ | ✗ |
jane@example.c | No | ✓ | ✗ | ✗ | ✓ | ✓ |
jane@-example.com | No | ✗ | ✗ | ✗ | ✗ | ✗ |
jane@ | No | ✗ | ✗ | ✗ | ✗ | ✗ |
jane doe@example.com | No | ✗ | ✗ | ✗ | ✗ | ✗ |
"jane doe"@example.com | No (RFC-valid) | ✗ | ✗ | ✗ | ✓ | ✗ |
jane@[192 | No (RFC-valid) | ✓ | ✗ | ✗ | ✓ | ✗ |
josé@example.com | No (needs SMTPUTF8) | ✗ | ✗ | ✗ | ✓ | ✗ |
aaa…a@ (65 a’s) | No | ✗ | ✗ | ✗ | ✓ | ✗ |
jane@example.com with spaces around it | After trimming | ✗ | ✗ | ✓ | ✗ | ✗ |
jane@example.com + line break | No | ✗ | ✗ | ✓ (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:
| Domain | DNS | checkdnsrr($d, 'MX') | acceptsMail |
|---|---|---|---|
gmail.com | Five MX records | true | true |
example.com | Null MX | true | false |
neverssl.com | A record, no MX | false | true |
| A domain that doesn’t exist | NXDOMAIN | false | false |
dnssec-failed.org | SERVFAIL | false | false |
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 key comes from an environment variable and goes in the
apikeyheader, so it stays out of your repository, your URLs and your logs. http_build_queryencodes the+injane+news@example.orgas%2B; with plain string concatenation the plus would arrive as a space.- Timeouts: the API’s
timeoutparameter (3 to 60 seconds, default 30) caps how long it waits for a slow mail server, andCURLOPT_TIMEOUTgives the request two seconds more, as the documentation recommends. - Errors: a wrong key (
401), a used-up quota or too many parallel checks (429) and a timeout all returnnulland leave a line in the error log. In our test the timeout case gave up after 10.0 seconds. - No
curl_close(): it has done nothing since PHP 8.0 and is deprecated in PHP 8.5.
The response has 21 fields. Decide on status:
status | Example sub_status | recommended_action | What to do |
|---|---|---|---|
valid | null, role_based, possible_typo | send, send_ | Accept; show did_you_mean if it isn’t empty. |
invalid | mailbox_, no_dns_entries | suppress | Reject and ask the user to check the address. |
catch_all | accept_all | send_ | Accept. The domain takes every address, so the mailbox can’t be confirmed. |
risky | mailbox_full | retry_later | Accept; the mailbox is full right now. |
do_not_mail | disposable | suppress | Ask for a permanent address. |
unknown | timeout_exceeded, greylisted | retry_later | Accept. 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 email | Response |
|---|---|
anna@example.org | 201 |
anna@example.org with spaces around it | 201 |
jane@ | 201, sent to the API as jane@ |
jane@example | 422, “Please enter a valid email address.” |
An address the API reports as invalid | 422, “This email address doesn’t exist. Please check it.” |
An address the API reports as do_not_mail | 422, “Please use a permanent email address.” |
| An address on a catch-all domain | 201 |
No email field, or email[]=… | 422 |
| Any address while the API answers 429 | 201, 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:
| Rule | What it adds |
|---|---|
email or email:rfc | RFC validation (egulias/email-validator). The default. |
email:strict | RFC 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:filter | PHP’s filter_var, as in step 1. |
email:dns | Checks the domain’s MX records; it rejected example.com (null MX) and accepted neverssl.com (A record only). |
email:spoof | Rejects 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.
Recommended setup
- On input:
normalizeEmail, thenisValidEmail, or in Laravelemail:strict,filter. - Optionally a DNS check with
acceptsMailoremail:dnsto stop dead domains without an API call. - On submit: the mailbox check. Block
invalidanddo_not_mail, accept everything else, and accept the address when the API can’t be reached. - For an existing list, upload it to the bulk email verifier or the bulk endpoint instead of calling
/v1/infoin 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.