Skip to content

Laravel validation rule for disposable and personal emails

· 6 min read · Tutorials

To validate emails against disposable and personal domains in Laravel, write a custom rule class that implements Illuminate\Contracts\Validation\ValidationRule, call an email-classification API from it with the Http facade (short timeout, one retry on connection errors), and call $fail() when the address is disposable, machine-generated, or on a free webmail domain you don't want. Cache the domain's category, not the per-address verdict, and let the signup through if the API can't answer.

Below: a small service class that talks to the API, the rule itself, how to use it in a form request, and tests with Http::fake().

Why Laravel's built-in rules aren't enough

Laravel's email rule has validation styles like rfc and dns. email:rfc,dns rejects malformed addresses and domains without mail DNS records, which is useful. It doesn't know that mailinator.com is a throwaway inbox (it has working MX records), that ccjxx90sze@gmail.com was generated by a script, or that gmail.com isn't a company. Those are data questions, so you need a list or an API. If you want to keep everything offline, the Node.js and Python guides show the bundled-list approach; it ports to PHP directly.

Step 1: config and key

Add the key to .env and expose it through config/services.php, so it never reaches the browser and works with config:cache:

PINGUTIL_KEY=pk_live_your_key_here
// config/services.php
'pingutil' => [
    'key' => env('PINGUTIL_KEY'),
],

Step 2: the screener service

<?php
// app/Support/EmailScreener.php

namespace App\Support;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;

final class EmailScreener
{
    private const URL = 'https://pingutil.com/v1/lookup';
    private const BACKOFF_KEY = 'pingutil:backoff';

    /**
     * @return array{category: ?string, is_spam: bool, reason: ?string, degraded: ?string}
     */
    public function screen(string $email): array
    {
        $key = config('services.pingutil.key');
        $host = strtolower(rtrim(substr((string) strrchr($email, '@'), 1), '.'));

        $result = [
            // Domain-level cache: category only, never the verdict.
            'category' => $host !== '' ? Cache::get("pingutil:category:{$host}") : null,
            'is_spam' => false,
            'reason' => null,
            'degraded' => null,
        ];

        if ($result['category'] === 'disposable') {
            return [...$result, 'is_spam' => true, 'reason' => 'disposable_domain'];
        }

        if (! $key || $host === '') {
            return [...$result, 'degraded' => 'not_configured'];
        }

        // After a 429, skip the API until the window passes. Cached categories still apply.
        if (Cache::has(self::BACKOFF_KEY)) {
            return [...$result, 'degraded' => 'backoff'];
        }

        try {
            $response = Http::withToken($key)
                ->acceptJson()
                ->connectTimeout(1)
                ->timeout(2)
                ->retry(
                    2,
                    200,
                    fn (Throwable $e, PendingRequest $request) => $e instanceof ConnectionException,
                    throw: false,
                )
                ->post(self::URL, ['input' => $email, 'skip_enrichment' => true]);
        } catch (ConnectionException) {
            // Both attempts timed out or failed to connect: fail open.
            return [...$result, 'degraded' => 'connection'];
        }

        if ($response->status() === 429) {
            $wait = max(1, (int) $response->json('retry_after_seconds', 60));
            Cache::put(self::BACKOFF_KEY, true, now()->addSeconds($wait));

            return [...$result, 'degraded' => 'rate_limited'];
        }

        if (! $response->successful() || ! is_array($response->json('verdict'))) {
            return [...$result, 'degraded' => 'http_'.$response->status()];
        }

        $domain = $response->json('domain');
        $category = $response->json('category');

        if (is_string($domain) && is_string($category)) {
            Cache::put("pingutil:category:{$domain}", $category, now()->addDay());
        }

        return [
            'category' => $category,
            'is_spam' => (bool) $response->json('verdict.is_spam'),
            'reason' => $response->json('verdict.reason'),
            'degraded' => null,
        ];
    }
}

What's going on:

  • Timeouts. The Http client waits 30 seconds by default, far too long for a form. connectTimeout(1) and timeout(2) cap each attempt.
  • Retry only on connection failures. The third argument to retry() decides whether to try again. It returns true only for ConnectionException, so a 400, 401 or 429 is not retried. With throw: false, a failed HTTP response comes back as a normal Response you can inspect. A connection failure on the last attempt still throws ConnectionException, as the Laravel HTTP client docs note, which is why the call sits in a try.
  • 429. pingutil keys allow 60 requests per minute. Over that, the response is { "error": "rate_limited", "retry_after_seconds": 60 }. The screener records a backoff in the cache and stops calling until it expires, instead of hammering the API or sleeping inside the request.
  • skip_enrichment: true skips the LLM company enrichment, so you get category and verdict quickly. Drop it if you also want the company profile for company domains.
  • The cache key. The API returns the registrable domain (mail.yopmail.fr comes back as yopmail.fr), and that's what gets stored. Reads use the host from the address, which is the same string unless the address uses a subdomain; then the read misses and the API is called. Nothing wrong gets served, you just skip a Public Suffix List dependency. More on keys and TTLs in caching domain lookups.

Category is safe to cache per domain because it's a property of the domain. The verdict isn't: is_spam is also true for a randomized local part on any domain, so two addresses at gmail.com can get different answers.

Step 3: the validation rule

