propaganistas/laravel-disposable-email
Laravel validator that rejects disposable email addresses from services like Mailinator and Guerillamail. Ships with a built-in blacklist (auto-updated weekly), a config file, the indisposable rule, and an optional command to refresh domains yourself.
Install and publish in one sitting.
composer require propaganistas/laravel-disposable-email
php artisan vendor:publish --tag=laravel-disposable-email
The service provider is auto-discovered — there is no registration step. Read config/disposable-email.php immediately after publishing; it is short, and the three things that matter are the domain source list(s), the fetcher hook, and (optionally) your cache store. Skim the bundled domains.json to get a feel for scale and false-positive risk (it's large; that's the point).
First use case: the registration FormRequest. Add the rule after email so format validation runs first:
public function rules(): array
{
return [
'email' => ['required', 'email', 'indisposable'],
];
}
Add the translation line so the error is user-readable rather than a raw key (validation.indisposable is derived automatically, but the package deliberately doesn't ship translations — you supply them):
// lang/en/validation.php
'indisposable' => 'Disposable email addresses are not allowed.',
Where to look when something behaves unexpectedly, in order: (1) config/disposable-email.php — did you publish it, and is the source list what you think it is; (2) the resolved domain list — the package caches it statically, so a stale process is a real suspect; (3) DNS, if you enabled :mx, since that path is the only one that leaves the machine.
Scope the rule to acquisition flows only. Registration, trial start, invite acceptance, newsletter signup — yes. Login, password reset, email change on an existing account — no. Blocking at login means a user who registered with a now-flagged domain gets locked out of a working account, which is a support ticket you cannot resolve by asking them to change email. Keep the rule in the RegisterRequest / CreateTrialRequest classes and out of LoginRequest.
Gate it per surface with a config flag so you can kill it without a deploy:
'email' => array_filter([
'required', 'email',
config('features.block_disposable_email') ? 'indisposable' : null,
]),
Or use Rule::when($this->isHighRiskSignup(), 'indisposable') when only the free-tier/trial path should pay the cost.
Use the service directly for warn-only flows. Blocking is the crude option; the package also exposes isDisposable() / isNotDisposable() on its DisposableDomains service. That lets you downgrade to soft enforcement — flag the account for review, require email verification before granting credit, log to your fraud pipeline, or queue a manual check — without rejecting the signup:
$isDisposable = app(DisposableDomains::class)->isDisposable($request->email);
This is the pattern to reach for when product asks "can we not just block them?" A flagged-but-allow rule with extra friction usually beats a hard rejection.
Reserve :mx for the endpoints where abuse actually costs you. MX inspection resolves DNS during validation and catches rotated front domains whose mail servers point at a known backend. Put it on trial/free-credit signup, not on every contact form:
public function rules(): array
{
return ['email' => ['required', 'email', 'indisposable:mx']];
}
Consider wrapping the whole request in a short circuit-breaker or timeout budget — see Gotchas.
Schedule disposable:update and forget the weekly release cadence. The package ships weekly patch releases that only bump the domain list. If your team doesn't bump dependencies weekly, schedule the command instead:
// routes/console.php
Schedule::command('disposable:update')
->daily()
->withoutOverlapping()
->onOneServer();
This is the single highest-leverage maintenance decision with this package. Do it on day one, not after someone notices the list is a year stale.
Write a custom fetcher when egress is restricted. If the app sits behind a proxy or a locked-down egress policy, the default file_get_contents() path will fail silently and the list will never refresh. Point disposable-email.fetcher at your own class that uses the HTTP client with your proxy/timeout/retry config. Keep it deterministic and injectable so disposable:update is testable:
'fetcher' => App\Support\DisposableEmailFetcher::class,
Test at the validator boundary, not through HTTP. The rule is a Laravel validator; assert on it directly so tests are fast and hermetic:
$v = Validator::make(['email' => '[email protected]'], ['email' => ['email', 'indisposable']]);
$this->assertTrue($v->fails());
$this->assertSame('validation.indisposable', $v->errors()->first('email'));
Add one test asserting your message key resolves, and one asserting a normal @company.com address passes — that second test is your canary for a corrupted or empty domain list.
Coverage decays silently. Nothing warns you that your bundled list is six months old. Either schedule disposable:update or put the dependency on a weekly bump rotation. If you neither bump nor schedule, you have a rule that passes its tests and stops doing its job — the worst failure mode.
Static caching means long-lived processes can serve a stale list. The package caches loaded domains statically per process (there's a release note about this going back to 1.0.1) in addition to whatever cache store backs it. Under Octane, or in queue workers, an update command run elsewhere may not be visible until the worker reloads. If list freshness is critical on your hot path, verify behavior against your actual runtime and restart workers as part of the update schedule.
Pin at least 2.1.16. That release fixed an unserialize() error with Redis as the cache driver. Since the domain list is cached, validate serialization against your cache driver — Redis, DynamoDB, and database caches all behave differently with large payloads. A corrupted cache here manifests as "everyone is disposable" or "nobody is."
indisposable:mx puts DNS on your validation hot path. A slow or failing resolver degrades signup latency, and depending on packaging, an exception during resolution can reject valid users. Decide explicitly: fail-open (treat resolver errors as "not disposable") is almost always correct for a hygiene control, but you must confirm the package does that on timeout rather than bubbling. Don't enable mx on a path that must work offline (CLI imports, batch backfills, CI).
MX inspection also has false positives. Shared mail infrastructure serves legitimate senders too. If a corporate domain's mail routes through a provider whose MX appears on the list, you block a real customer. This is why :mx belongs on the free-credit path with a human recovery route, not on account recovery.
Don't drop email in favor of indisposable. indisposable is a domain-list membership check, not format validation. Always ['email', 'indisposable'].
Subdomains are checked as of 2.4.0 — the rule matches the full domain, not just the registrable root. That closes [email protected], but it also means you should sanity-check any internal or partner domains that legitimately use deep subdomains.
Locked-down egress fails quietly. If disposable:update runs in a container with no outbound access to the source URLs, the command fails and — unless you alert on it — the list silently stops advancing. Monitor the scheduled command's exit status; a "successful" deployment with a permanently failing refresh is the common failure.
Upgrade-window drift is a real blocker, not a nice-to-have. 2.3.0 dropped Laravel 8/9 and PHP 8.0; 2.4.11 added Laravel 12; 2.4.25 added Laravel 13. This dependency gets trimmed aggressively. Put it on the Laravel-major upgrade checklist, or it will be the package that holds the framework bump hostage.
Keep a fallback plan. Single primary maintainer, 0 known dependents, no SLA. That's fine for a utility validator, but don't build product logic around its internals — depend on the indisposable rule string and the two public helper methods, not on the service's internals. If the package is ever abandoned, the bundled domains.json plus a 20-line custom rule is a trivial replacement, and having that exit in mind keeps you from over-integrating.
Give support a script. When a legitimate user is blocked, whoever handles the ticket needs a way to check why. A one-liner in tinker (app(DisposableDomains::class)->isDisposable($email), plus a lookup of the matched domain in the list) turns a 20-minute investigation into a copy-paste.
How can I help you explore Laravel packages today?