How to validate an email address in PHP
PHP's filter_var and checkdnsrr cover syntax and MX with zero dependencies — here's exactly where they stop and what the API layer past them actually adds.
Contents
You typed a query into a search box that reads something like "validate email in PHP," and you are hoping the answer is not a 40-character regex someone pasted from a forum in 2009. Good news: it is not. PHP ships with the right tool built in, and for the syntax half of the job you need zero dependencies. The other half — does this address point at a domain that can actually receive mail, and is it a throwaway — takes one more built-in and, if you want it, one HTTP call. Let's do all three, honestly, and be clear about where each one stops.
Use filter_var, not a regex
The single most common mistake is writing your own pattern. Don't. Email syntax is defined by RFC 5322, the grammar is genuinely awful, and PHP already implements a pragmatic version of it in C:
$email = 'you@example.com';
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
// Not a syntactically valid address. Reject it.
exit('Bad email format.');
}
FILTER_VALIDATE_EMAIL returns the address on success and false on failure, so a strict === false check is the safe read. This is the right built-in. It catches the missing @, the double dots, the trailing space, the whole class of typos that a naive regex either misses or over-rejects. If all you need is "did the user fill this field in with something shaped like an email," you are already done — ship it.
What filter_var does not tell you: whether the domain exists, whether it accepts mail, or whether you@example.com is a real inbox versus a plausible-looking string. Syntax and reachability are different questions.
Add an MX check with checkdnsrr
To find out whether the domain can receive mail at all, pull the domain off the address and ask DNS whether it publishes MX records:
$domain = substr(strrchr($email, '@'), 1);
if (checkdnsrr($domain, 'MX') === false) {
// Domain publishes no MX records — it can't receive mail.
exit('That domain does not accept email.');
}
checkdnsrr($domain, 'MX') returns true if any MX record exists and false otherwise. It performs a live DNS lookup, so it costs a network round trip — do it after the syntax check has already thrown out the garbage, and expect it to occasionally be slow or to time out on a flaky resolver. If you want the actual mail hosts rather than a boolean, getmxrr($domain, $hosts) fills an array by reference. Either way, you now know two real things: the address is well-formed, and its domain can, in principle, take mail.
Between filter_var and checkdnsrr you have covered syntax and MX with nothing but the standard library. For a contact form, that is a completely reasonable place to stop.
Where the built-ins stop
Here is what neither built-in can tell you. Is user@mailinator.com a disposable address that will evaporate in an hour? Is info@ a role account that goes to a shared mailbox nobody reads? Those judgments need a maintained list of throwaway domains and a list of role prefixes — data that goes stale and is not shipping inside PHP. You can hand-roll and hand-maintain both. Most people would rather not.
There is also the question of a live mailbox. Neither filter_var nor checkdnsrr opens an SMTP conversation to confirm the specific inbox exists, and honestly, you should be wary of anything that claims to — mailbox-level SMTP probing is unreliable, rate-limited, and often blocked outright.
The layer past: disposable and role, one contract
This is where an API earns its place — not by doing anything magic, but by folding the disposable list, the role list, and a live MX check into one response you get the same way from every language. Here is the call in plain PHP with cURL:
$ch = curl_init('https://api.boundstone.io/v1/verify/email');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer bs_live_YOUR_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['email' => $email]),
]);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
The response gives you valid_syntax, domain, mx_found, disposable, role_account, free_provider, and a checks object. So you can act on the parts the built-ins can't reach:
if ($result['disposable']) {
exit('Disposable address — please use a permanent inbox.');
}
if ($result['role_account']) {
// Warn, don't block: role accounts are real, just shared.
$warn = 'That looks like a shared inbox (info@, sales@).';
}
One note so nothing surprises you: free_provider is returned as a flag — true for a gmail.com or outlook.com address — but it is not one of the checks the API counts as performed. It is context, not a verdict. Blocking free providers is almost always the wrong call.
Read the checks arrays before you trust valid
The reason to trust a "valid" from this API is that it tells you exactly what "valid" covered. For a well-formed address, the response reports:
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
Read not_performed as a feature, not fine print. smtp_mailbox and catch_all are listed because the API did not open an SMTP session to confirm the specific inbox exists, and did not detect whether the domain accepts every address (catch-all). Boundstone does not do those today, and says so in the response rather than implying a green checkmark means "this human reads mail here." A "valid" you can audit beats a "valid" you have to take on faith.
The short version
- Validating email syntax in PHP? Use
filter_var($email, FILTER_VALIDATE_EMAIL). Never a regex. - Want to know the domain can receive mail? Add
checkdnsrr($domain, 'MX'). Two built-ins, zero dependencies — genuinely enough for many forms. - Need disposable and role detection too? That is the API's job — one contract across your stack, with a
checks.not_performedlist telling you the exact edges of the answer.
Kick the tires with no signup at the free email validator, see the same pattern in Python, or read the field-by-field contract in the docs.