Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Disposable Email Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. config/disposable-email.php — the only file most apps need to touch (whitelist, block_subdomains, cache_enabled, remote_url).
  2. 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.
  3. storage/app/blacklist_file/ — where your project-specific .txt domain lists live.
  4. 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.

Implementation Patterns

Pick the right entry point

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']]);

Guard at the domain layer, not just the form layer

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.

Use check() when you need to explain the rejection

Disposable::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.

Per-project blocklists without forking the package

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.

Whitelist before you get complaints

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.

Enable caching when lookups are hot

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.

Keep the remote list fresh on a schedule

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.

Blade for UI, not for enforcement

@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.

Optional validation modes for stricter gates

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core