Laravel 11 Email Validation: Fluent Rules, Middleware, and API Integration

hangrydev ·

Laravel’s Email Rule Has Six Options. None Check the Mailbox.

Laravel ships one of the best built-in email validation systems of any framework. The email rule accepts modifiers: rfc, strict, dns, spoof, filter, and filter_unicode. Most Laravel apps use exactly one of them, if any.

Here’s what each modifier actually does:

use Illuminate\Support\Facades\Validator;

$validator = Validator::make($request->all(), [
    'email' => 'required|email:rfc,dns',
]);
  • rfc validates against RFC 5322 (the Internet Message Format standard). This is the default when you write just email.
  • strict also validates against RFC 5322, but rejects addresses that produce warnings, like trailing periods or multiple consecutive periods.
  • dns checks that the domain has MX or A records. This is the big one most teams skip.
  • spoof detects mixed-script homograph attacks (pаypal.com using a Cyrillic “a”).
  • filter uses PHP’s FILTER_VALIDATE_EMAIL. Stricter than RFC on some edge cases.
  • filter_unicode same as filter but allows Unicode local parts.

Laravel 11 uses the egulias/email-validator package under the hood, which is genuinely solid. The dns option triggers a real DNS lookup. That puts it ahead of most frameworks right out of the gate. Note: both dns and spoof require the PHP intl extension.

But none of these check whether the mailbox exists. [email protected] passes every modifier, even if that specific address bounces. Disposable providers like Guerrilla Mail pass dns just fine because their domains have valid MX records. Catch-all servers accept mail for any address, real or invented. Email lists decay at 22-28% per year. Laravel’s rules will keep telling you those rotting addresses are valid the entire time.

Fluent Rules in Laravel 11

Laravel 11 encourages fluent rule definitions over pipe-delimited strings. Cleaner, more discoverable, and easier to conditionally compose.

use Illuminate\Validation\Rule;

$request->validate([
    'email' => [
        'required',
        'string',
        'max:255',
        Rule::email()->rfcCompliant()->validateMxRecord(),
        'unique:users,email',
    ],
]);

The fluent API exposes the same modifiers as method calls. rfcCompliant() maps to rfc. rfcCompliant(strict: true) maps to strict. validateMxRecord() maps to dns. preventSpoofing() maps to spoof. withNativeValidation() maps to filter, and withNativeValidation(allowUnicode: true) maps to filter_unicode.

You can chain them:

Rule::email()
    ->rfcCompliant()
    ->validateMxRecord()
    ->preventSpoofing();

This combo catches malformed syntax, domains with no mail server, and homograph spoofing. Three layers with zero external dependencies. Pretty good for built-in tooling.

Still doesn’t check if the mailbox exists. Still doesn’t detect disposable providers. Still can’t tell you whether [email protected] is a catch-all that accepts everything or a real inbox.

Custom Validation Rule: API-Powered Verification

A custom rule wrapping an email validation API fills the gap Laravel’s built-in rules leave open.

// app/Rules/DeliverableEmail.php
<?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
    {
        try {
            $response = Http::timeout(3)
                ->withToken(config('services.truemail.api_key'))
                ->post('https://api.truemail.io/v1/verify', [
                    'email' => $value,
                    'checks' => ['syntax', 'mx'],
                ]);

            if ($response->failed()) {
                return; // Fail open if API errors
            }

            $result = $response->json();

            if ($result['status'] === 'undeliverable') {
                $fail('That email doesn\'t appear to be deliverable.');
            }

            if ($result['disposable'] ?? false) {
                $fail('Disposable email addresses aren\'t allowed.');
            }
        } catch (\Illuminate\Http\Client\ConnectionException $e) {
            return; // Fail open: don't block signup if API is unreachable
        }
    }
}

Wire it into your request:

use App\Rules\DeliverableEmail;

$request->validate([
    'email' => ['required', 'email:rfc,dns', new DeliverableEmail, 'unique:users,email'],
]);

The timeout(3) and the early return on failure are the key design decisions. If the API doesn’t respond in 3 seconds, you accept the email and verify later. Never let an external service become a gate on user registration.

