Email verification in PHP

PHP already has filter_var($email, FILTER_VALIDATE_EMAIL), and half the forms on the web stop there. That function checks the shape of a string. It cannot tell you whether anyone is behind the address, which is the only thing that decides whether your mail bounces.

Guzzle is the client below. It is in almost every modern PHP project already, its middleware stack handles retries cleanly, and its exceptions map onto the API's error codes without ceremony.

Guzzle 7 with a retry middleware. http_errors left on, so a 402 throws rather than sliding through as an array with no result key.

Install

Shell
composer require guzzlehttp/guzzle

Verify one address

You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.

PHP
<?php

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;

$client = new Client([
    'base_uri' => 'https://api.zapbounce.com/v1/',
    'headers'  => ['Authorization' => 'Bearer ' . 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' => ['email' => $email]]);
    return json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
}

$result = verify($client, 'ada@example.com');

match ($result['result']) {
    'valid'     => $mailer->send($result['email']),
    'catch_all' => $segments->add('accepts-everything', $result['email']),
    'unknown'   => $queue->recheckLater($result['email']),   // never billed
    default     => $suppression->add($result['email']),
};

Verify a list

Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

PHP
<?php

function submitBatch(Client $client, array $emails, string $name): string
{
    $response = $client->post('batches', [
        'json'    => ['name' => $name, 'emails' => array_values($emails)],
        'headers' => ['Idempotency-Key' => bin2hex(random_bytes(16))],
    ]);

    return json_decode((string) $response->getBody(), true)['batch_id'];
}

/** Generator over the cursor: a million rows never land in one array. */
function batchRows(Client $client, string $batchId): \Generator
{
    $cursor = null;

    do {
        $query = ['limit' => 1000] + ($cursor ? ['cursor' => $cursor] : []);
        $page  = json_decode((string) $client
            ->get("batches/{$batchId}/results", ['query' => $query])
            ->getBody(), true);

        yield from $page['data'];
        $cursor = $page['has_more'] ? $page['next_cursor'] : null;
    } while ($cursor !== null);
}

foreach (batchRows($client, $batchId) as $row) {
    $contacts->setVerdict($row['email'], $row['result'], $row['billed']);
}

Errors and retries

A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.

PHP
<?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;
    },
    fn (int $retries): int => (int) ((2 ** $retries) * 500 * (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)['error'];

    if ($error['code'] === 'insufficient_credits') {
        throw new OutOfCredits($error['message']);   // retrying changes nothing
    }

    // request_id turns a support thread into a two-minute lookup. Always log it.
    $logger->warning('verify failed', ['code' => $error['code'], 'request_id' => $error['request_id']]);
    throw $e;
}

PHP: common questions

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 that cost nothing.