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 webhook route for batch completion.
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 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
// app/Rules/DeliverableEmail.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;
class DeliverableEmail implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$response = Http::withToken(config('services.zapbounce.key'))
->timeout(3) // a person is waiting on this form
->retry(1, 200)
->post('https://api.zapbounce.com/v1/verify', ['email' => $value]);
if ($response->failed()) {
return; // fail open. A slow verifier must not cost you a registration.
}
// Only block on a definite rejection. catch_all and unknown are not rejections.
// disposable is a boolean flag beside the result, so it is checked separately.
if ($response->json('result') === 'invalid' || $response->json('disposable') === 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 batch 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();
$batch = Http::withToken(config('services.zapbounce.key'))
->withHeaders(['Idempotency-Key' => "list-{$this->listId}"])
->post('https://api.zapbounce.com/v1/batches', [
'name' => "list-{$this->listId}",
'emails' => $emails,
'webhook_url' => route('webhooks.zapbounce'),
])
->throw()
->json();
ContactList::whereKey($this->listId)->update(['batch_id' => $batch['batch_id']]);
// No polling loop. The webhook route below picks it up when it finishes.
}
}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
// routes/web.php — the raw body is what gets signed, so read it before Laravel parses.
Route::post('/webhooks/zapbounce', function (Request $request) {
$raw = $request->getContent();
$header = $request->header('ZapBounce-Signature', '');
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, config('services.zapbounce.secret'));
abort_unless(hash_equals($expected, $parts['v1'] ?? ''), 401);
abort_if(abs(time() - (int) $parts['t']) > 300, 401, 'stale signature');
$event = json_decode($raw, true);
// Deliveries repeat. Key on the event id and make a repeat a no-op.
if (WebhookEvent::whereKey($event['id'])->exists()) {
return response()->noContent();
}
WebhookEvent::create(['id' => $event['id'], 'type' => $event['type']]);
ImportBatchResults::dispatch($event['data']['batch_id']);
return response()->noContent(); // acknowledge fast, do the work on the queue
})->name('webhooks.zapbounce')->withoutMiddleware([VerifyCsrfToken::class]);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 batch and let the webhook tell you when it is done.
Run this Laravel code today
100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.