Why run email:rfc,dns alongside the custom rule? Because the built-in check is free and instant. It catches syntax errors and dead domains before you spend an API call. The custom rule only fires for emails that pass the cheap checks first. Laravel runs rules in order.

Middleware: Validate on Every Authenticated Request

What if an email was valid at signup but bounced three months later? The user changed providers. The mailbox filled up. The domain expired. You’re still sending transactional emails to a dead address, racking up bounces, and dragging your sender score down.

This is a real problem. Roughly 22-30% of email addresses on a typical list go stale every year. If your app sends password resets, invoices, or shipping notifications, stale addresses cost you money and deliverability.

Middleware that re-validates periodically catches this drift.

// app/Http/Middleware/ValidateUserEmail.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;

class ValidateUserEmail
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if (!$user || $user->email_verified_at === null) {
            return $next($request);
        }

        $cacheKey = "email_valid:{$user->id}";

        if (Cache::has($cacheKey)) {
            return $next($request);
        }

        try {
            $response = Http::timeout(2)
                ->withToken(config('services.truemail.api_key'))
                ->post('https://api.truemail.io/v1/verify', [
                    'email' => $user->email,
                    'checks' => ['syntax', 'mx'],
                ]);

            if ($response->ok()) {
                $result = $response->json();
                $ttl = $result['status'] === 'deliverable' ? 604800 : 86400;
                Cache::put($cacheKey, $result['status'], $ttl);

                if ($result['status'] === 'undeliverable') {
                    $user->update(['email_status' => 'undeliverable']);
                }
            } else {
                Cache::put($cacheKey, 'unknown', 3600);
            }
        } catch (\Illuminate\Http\Client\ConnectionException $e) {
            Cache::put($cacheKey, 'unknown', 3600);
        }

        return $next($request);
    }
}

Register it in bootstrap/app.php (Laravel 11’s new middleware registration):

// bootstrap/app.php
use App\Http\Middleware\ValidateUserEmail;

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->web(append: [
            ValidateUserEmail::class,
        ]);
    })
    ->create();

The cache TTL is the lever. Seven days for deliverable addresses means you re-check roughly once a week. One day for undeliverable means you catch recoveries faster. One hour for unknowns (API failures) means you retry soon without hammering the API.

This doesn’t block the user. It updates a status field in the background. Your notification system reads that field and decides whether to send emails or show a “please update your email” banner. Non-blocking. Non-intrusive.

Queue-Based Batch Validation

Registration forms handle one email at a time. A CSV import of 50,000 contacts can’t block on 50,000 HTTP calls. You’d hit memory limits, timeout limits, and rate limits all at once. Queue the heavy work with Laravel’s job system.

// app/Jobs/ValidateEmailBatch.php
<?php

namespace App\Jobs;

use App\Models\Contact;
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 ValidateEmailBatch implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60;

    public function __construct(
        private array $contactIds
    ) {}

    public function handle(): void
    {
        $contacts = Contact::whereIn('id', $this->contactIds)->get();

        foreach ($contacts as $contact) {
            try {
                $response = Http::timeout(10)
                    ->withToken(config('services.truemail.api_key'))
                    ->post('https://api.truemail.io/v1/verify', [
                        'email' => $contact->email,
                    ]);

                if ($response->ok()) {
                    $result = $response->json();
                    $contact->update([
                        'email_status' => $result['status'],
                        'email_disposable' => $result['disposable'] ?? false,
                        'email_catch_all' => $result['catch_all'] ?? false,
                    ]);
                }
            } catch (\Illuminate\Http\Client\ConnectionException $e) {
                continue; // Skip and retry on next job attempt
            }
        }
    }
}

Dispatch in chunks from your import controller:

use App\Jobs\ValidateEmailBatch;

$contactIds = Contact::where('email_status', 'pending')->pluck('id')->toArray();

foreach (array_chunk($contactIds, 100) as $batch) {
    ValidateEmailBatch::dispatch($batch)->onQueue('validation');
}

At 80 requests per second, a 100,000-contact import takes about 20 minutes. Build that expectation into your UI. Show a progress indicator backed by the email_status column count, not a spinner.

The $tries = 3 with $backoff = 60 handles transient API failures. If a batch fails, Laravel retries it a minute later. Three strikes and it moves to the failed jobs table. Monitor that table.

