Email Verification in Laravel and PHP: Validation Rules, Fast Checks and Queues

Laravel's built-in email rule is a good start. With email:rfc,dns it even checks that the domain has DNS records. What it can't do is tell you that gmial.com is a typo, that the domain is a throwaway inbox provider, or that the mailbox doesn't exist.

In this post I'll wire email verification into a Laravel app the way I'd do it in production:

  1. A custom validation rule that runs a free, sub-second fast check on signup.
  2. Fail open on timeouts and errors, so a third-party hiccup never blocks a real user.
  3. A queued job that runs the full mailbox check after the account is created.
  4. Config, env and caching so it's easy to test and cheap to run.

There's also a plain PHP version with curl at the end, for apps that aren't on Laravel.

The two checks

The MailRambo API has two modes on the same endpoint, POST https://www.mailrambo.com/v1/verify:

Fast (?mode=fast) Full (default)
Cost Free 1 credit
Typical time well under a second usually 1-5 seconds
Checks Syntax, typos, MX, disposable, role flags Everything, plus the mailbox and catch-all
deliverable false or null true or false
Use it for Validating the signup form Confirming before you send

Fast mode can prove an address is bad but never that it's good, which is exactly what a form validator needs: reject provable problems, let everything else through. The full check is too slow to sit in a request that a user is waiting on, so it goes in a queue.

Configuration

Add the key to .env:

MAILRAMBO_KEY=mr_test_xxxxxxxxxxxx

Use a test key (mr_test_...) locally and in CI. Test keys never contact mail servers and never use credits; for full checks the answer depends on the part before the @ (deliverable@example.com, disposable@example.com, catch_all@example.com, and so on). Fast mode with a test key runs the real fast checks, which are free anyway.

Then expose it through config/services.php, so you never call env() outside config files (it returns null once config is cached):

// config/services.php
'mailrambo' => [
    'key' => env('MAILRAMBO_KEY'),
    'timeout' => 3,
],

A validation rule for signup

Create the rule with php artisan make:rule VerifiedEmail, then fill it in:

<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class VerifiedEmail implements ValidationRule
{
    private const MESSAGES = [
        'invalid_syntax' => 'Please enter a valid email address.',
        'no_mx' => "That domain can't receive email. Check for typos?",
        'disposable' => 'Please use a permanent email address.',
    ];

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $email = strtolower(trim((string) $value));
        $result = Cache::remember('mr:fast:' . sha1($email), now()->addDay(), fn () => $this->check($email));

        if ($result === null || ($result['deliverable'] ?? null) !== false) {
            return; // passed, or skipped (fail open)
        }

        $reason = $result['reason'] ?? null;
        if ($reason === 'possible_typo' && ! empty($result['suggestion'])) {
            $fail("Did you mean {$result['suggestion']}?");
            return;
        }

        $fail(self::MESSAGES[$reason] ?? 'Please use a different email address.');
    }

    private function check(string $email): ?array
    {
        try {
            $res = Http::withToken(config('services.mailrambo.key'))
                ->timeout(config('services.mailrambo.timeout'))
                ->acceptJson()
                ->post('https://www.mailrambo.com/v1/verify?mode=fast', ['email' => $email]);

            if ($res->failed()) {
                Log::warning('mailrambo fast check skipped', ['status' => $res->status(), 'error' => $res->json('error')]);
                return null;
            }
            return $res->json();
        } catch (\Throwable $e) {
            Log::warning('mailrambo fast check skipped', ['error' => $e->getMessage()]);
            return null;
        }
    }
}

A few choices worth calling out:

  • Fail open. Timeouts, connection errors and non-2xx responses return null, and the rule passes. Blocking a real signup because a third party was slow is a worse outcome than letting one bad address in.
  • Don't cache failures. Cache::remember doesn't store null, so a skipped check is retried next time instead of being remembered as "fine".
  • Typos ask, they don't fix. The rule fails with "Did you mean ...?" and the user corrects the field. I wouldn't silently rewrite the address on the server: if the guess is wrong, you've created an account for someone else's inbox.

Use it next to Laravel's own rules:

$request->validate([
    'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new VerifiedEmail],
    'password' => ['required', 'confirmed', Password::defaults()],
]);

Put it last, so cheap local checks (and the unique query) run first. By default Laravel keeps running the remaining rules on an attribute after one fails; add bail at the start of the array if you'd rather skip the API call when an earlier rule already failed.

A queued full check after signup

The full check confirms the mailbox. Run it in a job once the user is created:

<?php

namespace App\Jobs;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;

class VerifyUserEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

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

    public function __construct(public User $user) {}

    public function handle(): void
    {
        $res = Http::withToken(config('services.mailrambo.key'))
            ->timeout(30)
            ->acceptJson()
            ->post('https://www.mailrambo.com/v1/verify', ['email' => $this->user->email]);

        if ($res->status() === 402) {
            // insufficient_credits: don't retry, just flag it for yourself
            report(new \RuntimeException('MailRambo out of credits'));
            return;
        }
        $res->throw(); // 429 and 503 throw, so the queue retries with backoff

        $this->user->forceFill([
            'email_deliverable' => $res->json('deliverable'),
            'email_reason' => $res->json('reason'),
        ])->save();
    }
}

Dispatch it after registration, for example in your Registered event listener or right after User::create():

VerifyUserEmail::dispatch($user);

Retrying is safe on cost: a 503 provider_unavailable refunds the credit automatically, and repeat checks of the same address from the same account within 24 hours are free (the response includes "cached": true).

What you do with the result is up to you. I'd use it to gate marketing sends and to flag accounts for review, not to lock people out after the fact. Add the two columns with a migration (email_deliverable as a nullable boolean, email_reason as a nullable string).

How to handle each reason

reason deliverable At signup (fast) After signup (full)
mailbox_exists true n/a Good to send
role_account true n/a Deliverable team inbox (info@); fine for product email
fast_check_passed null Allow n/a
possible_typo false Show "Did you mean ...?" n/a
invalid_syntax, no_mx false Reject Don't send
disposable false Reject (your call) Don't send, flag
mailbox_not_found, mailbox_disabled false n/a Don't send, ask user to update
catch_all, unverifiable false n/a Can't be confirmed; transactional only
inbox_full, spamtrap false n/a Don't send

For the full list and errors (401, 402, 429, 503), see the developers page.

Plain PHP with curl

No framework, same logic:

<?php

function mailrambo_fast_check(string $email): ?array
{
    $ch = curl_init('https://www.mailrambo.com/v1/verify?mode=fast');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 3,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('MAILRAMBO_KEY'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode(['email' => $email]),
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($body === false || $status !== 200) {
        return null; // fail open
    }
    return json_decode($body, true);
}

$result = mailrambo_fast_check($_POST['email'] ?? '');
if ($result !== null && $result['deliverable'] === false) {
    $error = $result['reason'] === 'possible_typo'
        ? 'Did you mean ' . htmlspecialchars($result['suggestion']) . '?'
        : 'Please use a different email address.';
}

Remember to escape the suggestion before printing it into HTML.

Summary

  • Keep Laravel's email rule, then add a fast check for typos, disposable domains and dead domains.
  • Fail open, cache successes, and never auto-correct the address.
  • Queue the full check after signup and store deliverable and reason on the user.

Fast mode is free on every plan, and the Free plan includes 100 full checks a month. Grab a test key and the request details from the developers page.

Run these checks from your code

Verify addresses at signup, clean lists and read SPF/DKIM/DMARC with one API call. 100 free verifications a month, test keys that cost nothing, and the API on every plan.