PHP Email Verification: filter_var, DNS, and API (2026)
The complete guide to verifying email addresses in PHP. Covers filter_var pitfalls, DNS/MX checks, and Truelist API integration with working code.
TL;DR: Use filter_var($email, FILTER_VALIDATE_EMAIL) for syntax. Use checkdnsrr($domain, "MX") for domain reality. Use a verification API for deliverability. Don't roll your own SMTP probe in PHP.
PHP gives you two built-in tools for email checks: filter_var for syntax, and checkdnsrr for DNS lookups. Together they catch typos and dead domains. But they can’t tell you whether the mailbox actually exists — for that you need a verification API.
This guide walks each layer with code that runs on PHP 8.1+.
Layer 1: filter_var syntax check
function isValidEmailSyntax(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
isValidEmailSyntax('bob@example.com'); // true
isValidEmailSyntax('bob@gmail'); // false — no TLD
isValidEmailSyntax('bob @example.com'); // false — whitespace FILTER_VALIDATE_EMAIL follows RFC 822 (an older standard than 5322), which is permissive enough to accept the addresses you actually want and reject obvious typos.
Known limitations of filter_var
A few things to know:
- Allows quoted local parts —
"weird local"@example.compasses, even though most providers reject it - Does NOT verify the domain exists —
bob@asdfqwerty.zzpasses filter_var fine - Does NOT handle IDN punycode — if you need to support international users, normalize with
idn_to_ascii($email, 0, INTL_IDNA_VARIANT_UTS46)before validating - Does NOT normalize whitespace — always
trim()first
A typical wrapper:
function normalizeAndValidate(string $email): ?string
{
$email = trim($email);
if ($email === '') return null;
// Punycode for IDN domains
[$local, $domain] = explode('@', $email, 2) + [null, null];
if ($domain === null) return null;
$asciiDomain = idn_to_ascii($domain, 0, INTL_IDNA_VARIANT_UTS46);
$normalized = $local . '@' . $asciiDomain;
return filter_var($normalized, FILTER_VALIDATE_EMAIL) ?: null;
} Returns the normalized address on success, null on failure.
Layer 2: DNS / MX record check
A syntactically valid address can still belong to a domain that doesn’t exist. PHP has checkdnsrr for this:
function domainHasMx(string $domain): bool
{
return checkdnsrr($domain, 'MX');
}
domainHasMx('gmail.com'); // true
domainHasMx('not-a-real-domain.zz'); // false Combine the layers:
function isDeliverableDomain(string $email): bool
{
$normalized = normalizeAndValidate($email);
if ($normalized === null) return false;
$domain = substr(strrchr($normalized, '@'), 1);
return domainHasMx($domain);
} This catches obvious typo’d domains (bob@gmial.com → no MX) at the cost of one DNS lookup (~10-50ms in most environments).
What checkdnsrr doesn’t tell you
Same limits as every other layer-2 check:
- Domain may have MX but the specific mailbox doesn’t exist
- Domain may be a catch-all that accepts everything but delivers nothing
- Domain may be a disposable service (Mailinator, Guerrilla Mail, etc.)
- Address may be on a known spam-trap list
PHP also has fsockopen and stream_socket_client if you want to talk SMTP directly, but the same warning applies as for any other language: servers greylist you, blocklist your IP after a few probes, and the false-positive rate is brutal. Use an API instead.
Layer 3: Verification API
A verification service runs syntax, MX, SMTP probe, disposable detection, role detection, and catch-all detection in a single call. Truelist exposes this through the verify_inline endpoint.
function verifyEmail(string $email): array
{
$apiKey = getenv('TRUELIST_API_KEY');
$url = 'https://api.truelist.io/api/v1/verify_inline?email=' . urlencode($email);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 35,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $apiKey",
],
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($statusCode !== 200) {
throw new RuntimeException("Verification failed with status $statusCode");
}
$data = json_decode($response, true);
return $data['emails'][0];
} Returns an associative array with email_state, email_sub_state, and metadata:
$result = verifyEmail('bob@example.com');
if ($result['email_state'] === 'ok') {
// accept the signup
}
if ($result['email_sub_state'] === 'is_disposable') {
// reject — Mailinator, Guerrilla Mail, etc.
} See the email validation API guide for the full response schema.
Sub-200ms feedback for signup forms
The full validation including SMTP can take up to 30 seconds. For signup forms that’s unacceptable. Pass the checks parameter to skip SMTP on the synchronous call:
function verifyFast(string $email): array
{
$apiKey = getenv('TRUELIST_API_KEY');
$url = 'https://api.truelist.io/api/v1/verify_inline?'
. http_build_query([
'email' => $email,
'checks' => 'syntax,mx,disposable,role',
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $apiKey",
],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true)['emails'][0];
} This typically returns in under 200ms. Queue the full SMTP probe as a background job (via Laravel Queue, Symfony Messenger, or any worker library) and act on the result asynchronously.
Putting it together: Laravel controller example
use IlluminateHttpRequest;
use IlluminateSupportFacadesHttp;
class SignupController
{
public function store(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required|min:8',
]);
$email = normalizeAndValidate($request->input('email'));
if ($email === null) {
return back()->withErrors(['email' => 'Invalid email address.']);
}
// Fast deliverability check (sub-200ms)
$response = Http::withToken(config('services.truelist.key'))
->timeout(5)
->post('https://api.truelist.io/api/v1/verify_inline', [
'email' => $email,
'checks' => 'syntax,mx,disposable,role',
]);
// Fail open on 5xx — accept signup, queue full verification
if ($response->successful()) {
$result = $response->json('emails.0');
if ($result['email_state'] === 'email_invalid') {
return back()->withErrors([
'email' => 'That email address cannot receive mail.',
]);
}
if ($result['email_sub_state'] === 'is_disposable') {
return back()->withErrors([
'email' => 'Disposable email addresses are not supported.',
]);
}
}
$user = User::create([
'email' => $email,
'password' => bcrypt($request->input('password')),
'email_status' => 'pending_verification',
]);
VerifyEmailFullJob::dispatch($user);
return redirect()->route('dashboard');
}
} Patterns worth noting:
Http::withToken— Laravel’s HTTP client handles the bearer header cleanly. Symfony’s HTTP Client and Guzzle have equivalents.- Fail open on 5xx — if the verification service is down, accept the signup and queue the full check. Don’t block the user.
- Store the normalized email, not raw input. Reject duplicates against the normalized version.
- Queue async — never call the full-SMTP endpoint from a request handler. Background job only.
Common pitfalls
filter_varreturns the email orfalse— nottrue/false. Compare with!== false, not==.checkdnsrrdefaults toMX— but pass it explicitly to be unambiguous (checkdnsrr($domain, 'MX')).- Plus-addressing —
bob+newsletter@gmail.comis a valid Gmail address. Don’t strip the+. - PHP-FPM blocking on DNS —
checkdnsrrblocks the PHP-FPM worker. For high-traffic flows, consider async or move the check to a queue. - Mismatched IDN handling — if you accept IDN domains in display but ASCII in storage, normalize early and store one canonical form.
Symfony Validator: Constraints\Email
On Symfony 7+ (or any project using symfony/validator), use the framework’s Email constraint — it adds strictness modes and integrates with forms, API resources, and DTOs.
use SymfonyComponentValidatorConstraints as Assert;
use SymfonyComponentValidatorValidation;
$violations = Validation::createValidator()->validate('bob@example.com', [
new AssertEmail(mode: AssertEmail::VALIDATION_MODE_HTML5),
new AssertNotBlank(),
]); The mode parameter is the key choice:
VALIDATION_MODE_HTML5— matches what browsers do for<input type="email">. Recommended default for web forms.VALIDATION_MODE_HTML5_ALLOW_NO_TLD— acceptsbob@localhost. Useful for internal tools.VALIDATION_MODE_STRICT— usesegulias/email-validatorand checks RFC 5321 / 5322. Catches edge cases like consecutive dots in the local part.
For DTOs and API Platform resources, attach the constraint as an attribute on the property — #[Assert\Email(mode: Assert\Email::VALIDATION_MODE_HTML5)] — and the same declaration is validated by Symfony Forms, API Platform, and serializer-mapped payloads.
Laravel form requests and custom Rule classes
Laravel’s email validation rule supports strictness levels via colon suffixes. As of Laravel 10+:
class StoreSignupRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'string', 'email:rfc,dns,spoof', 'max:254'],
'password' => ['required', 'min:8'],
];
}
} The suffixes do real work:
rfc— usesegulias/email-validatorunder the hood (same library Symfony Strict uses).dns— callscheckdnsrrto confirm the domain has an MX record. Adds 10-50ms but catches typo domains.spoof— detects homograph attacks (Cyrillicаmasquerading as Latina).filter— falls back tofilter_var.
For deliverability against a verification API, write a custom Rule implements ValidationRule class that calls Http::withToken(...)->post(...) and uses the $fail(...) closure on email_invalid or is_disposable. Drop it into any form request — 'email' => ['required', 'email:rfc,dns', new DeliverableEmail()] — and it fires after the cheap built-ins pass, so you don’t waste an API call on syntactically broken input.
Symfony HttpClient vs Guzzle vs Laravel HTTP
Three popular HTTP clients — pick the one your framework already ships with.
Symfony HttpClient (default in Symfony 7+):
$response = $this->client->request('POST', 'https://api.truelist.io/api/v1/verify_inline', [
'query' => ['email' => $email, 'checks' => 'syntax,mx,disposable,role'],
'auth_bearer' => $this->apiKey,
'timeout' => 5,
]);
return $response->toArray()['emails'][0]; HttpClient wins on multiplexing — fire 10 requests with $client->request() and they execute in parallel over HTTP/2 when you call $response->toArray() lazily. A real win over Guzzle’s Pool API for batches.
Guzzle is fine for one-off calls — $client->post($url, ['query' => [...], 'headers' => ['Authorization' => 'Bearer ' . $apiKey]]). For batches use GuzzleHttp\Pool with concurrency capped at 5-10. Laravel HTTP wraps Guzzle (Http::withToken()->post(), shown above); use Http::pool() for parallel requests. See the email validation API guide for cross-language comparisons.
PHP 8.3 typed enums for email state
The Truelist response has a fixed set of email_state values: ok, email_invalid, risky, accept_all, unknown. Model them as a backed enum to get exhaustive match checks.
enum EmailState: string
{
case Ok = 'ok';
case Invalid = 'email_invalid';
case Risky = 'risky';
case AcceptAll = 'accept_all';
case Unknown = 'unknown';
public function shouldAccept(): bool
{
return match ($this) {
self::Ok, self::AcceptAll => true,
self::Risky, self::Unknown, self::Invalid => false,
};
}
}
$state = EmailState::from($result['email_state']); The match is exhaustive — bump the enum when Truelist adds a new state and PHP 8.3’s stricter typing flags every match that forgot to handle it. Pair the enum with a final readonly class VerificationResult value object built from a fromApi(array): self factory — the JSON-to-object boundary lives in one place.
Async batch verification with ReactPHP and Amp
For one-off signup forms the sequential curl_exec is fine. For bulk operations — verifying a 50,000-address list before a campaign — you want concurrency. PHP has two mature async runtimes: ReactPHP and Amp. Amp v3 (PHP 8.1+) is fiber-based and reads like synchronous code:
use function Ampasync;
use function AmpFutureawait;
use AmpHttpClientHttpClientBuilder;
use AmpHttpClientRequest;
$client = HttpClientBuilder::buildDefault();
$futures = array_map(
fn (string $email) => async(function () use ($client, $apiKey, $email) {
$request = new Request(
'https://api.truelist.io/api/v1/verify_inline?'
. http_build_query(['email' => $email, 'checks' => 'syntax,mx,disposable,role']),
'POST',
);
$request->setHeader('Authorization', 'Bearer ' . $apiKey);
return json_decode($client->request($request)->getBody()->buffer(), true)['emails'][0];
}),
$emails,
);
$results = await($futures); This completes 1,000 emails in 10-15 seconds instead of 5+ minutes serially. ReactPHP gets the same result with promises rather than fibers. For most teams the easier path is batch upload — send the list to a provider’s batch endpoint and poll. See the bulk email verifier guide.
FAQ
Does filter_var follow RFC 5322?
No. FILTER_VALIDATE_EMAIL follows RFC 822 (a looser, older standard) with some pragmatic exceptions. For strict RFC 5322 compliance, use Symfony’s Email constraint in STRICT mode, which delegates to egulias/email-validator. In practice, HTML5-mode validation is what you want for web signups — filter_var is close enough.
How do I check whether an email is disposable in PHP?
You can ship a static list of disposable domains, but you’ll spend your life updating it. Use a verification API — Truelist’s response includes email_sub_state: "is_disposable" for known disposable providers like Mailinator, Guerrilla Mail, and 10MinuteMail. See the email validation API guide.
Why does checkdnsrr sometimes return true for fake domains?
Two reasons. Some DNS resolvers synthesize records for non-existent domains (ISP DNS hijacking) — switch to 8.8.8.8 or 1.1.1.1 to avoid this. And checkdnsrr only confirms an MX or A record exists; it doesn’t check that a mail server actually accepts mail. For real deliverability you need an SMTP probe via an API. The MX record lookup post goes deeper.
Can I do an SMTP probe from PHP directly?
Technically yes — fsockopen and stream_socket_client will open a connection to the MX server. In practice, don’t. Major providers greylist new IPs, throttle aggressive probes, and blacklist hosts. Run probes through a service whose IPs have an established sender reputation. See why emails bounce back and email sender reputation score.
How fast should signup verification be?
Aim for under 200ms. The Truelist verify_inline endpoint with checks=syntax,mx,disposable,role typically returns in 80-150ms. Skip the SMTP check on the synchronous path, queue it as a background job (Laravel Queue, Symfony Messenger), and fail open on 5xx — accept the signup, queue the check, never block the user.
Related guides
- Email validation API and see if an email address is valid
- JavaScript email validation and Python email validation
- Email address existence checker, free email validation, bulk email verifier
- Format of email address and MX record lookup
- Why do emails bounce back?, email bounce checker, email sender reputation score