Generate the class with php artisan make:rule AllowedEmail, then:

<?php
// app/Rules/AllowedEmail.php

namespace App\Rules;

use App\Support\EmailScreener;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;

class AllowedEmail implements ValidationRule
{
    public function __construct(
        private bool $allowPersonal = true,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || ! str_contains($value, '@')) {
            return; // Leave format errors to the `email` rule.
        }

        $check = app(EmailScreener::class)->screen($value);

        if ($check['degraded'] !== null) {
            Log::warning('Email screening degraded', ['reason' => $check['degraded']]);
        }

        if ($check['is_spam']) {
            $fail('Please use a permanent email address.');

            return;
        }

        if (! $this->allowPersonal && in_array($check['category'], ['free', 'isp'], true)) {
            $fail('Please use your work email address.');
        }
    }
}

The interface and validate(string $attribute, mixed $value, Closure $fail): void signature are the current form documented under custom validation rules. If you localise, pass a translation key and call $fail('validation.permanent_email')->translate().

allowPersonal: false is for B2B forms that need a company domain. It rejects free (gmail.com, outlook.com and roughly 100 other webmail domains) and isp (comcast.net and similar). It doesn't reject educational or government; decide separately whether those belong on your form. Before you turn it on, read should your B2B signup form require a work email?. Blocking Gmail costs you real users, and for many products routing them is better than rejecting them.

Step 4: use it

<?php
// app/Http/Requests/RegisterRequest.php

namespace App\Http\Requests;

use App\Rules\AllowedEmail;
use Illuminate\Foundation\Http\FormRequest;

class RegisterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => [
                'bail',
                'required',
                'email:rfc',
                'max:255',
                'unique:users,email',
                new AllowedEmail(allowPersonal: true),
            ],
            'password' => ['required', 'confirmed', 'min:12'],
        ];
    }
}

bail stops at the first failing rule, so a malformed or already-registered address never triggers an API call. Put the same rule on the email-change form, or someone will sign up with a real address and switch to a throwaway one later.

Step 5: tests

<?php
// tests/Feature/AllowedEmailTest.php

use App\Rules\AllowedEmail;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;

beforeEach(function () {
    config(['services.pingutil.key' => 'pk_test']);
    Cache::flush();
    Http::preventStrayRequests();
});

function pingutil(string $domain, string $category, bool $spam, ?string $reason = null): array
{
    return [
        'domain' => $domain,
        'category' => $category,
        'verdict' => ['is_spam' => $spam, 'low_trust' => false, 'reason' => $reason],
    ];
}

it('rejects disposable addresses', function () {
    Http::fake(['pingutil.com/*' => Http::response(pingutil('mailinator.com', 'disposable', true, 'disposable_domain'))]);

    $v = Validator::make(['email' => 'a@mailinator.com'], ['email' => [new AllowedEmail]]);

    expect($v->fails())->toBeTrue();
});

it('rejects free webmail when personal emails are not allowed', function () {
    Http::fake(['pingutil.com/*' => Http::response(pingutil('gmail.com', 'free', false))]);

    $v = Validator::make(['email' => 'jane@gmail.com'], ['email' => [new AllowedEmail(allowPersonal: false)]]);

    expect($v->fails())->toBeTrue();
});

it('fails open when the API is unreachable', function () {
    Http::fake(['pingutil.com/*' => Http::failedConnection()]);

    $v = Validator::make(['email' => 'jane@acme.com'], ['email' => [new AllowedEmail]]);

    expect($v->passes())->toBeTrue();
});

it('backs off after a 429', function () {
    Http::fake(['pingutil.com/*' => Http::response(['error' => 'rate_limited', 'retry_after_seconds' => 60], 429)]);

    Validator::make(['email' => 'a@acme.com'], ['email' => [new AllowedEmail]])->passes();
    Validator::make(['email' => 'b@acme.com'], ['email' => [new AllowedEmail]])->passes();

    Http::assertSentCount(1);
});

These use Pest; the same assertions work in PHPUnit. Http::failedConnection() makes the fake throw a ConnectionException, which is exactly the timeout path.

Limits to know

  • New disposable domains aren't on any list yet, so some get through.
  • Privacy relays (Apple Hide My Email, Firefox Relay) can be flagged as randomized. If that matters for your users, see why relay addresses are not disposable and consider allowing privaterelay.appleid.com explicitly before calling the screener.
  • No batch endpoint. Don't run this over your existing users table in a loop; it's for live requests at up to 60 per minute per key.
  • Worst-case latency is two attempts of up to two seconds each plus the 200 ms pause. If that's too long for your form, drop the retry.

Field definitions for category and verdict are in the API reference.

Questions people ask

Doesn't Laravel's email:dns rule already block fake emails?
No. email:dns checks that the domain has DNS records that can receive mail. Disposable services run real mail servers, so their domains pass. It also says nothing about free webmail versus a company domain.
Why not cache the whole API response per domain?
Because part of it is about the address, not the domain. jane@gmail.com and ccjxx90sze@gmail.com share a domain but only the second is machine-generated. Cache the domain category, and look up the verdict for each address.
Will this slow down my registration form?
With skip_enrichment set, the API makes no LLM call, so the lookup is one short network round trip. The worst case is bounded by the timeouts: with the settings below, about four seconds before the rule gives up and lets the signup through.