Email verification in Laravel

Laravel gives you three places this belongs, and picking the wrong one is the usual mistake. A validation rule for the signup form, a queued job for anything list-shaped, and a polling job that collects the results.

What does not belong anywhere: a synchronous verification inside a controller handling a list. Http::pool looks tempting and will hold a PHP-FPM worker while a mail server thinks about it.

Laravel's Http facade, which wraps Guzzle and brings retry() and timeout() as first-class calls.

Verify 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
<?php
// app/Rules/DeliverableEmail.php
namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

class DeliverableEmail implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        try {
            $response = Http::withHeaders(['X-API-Key' => config('services.zapbounce.key')])
                ->timeout(3)                    // a person is waiting on this form
                ->retry(1, 200, throw: false)
                ->post('https://app.zapbounce.com/api/v1/verify', ['emails' => [$value]]);
        } catch (ConnectionException) {
            return;   // fail open on a timeout. A slow verifier must not cost you a registration.
        }

        if ($response->failed()) {
            return;   // fail open here too
        }

        // Only block on a definite rejection. catch_all and unknown are not rejections.
        // disposable is an entry in the flags array beside the status, so it is checked separately.
        $result = $response->json('results.0');
        if ($result['status'] === 'invalid' || in_array('disposable', $result['flags'], true)) {
            $fail('That address will not receive mail. Check it for a typo.');
        }
    }
}

// app/Http/Requests/RegisterRequest.php
public function rules(): array
{
    return ['email' => ['required', 'email:rfc,dns', new DeliverableEmail]];
}

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
<?php
// app/Jobs/VerifyContactList.php
namespace App\Jobs;

class VerifyContactList implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public array $backoff = [30, 120, 600];

    public function __construct(public int $listId) {}

    public function handle(): void
    {
        $emails = Contact::where('list_id', $this->listId)
            ->whereNull('email_result')
            ->pluck('email')
            ->all();

        $job = Http::withHeaders([
                'X-API-Key'       => config('services.zapbounce.key'),
                'Idempotency-Key' => "list-{$this->listId}",
            ])
            ->post('https://app.zapbounce.com/api/v1/jobs', [
                'emails' => $emails,        // up to 50,000 per job
                'source' => 'api',
            ])
            ->throw()
            ->json();

        ContactList::whereKey($this->listId)->update(['job_id' => $job['id']]);

        // No webhook exists. A second queued job polls and releases itself until done.
        CollectJobResults::dispatch($this->listId, $job['id'])->delay(30);
    }
}

// app/Jobs/CollectJobResults.php
class CollectJobResults implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $listId, public string $jobId) {}

    public function handle(): void
    {
        $http = Http::withHeaders(['X-API-Key' => config('services.zapbounce.key')])
            ->baseUrl('https://app.zapbounce.com/api/v1');

        $job = $http->get("jobs/{$this->jobId}")->throw()->json();

        if (! in_array($job['status'], ['completed', 'partial', 'failed', 'cancelled'], true)) {
            $this->release(30);   // back on the queue, try again in 30 seconds
            return;
        }

        $page = 1;
        do {
            $body = $http->get("jobs/{$this->jobId}/emails", ['page' => $page, 'pageSize' => 100])
                ->throw()->json();

            foreach ($body['items'] as $row) {
                Contact::where('email', $row['email'])->update(['email_result' => $row['status']]);
            }
        } while ($page++ < $body['meta']['totalPages']);
    }
}

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
<?php
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;

function zapbounceVerify(string $email): array
{
    try {
        $response = Http::withHeaders(['X-API-Key' => config('services.zapbounce.key')])
            ->timeout(10)
            ->retry(
                5,
                // There is no Retry-After header. A 429 waits on X-RateLimit-Window-Seconds.
                fn (int $attempt, \Exception $e) => $e instanceof RequestException && $e->response->status() === 429
                    ? max(1, (int) $e->response->header('X-RateLimit-Window-Seconds')) * 1000 * $attempt
                    : (2 ** $attempt) * 250,
                // Retry 429 and 5xx. Never retry CREDITS_INSUFFICIENT (422): it fails the same way forever.
                fn (\Exception $e) => $e instanceof ConnectionException
                    || ($e instanceof RequestException && in_array($e->response->status(), [429, 500, 502, 503, 504])),
                throw: false,
            )
            ->post('https://app.zapbounce.com/api/v1/verify', ['emails' => [$email]]);
    } catch (ConnectionException) {
        // A timeout is not a bad address. Fail open.
        return ['email' => $email, 'status' => 'unknown', 'reasonCode' => 'TIMEOUT', 'flags' => []];
    }

    if ($response->failed()) {
        // { statusCode, code, message, requestId }. Log requestId: it makes a support thread a quick lookup.
        Log::warning('zapbounce failed', $response->json());

        if ($response->json('code') === 'CREDITS_INSUFFICIENT') {
            throw new OutOfCredits($response->json('message'));
        }
        $response->throw();
    }

    return $response->json('results.0');
}

Laravel: common questions

Should validation block on a catch-all verdict?

No. A catch-all domain accepts every address by design and plenty of real customers sit behind one. Blocking those turns a deliverability tool into lost signups.

Where do I put the key?

In config/services.php reading from the environment, never in a committed config file. config:cache then keeps it out of every request path.

Is Http::pool a good fit for a list?

No. It holds a worker while requests run. Submit a job and poll it from a queued job.

Run this Laravel code today

100 free checks a month, no card, credits that never expire, and unknown results refunded.