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