Want to run the dedicated validation queue separately from your default queue? Spin up a second worker:

php artisan queue:work --queue=validation --tries=3 --backoff=60

This keeps validation jobs from competing with your password reset emails and notification dispatches. Isolation matters when you’re processing large imports.

Caching Validation Results

Don’t call the API twice for the same email when a user resubmits your form after fixing their password field. Cache it.

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

function validateEmailCached(string $email): ?array
{
    $cacheKey = 'email_check:' . strtolower($email);
    $cached = Cache::get($cacheKey);

    if ($cached !== null) {
        return $cached;
    }

    try {
        $response = Http::timeout(3)
            ->withToken(config('services.truemail.api_key'))
            ->post('https://api.truemail.io/v1/verify', [
                'email' => $email,
                'checks' => ['syntax', 'mx'],
            ]);

        if ($response->ok()) {
            $result = $response->json();
            $ttl = $result['status'] === 'deliverable' ? 86400 : 3600;
            Cache::put($cacheKey, $result, $ttl);
            return $result;
        }
    } catch (\Illuminate\Http\Client\ConnectionException $e) {
        // Fall through
    }

    return null;
}

Set the cache driver to Redis in production. Laravel 11 ships config/cache.php with Redis support ready to go. 24-hour TTL for deliverable results, 1 hour for everything else. An email that was valid this morning is still valid tonight. One that bounced could get fixed, so give it a shorter window.

On forms where users average 1.8 submissions per signup (correcting password or name fields), caching cuts API calls nearly in half.

The Latency Budget

Your signup form has roughly a 2-3 second window before users start abandoning. Each validation layer eats into it.

  • Regex only (email:rfc): under 1ms. Negligible.
  • DNS check (email:rfc,dns): 20-150ms. The fastest meaningful network check.
  • API call (syntax + MX): 100-400ms. Still within budget.
  • API call (full SMTP): 500-3,000ms. Too slow for synchronous signup.

The pattern that works: run Laravel’s built-in email:rfc,dns plus a quick API check for disposable detection synchronously. Queue full SMTP verification as a background job. You catch dead domains and throwaway addresses in real time, then deep-verify without blocking the form.

// app/Http/Controllers/Auth/RegisteredUserController.php
public function store(Request $request)
{
    $validated = $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'email:rfc,dns', new DeliverableEmail, 'unique:users,email'],
        'password' => ['required', 'confirmed', Rules\Password::defaults()],
    ]);

    $user = User::create([
        'name' => $validated['name'],
        'email' => $validated['email'],
        'password' => Hash::make($validated['password']),
    ]);

    DeepEmailVerificationJob::dispatch($user->id)->onQueue('validation');

    event(new Registered($user));
    Auth::login($user);

    return redirect(route('dashboard'));
}

Syntax errors caught instantly. Dead domains caught by dns. Disposable addresses caught by the API rule. Full SMTP verification runs in the background after the user is already logged in and seeing their dashboard.

The Full Progression

Stage 1 (MVP). email:rfc with fluent rules. Free, instant, catches syntax errors. Ship this on day one.

Stage 2 (Growing). Add dns and spoof modifiers. Catches dead domains and homograph attacks. Adds 20-150ms latency. Still zero dependencies.

Stage 3 (Production). Custom validation rule wrapping MailCop’s API. Synchronous syntax + MX + disposable check at signup. Middleware for periodic re-validation. Queue-based batch verification for imports. Cache results in Redis.

Where do you draw the line? If you’re pre-launch with 100 users, Stage 1 and Stage 2 cover you. If you’re sending 50,000 transactional emails a month and your bounce rate is past 3%, you needed Stage 3 last quarter.

Laravel gives you more email validation out of the box than most frameworks. The dns modifier alone puts it ahead of Django, Rails, and Express. But there’s still a gap between “valid format with a working domain” and “deliverable mailbox that won’t bounce.”

That gap is where bounces live. Where sender reputation degrades. Where transactional emails disappear into the void.

API-powered validation closes it without replacing what Laravel already does well. Keep the built-in rules. Layer the API on top. Let the queue handle the rest.