Integration guide · Laravel

How to block disposable emails in Laravel

One custom validation rule keeps throwaway inboxes and dead domains out of your users table. It works with Breeze, Jetstream and Fortify, needs no API key and never blocks a signup because a lookup was slow.

By the Vetto team · Updated

Quick answer

Create a custom validation rule. In it, take the domain after the @, look it up, and call $fail() when the domain is a throwaway service or cannot receive mail. Add the rule to the email field of your registration validation. Cache each domain for a day and, if the lookup times out, let the signup through.

Why Laravel’s built-in email rule is not enough

The email rule checks the address format. email:dns goes further and checks that the domain has a mail server. Throwaway services like mailinator.com, yopmail.com and temp-mail.org all have working mail servers, so they pass both checks. Each one lets a person create a new “user” in seconds, take another free trial, or flood your waiting list.

The rule below asks a live lookup instead. It knows 9,000+ throwaway domains, adds new ones as they appear, and also catches domains that run on known throwaway mail servers under a fresh name.

1. Create the rule

Run php artisan make:rule NotDisposableEmail and replace the class with this. It uses Laravel’s HTTP client with a 2-second timeout and the cache, so each domain is looked up at most once a day:

app/Rules/NotDisposableEmail.php
<?php
// app/Rules/NotDisposableEmail.php

namespace App\Rules;

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

class NotDisposableEmail implements ValidationRule
{
    private const DEAD = ['nxdomain', 'no_mx_a', 'null_mx', 'no_mail'];

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $email = trim((string) $value);
        if (! str_contains($email, '@')) {
            return; // the 'email' rule reports malformed addresses
        }
        $domain = strtolower(substr(strrchr($email, '@'), 1));

        // One lookup per domain per day. Failed lookups return null and are not cached.
        $result = Cache::remember("vetto:domain:{$domain}", now()->addDay(), function () use ($domain) {
            try {
                $response = Http::timeout(2)
                    ->acceptJson()
                    ->post('https://usevetto.com/v1/audit', ['domains' => [$domain]]);

                return $response->json('results')[$domain] ?? null;
            } catch (\Throwable) {
                return null; // timeout or network error: fail open
            }
        });

        if (($result['disposable'] ?? false) === true) {
            $fail('Please use a permanent email address. Temporary inboxes are not accepted.');
        } elseif (in_array($result['status'] ?? null, self::DEAD, true)) {
            $fail('This email domain cannot receive mail. Please check it for typos.');
        }
    }
}

2. Add it to registration

With Laravel Breeze, the registration validation lives in the controller:

RegisteredUserController.php
// app/Http/Controllers/Auth/RegisteredUserController.php (Breeze)
use App\Rules\NotDisposableEmail;

$request->validate([
    'name' => ['required', 'string', 'max:255'],
    'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class, new NotDisposableEmail],
    'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);

With Jetstream or Fortify, it lives in the CreateNewUser action:

CreateNewUser.php
// app/Actions/Fortify/CreateNewUser.php (Jetstream / Fortify)
use App\Rules\NotDisposableEmail;

Validator::make($input, [
    'name' => ['required', 'string', 'max:255'],
    'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
    'password' => $this->passwordRules(),
])->validate();

Using a Form Request or an API controller? Add new NotDisposableEmail to the email rules there in the same way. Add it wherever a user can change their email address too, or people sign up with Gmail and switch to a throwaway later.

3. Test it

Register with x7@mailinator.com: you get “Please use a permanent email address”. Try a domain that does not exist, like jane@doesnotexist-xyz-123.com: you get “This email domain cannot receive mail”. Gmail, Outlook and Apple Hide My Email go through. In your test suite, fake the HTTP call so tests stay fast and offline (Pest shown, PHPUnit works the same):

tests/Feature/NotDisposableEmailTest.php
// tests/Feature/NotDisposableEmailTest.php
use App\Rules\NotDisposableEmail;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;

it('rejects a throwaway inbox', function () {
    Http::fake(['*/v1/audit' => Http::response(['ok' => true, 'results' => [
        'mailinator.com' => ['status' => 'skipped', 'mx' => [], 'disposable' => true],
    ]])]);

    $v = Validator::make(['email' => 'x7@mailinator.com'], ['email' => [new NotDisposableEmail]]);
    expect($v->fails())->toBeTrue();
});

it('lets the signup through when the lookup is down', function () {
    Http::fake(['*/v1/audit' => fn () => throw new \Illuminate\Http\Client\ConnectionException('down')]);

    $v = Validator::make(['email' => 'jane@slow.example'], ['email' => [new NotDisposableEmail]]);
    expect($v->passes())->toBeTrue();
});

What the lookup returns

The rule calls POST /v1/audit, the free keyless domain lookup. For each domain it returns a status (ok, nxdomain, no_mx_a, null_mx, no_mail), the mail servers and a disposable flag. Privacy relays (Apple Hide My Email, Firefox Relay, DuckDuckGo, SimpleLogin, Proton Pass) come back as not disposable, because a real person reads them. You can check any domain by hand on the disposable email checker.

Going further: one person, many accounts

Throwaway domains are only one kind of fake signup. The other common one is a single Gmail inbox reused through dots and +tags: j.a.n.e+1@gmail.com, jane+2@gmail.com and jane@googlemail.com all reach the same person. Vetto Pro’s /v1/check endpoint catches those, plus VPN and data-center signups and your own deny list, and shows every check in a dashboard:

inside validate()
// Vetto Pro: full check (Gmail aliases, VPN and data-center signups, your rules)
$verdict = Http::timeout(2)
    ->withToken(config('services.vetto.secret')) // sk_... from your dashboard
    ->post('https://usevetto.com/v1/check', ['email' => $email])
    ->json();

if (($verdict['verdict'] ?? null) === 'block') {
    $fail($verdict['message'] ?? 'Please use a real email address.');
}

More on the patterns behind repeat trials: how free-trial abuse works and the Gmail alias checker.

Prefer a package with a bundled list? Propaganistas/Laravel-Disposable-Email works offline. Other guides: Django · Supabase · Firebase · how to detect fake signups.

Frequently asked questions

How do I block disposable emails in Laravel?

Write a custom validation rule that looks up the email domain and fails when it is a throwaway service, then add it to the email field of your registration request. The rule on this page uses a free lookup, caches each domain for a day and lets the signup through if the lookup is slow.

Isn't email:dns enough?

No. email:dns only checks that the domain has a mail server, and throwaway services have working mail servers. It does catch typo domains, so keep it if you like, but it won't stop mailinator.com or yopmail.com.

Should I use a package instead?

Propaganistas/Laravel-Disposable-Email bundles a list and adds an indisposable rule. It is a good offline option. A live lookup adds new throwaway domains as they appear and also flags domains that cannot receive mail.

Will this block Apple Hide My Email or Firefox Relay?

No. Privacy relays forward to a real inbox, so the lookup reports them as not disposable and the rule lets them through.

What about the rate limit?

The free lookup allows 300 calls a day per IP. The rule caches every domain for a day, so most signups never call it. For higher volume, use the Pro /v1/check endpoint with your secret key.

Give your signup form sharp eyes.

Free on one site. One line of HTML. Remove it any time and your form works exactly as before.

Founding offer: Pro for life for $69 once, first 100 buyers, until Nov 27.