Block disposable emails in Laravel with a free validation rule
Block throwaway email signups in Laravel with one validation rule, a free API call and a free list of 224,174 domains, with tested PHP code and measured costs.
To block disposable emails in Laravel, add a validation rule that asks a free API about the domain and keeps a list of throwaway domains for when the API cannot answer. Pair it with email:filter for the format. The class below needs no package or key, and it lets relay addresses such as Apple's Hide My Email through. When the API is down, the list refuses known throwaway domains and every other signup stays open.
#Is a package enough?
Two packages lead on Packagist: propaganistas/laravel-disposable-email, with 287,633 downloads last month, and erag/laravel-disposable-email, with 41,686. Both are MIT licensed. The table sets their lists beside the one this recipe uses:
| List | Domains | How it updates |
|---|---|---|
| Propaganistas | 97,713 in its domains.json | Weekly releases, or a scheduled update command |
| erag | 124,220+, as its README states | A sync command that only adds domains |
| FFraud's MIT list | 224,174 | Rebuilt every 30 minutes |
Two defaults are worth knowing. Propaganistas checks parent domains only when you set include_subdomains to true, so inbox.mailinator.com passes as it ships. erag's sync keeps every domain it has ever added, even after the source removes one, so a wrongly listed domain stays blocked.
Propaganistas also offers MX inspection, which flags an address whose mail servers point at a listed domain. FFraud's free API checks mail servers too, and it tells relay services apart from throwaway inboxes, so the class below asks it first.
#How do you build the check?
<?php
namespace App\Support;
use RuntimeException;
// Block disposable email addresses: FFraud's free API decides, its MIT list answers when the API cannot.
final class DisposableEmail
{
private const LIST_URL = 'https://raw.githubusercontent.com/FFraud-com/disposable-email-domains/main/disposable-email-domains.txt';
private const API_URL = 'https://api.ffraud.com/public/email/check';
private ?array $domains = null;
private array $types = [];
// Relay services such as Apple's Hide My Email forward to an inbox a real person reads; pass false to refuse them too.
public function __construct(private string $cacheFile, private string $listUrl = self::LIST_URL, private bool $acceptRelays = true)
{
}
/** Download the list, clean it and swap it in. A failed or short download keeps the current file. */
public function refresh(): int
{
$text = @file_get_contents($this->listUrl, false, stream_context_create(['http' => ['timeout' => 10]]));
if ($text === false) {
throw new RuntimeException('list download failed, keeping the current list');
}
$domains = [];
foreach (explode("\n", $text) as $line) {
$domain = strtolower(rtrim(trim($line), '.'));
if ($domain !== '' && $domain[0] !== '#') {
$domains[$domain] = true;
}
}
if (count($domains) < 100000) {
throw new RuntimeException(count($domains) . ' domains only, keeping the current list');
}
$tmp = $this->cacheFile . '.tmp';
file_put_contents($tmp, implode("\n", array_keys($domains)));
rename($tmp, $this->cacheFile);
return count($domains);
}
public function isListed(string $domain): bool
{
$this->domains ??= is_file($this->cacheFile) ? array_flip(file($this->cacheFile, FILE_IGNORE_NEW_LINES)) : [];
$labels = explode('.', strtolower(rtrim(trim($domain), '.')));
for ($i = 0; $i < count($labels) - 1; $i++) {
if (isset($this->domains[implode('.', array_slice($labels, $i))])) {
return true;
}
}
return false;
}
/** 'temp', 'alias' or 'normal', or null when the API cannot answer. The type depends on the domain alone, so only the domain is sent. */
public function apiType(string $domain, int $timeoutMs = 2000): ?string
{
$ch = curl_init(self::API_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['email' => 'user@' . $domain]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT_MS => $timeoutMs,
]);
$body = curl_exec($ch);
$ok = $body !== false && curl_getinfo($ch, CURLINFO_RESPONSE_CODE) === 200;
curl_close($ch);
$type = $ok ? (json_decode($body, true)['email_type'] ?? null) : null;
return is_string($type) ? $type : null;
}
/** [accept, reason]: the API decides; without an answer, the list does. */
public function check(string $value, int $timeoutMs = 2000): array
{
$email = trim($value);
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return [false, 'not an email address'];
}
$domain = strtolower(substr($email, strrpos($email, '@') + 1));
if (!array_key_exists($domain, $this->types)) {
$this->types[$domain] = $this->apiType($domain, $timeoutMs);
}
$type = $this->types[$domain];
if ($type === null) {
// The list cannot tell a relay from a throwaway inbox, so it refuses both.
return $this->isListed($domain) ? [false, 'disposable domain'] : [true, 'API unavailable, list only'];
}
if ($type === 'temp' || ($type === 'alias' && !$this->acceptRelays)) {
return [false, 'disposable domain'];
}
return [true, $type === 'alias' ? 'relay address' : 'ok'];
}
}- The API decides.
apiType()sendsuser@and the domain, never the address, and readsemail_type:temp,aliasornormal. The class asks once per domain and gives the API 2 seconds. - Relays pass. An
aliasanswer is accepted. PassacceptRelays: falseto refuse relay services too. - The list answers when the API cannot. It loads only then, in about 21 ms in our test, and parent domains count:
inbox.mailinator.commatchesmailinator.com. - A safe refresh.
refresh()lowercases the list, drops comments and trailing dots, and refuses a download under 100,000 domains. It writes a new file and swaps it in with onerename(), so the old list stays until the new one is complete. - Format first.
filter_varrejects a malformed address before any lookup. In ouremail validation regex test , it rejected none of 14 real forms and accepted none of 15 bad ones.
FFraud's free list holds relay services such as privaterelay.appleid.com, duck.com and mozmail.com, and the API answers alias for them. A relay forwards to an inbox a real person reads, and our guide to
#How do you wire it into Laravel?
<?php
namespace App\Rules;
use App\Support\DisposableEmail;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class NotDisposableEmail implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
[$accept, $reason] = app(DisposableEmail::class)->check((string) $value);
if (!$accept && $reason === 'disposable domain') {
$fail('Please use an email address you will keep. Throwaway inboxes are not accepted.');
}
}
}// app/Providers/AppServiceProvider.php, inside register()
$this->app->singleton(\App\Support\DisposableEmail::class, fn () => new \App\Support\DisposableEmail(storage_path('app/disposable-domains.txt')));
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::call(fn () => app(\App\Support\DisposableEmail::class)->refresh())->daily();
// in a controller or a form request
$request->validate(['email' => ['required', 'email:filter', 'max:254', new \App\Rules\NotDisposableEmail]]);A rule object implements ValidationRule and calls $fail with its message (Laravel docs). The rule raises only for throwaway domains and leaves the format to email:filter, which uses PHP's filter_var. Laravel's default rfc style lets trailing and doubled periods through; only strict fails them.
The singleton binding means one instance per request, so the API is asked once per domain even if several rules run. To refuse relay addresses as well, bind new DisposableEmail(storage_path('app/disposable-domains.txt'), acceptRelays: false). Run refresh() once when you deploy, and the daily schedule keeps the file current.
These three snippets follow Laravel 13's documentation; we ran the class and the rule outside Laravel.
#What does the list cost when it loads?
The list loads only when the API cannot answer. PHP starts each request from scratch, so that request reads the file again. We measured three ways to store it, each in a fresh PHP process, three times:
- Text file, file() and array_flip()21 ms
- Serialized array, unserialize()36 ms
- PHP array file, require149 ms
Source: FFraud test on PHP 8.3.25, October 26, 2026
Each way used 19.7 MB. OPcache keeps compiled code in shared memory and removes the parse on each request (PHP manual), which would favor the PHP array file. Our test build has no OPcache, so the class uses the text file, the fastest way we measured. Only a request that runs the rule while the API is down pays the cost.
#What happens when the API or the download fails?
If the API fails or takes more than 2 seconds, the list decides. A listed domain is refused, and any other address is accepted. The list cannot tell a relay from a throwaway inbox, so relay addresses are refused until the API answers again.
If the daily download fails or comes back short, refresh() throws and the old file stays in place. Without any file yet, every address passes while the API is down.
Failing closed would stop every signup during an outage, which costs more than one new throwaway service. A confirmation email still decides whether the inbox works.
#How do you test it?
We ran the class and the rule on PHP 8.3 on October 26, 2026, with one API call a second at most. A 1 ms timeout stands in for an API outage:
PHP 8.3.25, opcache not loaded, 2026-10-26T00:07:49Z
refresh: 224174 domains, cache file 3544411 bytes, 0.65 s
guard: 2000 domains only, keeping the current list | file kept out: yes
comparison files: PHP array 6795935 bytes, serialized 6997697 bytes, text 3544411 bytes
per request, require a PHP array file: 430 ms, 19.7 MB | 147 ms, 19.7 MB | 149 ms, 19.7 MB
per request, file() and array_flip() the text list (this class): 21 ms, 19.7 MB | 21 ms, 19.7 MB | 21 ms, 19.7 MB
per request, unserialize() a serialized array: 37 ms, 19.7 MB | 36 ms, 19.7 MB | 35 ms, 19.7 MB
isListed('mailinator.com') = true, 0.50 microseconds per call
isListed('gmail.com') = false, 0.41 microseconds per call
isListed('inbox.mailinator.com') = true, 0.43 microseconds per call
check("test@mailinator.com") -> [false, 'disposable domain'] in 499.6 ms
check("user@inbox.mailinator.com") -> [false, 'disposable domain'] in 673.8 ms
check("test@privaterelay.appleid.com") -> [true, 'relay address'] in 452.9 ms
check(" team@ffraud.com\n") -> [true, 'ok'] in 458.0 ms
check("team@ffraud.com") -> [true, 'ok'] in 0.0 ms
check("us..er@ffraud.com") -> [false, 'not an email address'] in 0.0 ms
check("test@mailinator.com", 1 ms) -> [false, 'disposable domain'] in 24.7 ms
check("test@privaterelay.appleid.com", 1 ms) -> [false, 'disposable domain'] in 1.4 ms
check("team@ffraud.com", 1 ms) -> [true, 'API unavailable, list only'] in 1.3 ms
check("test@privaterelay.appleid.com") with acceptRelays false -> [false, 'disposable domain'] in 471.1 ms
NotDisposableEmail('test@mailinator.com'): fails: Please use an email address you will keep. Throwaway inboxes are not accepted.
NotDisposableEmail('test@privaterelay.appleid.com'): passes
NotDisposableEmail('team@ffraud.com'): passesLaravel was not installed for this test. The rule ran against stand-ins for the ValidationRule interface and the app() helper, so run it once in your own app before you ship it.
#Questions people ask
How do I block disposable emails in Laravel?
Add a rule object that asks the free API about the domain, with a list of throwaway domains for outages, and use it after email:filter: ['required', 'email:filter', 'max:254', new NotDisposableEmail].
Which Laravel package should I use for disposable email validation?
If you pick a package, check its defaults: Propaganistas skips subdomains unless you turn them on, and erag never removes a domain. A list you download yourself every day, like the class above, has neither issue.
Does Laravel's email validation rule check for disposable domains?
No. The email rule checks the format, and its dns style checks for an MX record, which the
Will the rule block Apple's Hide My Email?
Not as shipped. The API answers alias for relay services, and the class accepts them unless you pass acceptRelays: false. While the API is down, the list refuses them.
How do I keep the disposable domain list up to date in Laravel?
Schedule the refresh in routes/console.php, as shown above: Schedule::call(...)->daily(). A failed download keeps the current list.
Should the disposable rule come after the email rule?
Yes. Let email:filter reject malformed addresses first, so the disposable rule only sees real ones and its message stays accurate.
To test one address by hand, paste it into the free