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
// 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
// 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
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.