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
Httpclient waits 30 seconds by default, far too long for a form.connectTimeout(1)andtimeout(2)cap each attempt. - Retry only on connection failures. The third argument to
retry()decides whether to try again. It returnstrueonly forConnectionException, so a 400, 401 or 429 is not retried. Withthrow: false, a failed HTTP response comes back as a normalResponseyou can inspect. A connection failure on the last attempt still throwsConnectionException, as the Laravel HTTP client docs note, which is why the call sits in atry. - 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: trueskips the LLM company enrichment, so you getcategoryandverdictquickly. Drop it if you also want the company profile forcompanydomains.- The cache key. The API returns the registrable domain (
mail.yopmail.frcomes back asyopmail.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.comexplicitly before calling the screener. - No batch endpoint. Don't run this over your existing
userstable 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.