erag/laravel-disposable-email
Laravel package for detecting and blocking disposable email addresses. It provides a validation rule and helpers to reject throwaway domains using a configurable domain list, making it easy to keep registration, forms, and APIs free of temporary email signups.
Install the package and run its installer (which publishes the config and prepares the blacklist directory):
composer require erag/laravel-disposable-email
php artisan erag:install-disposable-email
Laravel 11+ auto-discovers the service provider; if you have discovery disabled, register EragLaravelDisposableEmail\LaravelDisposableEmailServiceProvider in bootstrap/providers.php (or config/app.php on Laravel 10).
Where to look first, in this order:
config/disposable-email.php — the only file most apps need to touch (whitelist, block_subdomains, cache_enabled, remote_url).php artisan disposable:stats — confirms the package is loaded, shows built-in vs custom domain counts, whitelist size, cache status, and last sync time. Run this before debugging anything else.storage/app/blacklist_file/ — where your project-specific .txt domain lists live.src/Support/Email.php — the compiled built-in domain array. Read it, don't edit it (see Gotchas).First use case — block disposable emails at registration. The smallest useful change is one rule in your existing Form Request:
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'disposable_email'],
'password' => ['required', 'confirmed', 'min:8'],
];
}
Note the pairing with Laravel's own email rule — disposable_email is a domain-lookup check, not a syntax check.
There are four surfaces; use the simplest one that fits:
| Need | Use |
|---|---|
Request validation (controller, Form Request, API, manual Validator) |
string rule disposable_email |
| Rule passed around / composed programmatically | new DisposableEmailRule() or Disposable::rule() / Disposable::make() |
| Imperative check in a service, job, command, listener | Disposable::email($email) (boolean) |
| "Why was this blocked?" — logging, support tooling, analytics | Disposable::check($email) (result object) |
| UI branching only | @disposableEmail(...) Blade directive |
The string rule works in both array and pipe syntax, so it drops into legacy validators without refactoring:
$request->validate(['email' => 'required|email|disposable_email']);
$request->validate(['email' => ['required', 'email', 'disposable_email']]);
The most common production pattern is validating at the edge and re-checking before persistence, because emails arrive from imports, OAuth callbacks, admin panels, and queued jobs that bypass HTTP validation:
use Disposable;
class SignupGuard
{
public function ensureRealMailbox(string $email): void
{
if (Disposable::email($email)) {
throw new InvalidArgumentException('Disposable email addresses are not allowed.');
}
}
}
Keep this in one small class and call it from the controller, the importer, and the invitation job. If you later swap the package out (see Gotchas on maturity), you change one file.
check() when you need to explain the rejectionDisposable::email() gives you a boolean and nothing else, which is painful when a user disputes the block or when a whitelist rule misfires. check() returns a result object:
$result = Disposable::check($email);
$result->disposable(); // bool
$result->domain(); // parsed domain
$result->matchedDomain(); // the entry that actually matched
$result->source(); // 'built-in' | 'custom' | 'whitelist'
$result->whitelisted(); // bool
$result->toArray(); // log/serialize this
Log toArray() at debug level on rejection. source() is the single fastest way to answer "is this from the built-in list, our custom list, or the whitelist?" — which is exactly the question that comes up in support tickets.
Drop plain .txt files into the configured blacklist directory. Every .txt file in that directory is loaded, one domain per line:
# storage/app/blacklist_file/disposable_domains.txt
abakiss.com
fakemail.org
trashbox.io
The loader normalizes [email protected]-style entries down to the domain, so pasting straight from a support ticket mostly works — but keep the files clean anyway: no comments, no headers, no metadata, since unrecognized lines may be normalized into junk domains rather than skipped. Split files by concern (vendor-blocklist.txt, qa-blocklist.txt) so it's obvious what to remove later.
Test/dev accounts on disposable-looking domains are the #1 source of false positives. whitelist always wins over any source:
'whitelist' => [
'trusted-test-domain.com',
'our-qa-domain.dev',
],
Keep subdomain blocking on unless you have a reason not to — block_subdomains => true means blocking tempmail.com also blocks a.tempmail.com, which is what most people actually want.
With the default cache_enabled => false, every validation rebuilds/loads the compiled domain list. That's fine for a registration form; it is not fine when the guard runs inside a loop, an importer, or a busy API:
'cache_enabled' => true,
'cache_ttl' => 60,
Turn this on before load testing, not after. Treat cache invalidation as part of every blacklist change: file edit → php artisan cache:clear.
The built-in array is a snapshot from the package release; the sync command is what actually keeps you current. Schedule it:
use Illuminate\Support\Facades\Schedule;
Schedule::command('erag:sync-disposable-email-list')->daily();
erag:sync-disposable-email-list clears the package cache, reads config('disposable-email.remote_url'), normalizes the response into domains, and writes the result into the blacklist directory. Sources live in config, so adding a second feed is a config change, not a code change.
@disposableEmail($user->email)
<p class="text-red-600">Disposable email detected.</p>
@else
<p class="text-green-600">Email looks acceptable.</p>
@enddisposableEmail
Good for an inline hint on a profile page or an admin badge. Never the only check — it renders, it doesn't validate, and the value may be stale by the time the form posts.
Plain disposable_email is domain-list-only. If you need more (RFC compliance, DNS/MX existence, spoof detection, filters), pass modes comma-separated with no spaces:
'email' => ['required', 'disposable_email:rfc,dns'],
Supported: rfc, strict, dns, spoof, filter, filter_unicode. These add latency (DNS especially) — reserve them for high-value flows like registration or payout setup, and leave the plain rule on low-risk forms.
disposable_email doesn't validate syntax. Without Laravel's email rule alongside it, garbage strings can reach the domain parser. Always pair them, and put email first in the array so failures short-circuit before a lookup.
Rule name is exact. disposable_email, not disposableEmail or DisposableEmail. A typo throws "validation rule does not exist", which reads like a package problem but isn't.
The sync command does not touch the built-in array. This trips people up constantly. erag:sync-disposable-email-list writes to your storage blacklist directory only; src/Support/Email.php is only rewritten by php scripts/update-built-in-domains.php, which is a maintainer tool for the package repo, not for your app. If disposable:stats shows your synced domains under "custom," that's correct.
Config caching bites after you publish. On a cached-config deployment, editing config/disposable-email.php does nothing until php artisan config:clear. Same for whitelist and block_subdomains. Add config:clear and cache:clear to your deploy script when these change.
Cache invalidation is manual. Editing a .txt file does not invalidate the compiled list. Order of operations for any blacklist change: edit file → php artisan cache:clear → php artisan disposable:stats to confirm the count moved. Skipping the clear produces the classic "I added the domain and it still passes."
Sync needs a writable disk and network egress. It writes into storage/app/blacklist_file. On read-only container filesystems, or in CI/production environments with blocked outbound HTTPS, the command fails or silently syncs nothing. Decide deliberately: sync in a scheduled job on a writable instance, or ship a vendored blacklist file in your deploy artifact and skip the command. Don't schedule a command that can only fail.
remote_url is the source of truth, and it's remote. You're trusting an external GitHub raw URL at runtime. Pin the package version, and if the list is business-critical, vendor a copy into your repo and point remote_url at your own mirror so an upstream change or outage can't change your signup behavior.
source() explains 90% of "why was I blocked." Whitelist entries report whitelisted() === true and pass — if you see a block on a whitelisted domain, block_subdomains matching an entry you didn't expect is the usual cause. Check matchedDomain() before assuming the whitelist is broken.
domain() accepts both forms. Disposable::domain('tempmail.com') and Disposable::domain('[email protected]') both work — convenient, but it means a typo'd email silently parses instead of erroring. Validate syntax upstream.
Facade methods are case-insensitive aliases. Disposable::Email(), Disposable::Check() etc. all work. Don't let that leak into your codebase — pick email()/check() and stay consistent, because IDE autocomplete and static analysis will not help you with the variants.
Watch the version you're on. There's a /deprecated-5-0-0 upgrade note — if you're on 4.x and jumping to 5.x, read it before upgrading rather than after. The package also has a very small dependent count and modest adoption; treat it as a small, single-maintainer dependency. Pin the version in composer.json, wrap it behind your own guard/rule class, and you keep the option to swap in an equivalent implementation without touching every Form Request.
Fastest troubleshooting sequence, in order:
php artisan disposable:stats # is it loaded, what's in the list?
php artisan cache:clear # is a stale compiled list hiding a change?
php artisan erag:sync-disposable-email-list # is the list just old?
Then Disposable::check($email)->toArray() on the exact address to see source() and matchedDomain(). That resolves nearly every report in under a minute.
How can I help you explore Laravel packages today?