Email verification in PHP
PHP can validate an email address at four depths. filter_var with FILTER_VALIDATE_EMAIL checks the syntax. getmxrr and checkdnsrr confirm the domain accepts mail. A raw SMTP conversation over fsockopen asks about the mailbox itself, and the ZapBounce API runs that conversation for you and returns a verdict.
Half the forms on the web stop at filter_var. That function checks the shape of a string, so a well-formed address with nobody behind it passes. Whether a person is behind it is what decides if your mail bounces.
Guzzle is the client for the API calls below. It's already in most modern PHP projects, its middleware handles retries cleanly, and its exceptions map onto the API's error codes.
Guzzle 7 with a retry middleware. http_errors left on, so a 422 throws rather than sliding through as an array with no status key.
What PHP can check before any API call
Three checks are built into PHP and cost nothing. filter_var catches a typo or a missing @. getmxrr and checkdnsrr confirm the domain has somewhere to deliver mail, which removes dead company domains and misspelled ones like gmial.com. Run both before you spend a credit.
Neither proves the mailbox exists. For that, PHP has to talk to the mail server: open port 25 with fsockopen, send EHLO, MAIL FROM and RCPT TO, read the three-digit reply, then QUIT before DATA so nothing is delivered. The function below does exactly that, and it's worth running once to watch the conversation.
On shared hosting and most servers, that function returns null more often than a code. Port 25 is blocked by most residential ISPs and many cloud providers, Gmail and Microsoft 365 block probes from cloud IPs, and a catch-all domain answers 250 to every name. ZapBounce runs the handshake from its own check and returns one of four results, which is where the Guzzle code below picks up.
<?php
// 1. Syntax. Built in, free, catches typos.
function looksValid(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
// 2. Does the domain accept mail? Falls back to an A record, as mail servers do.
function domainTakesMail(string $email): bool
{
$domain = substr(strrchr($email, '@'), 1);
return getmxrr($domain, $hosts) || checkdnsrr($domain, 'A');
}
// 3. The SMTP handshake by hand. QUIT before DATA, so nothing is sent.
function smtpProbe(string $email): ?int
{
$domain = substr(strrchr($email, '@'), 1);
if (!getmxrr($domain, $hosts, $weights)) {
return null;
}
array_multisort($weights, $hosts); // lowest preference first
$fp = @fsockopen($hosts[0], 25, $errno, $errstr, 10);
if ($fp === false) {
return null; // port 25 closed on this network
}
stream_set_timeout($fp, 10);
$read = function () use ($fp): int {
do {
$line = fgets($fp, 515);
} while ($line !== false && ($line[3] ?? ' ') === '-'); // multi-line replies
return (int) substr((string) $line, 0, 3);
};
$read(); // 220 greeting
fwrite($fp, "EHLO verify.example.com\r\n"); $read();
fwrite($fp, "MAIL FROM:<probe@verify.example.com>\r\n"); $read();
fwrite($fp, "RCPT TO:<{$email}>\r\n"); $code = $read();
fwrite($fp, "QUIT\r\n");
fclose($fp);
return $code; // 250 accepted, 550 no such user, 4xx try again later
}Install
composer require guzzlehttp/guzzleVerify one address
You branch on four statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
$client = new Client([
'base_uri' => 'https://app.zapbounce.com/api/v1/',
'headers' => ['X-API-Key' => getenv('ZAPBOUNCE_KEY')],
'timeout' => 10.0, // read timeout: the mail server is the slow part
'connect_timeout' => 3.0,
]);
function verify(Client $client, string $email): array
{
$response = $client->post('verify', ['json' => ['emails' => [$email]]]);
$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
return $body['results'][0];
}
$result = verify($client, 'ada@example.com');
match ($result['status']) {
'valid' => $mailer->send($result['email']),
'catch_all' => $segments->add('accepts-everything', $result['email']),
'unknown' => $queue->recheckLater($result['email']), // credit refunded
default => $suppression->add($result['email']),
};
if (in_array('disposable', $result['flags'], true)) {
$suppression->add($result['email']);
}Verify a list
Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
<?php
function submitJob(Client $client, array $emails): string
{
$response = $client->post('jobs', [
'json' => ['emails' => array_values($emails), 'source' => 'api'], // up to 50,000
'headers' => ['Idempotency-Key' => bin2hex(random_bytes(16))],
]);
return json_decode((string) $response->getBody(), true)['id'];
}
/** No webhook: poll until the job reaches a terminal status. */
function waitForJob(Client $client, string $jobId, int $everySeconds = 5): array
{
do {
$job = json_decode((string) $client->get("jobs/{$jobId}")->getBody(), true);
if (in_array($job['status'], ['completed', 'partial', 'failed', 'cancelled'], true)) {
return $job;
}
sleep($everySeconds);
} while (true);
}
/** Generator over the pages: a million rows never land in one array. */
function jobRows(Client $client, string $jobId): \Generator
{
$page = 1;
do {
$body = json_decode((string) $client
->get("jobs/{$jobId}/emails", ['query' => ['page' => $page, 'pageSize' => 100]])
->getBody(), true);
yield from $body['items'];
} while ($page++ < $body['meta']['totalPages']);
}
$jobId = submitJob($client, $emails);
waitForJob($client, $jobId);
foreach (jobRows($client, $jobId) as $row) {
$contacts->setVerdict($row['email'], $row['status']);
}Errors and retries
A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT and a 400 for a malformed address will fail the same way on every attempt, so stop.
<?php
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
$stack = HandlerStack::create();
$stack->push(Middleware::retry(
function (int $retries, RequestInterface $req, ?ResponseInterface $res): bool {
if ($retries >= 5) return false;
if ($res === null) return true; // connection failure
return $res->getStatusCode() === 429 || $res->getStatusCode() >= 500;
},
// There is no Retry-After header. Back off from X-RateLimit-Window-Seconds (60 by default).
fn (int $retries, ?ResponseInterface $res): int => (int) (
((2 ** $retries) * 500 + ($res?->getStatusCode() === 429
? 1000 * (int) ($res->getHeaderLine('X-RateLimit-Window-Seconds') ?: 1)
: 0)
) * (0.5 + mt_rand() / mt_getrandmax())
),
));
$client = new Client(['handler' => $stack, /* ...as above */]);
try {
$result = verify($client, $email);
} catch (ClientException $e) {
$error = json_decode((string) $e->getResponse()->getBody(), true); // statusCode, code, message, requestId
if ($error['code'] === 'CREDITS_INSUFFICIENT') {
throw new OutOfCredits($error['message']); // 422, retrying changes nothing
}
// requestId turns a support thread into a two-minute lookup. Always log it.
$logger->warning('verify failed', ['code' => $error['code'], 'request_id' => $error['requestId']]);
throw $e;
} catch (\GuzzleHttp\Exception\ConnectException $e) {
// A timeout is not a bad address. Fail open.
$result = ['email' => $email, 'status' => 'unknown', 'reasonCode' => 'TIMEOUT', 'flags' => []];
}PHP: common questions
How do I validate an email address in PHP?
Run filter_var with FILTER_VALIDATE_EMAIL for the syntax, getmxrr for the domain, then ask the mail server with an SMTP handshake or one POST to a verification API. The first two are built in and free. Only the handshake tells you whether the mailbox exists.
Does filter_var check if an email exists?
No, filter_var checks the format of the string and stops there. It accepts an address at a domain with no mail server, and it rejects a few rare addresses the RFC allows. Treat it as a typo filter, then verify.
Can I use cURL directly?
Yes, the API is plain HTTP. Guzzle is here because the retry middleware and the exception mapping are already written and tested.
Where does this go in Laravel?
Behind a queued job rather than in the request cycle. The Laravel page shows the job, the rule object and the Horizon configuration.
Does verification send an email?
No. The connection closes before DATA, so the address is checked and nothing is delivered.
Run this PHP code today
100 free checks a month, no card, credits that never expire, and unknown results refunded.