g4t / email-rule
A self-contained Laravel validation rule that verifies email addresses are real and deliverable — syntax, DNS, MX, disposable providers, role mailboxes and an optional SMTP probe.
Requires
- php: ^8.2
- ext-intl: *
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- illuminate/validation: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
README
A self-contained Laravel validation rule that checks an email address is real and deliverable — not just that it looks like an email.
use G4T\EmailRule\Rules\VerifiableEmail; $request->validate([ 'email' => ['required', new VerifiableEmail], ]);
user@gmial.com passes Laravel's built-in email rule. It does not pass this one.
No API key, no external service, no database table. Install it and it works.
What it checks
| Check | Default | Notes |
|---|---|---|
| RFC syntax | ✅ rejects | Length limits, dot placement, character set |
| Domain resolves | ✅ rejects | A/AAAA/MX lookup |
| Domain accepts mail | ✅ rejects | MX records, including RFC 7505 null MX |
| Disposable provider | ⚙️ opt-in | Bundled blocklist, subdomains included |
| Role mailbox | ⚙️ opt-in | support@, info@, … |
| Mailbox exists | ⚙️ opt-in | Live SMTP handshake |
| Catch-all domain | ⚙️ opt-in | Probes a decoy address |
The defaults are the fast, safe ones. Everything that requires a network round trip beyond DNS, or that encodes a product decision rather than a correctness one, is opt-in.
Installation
composer require g4t/email-rule
The service provider is auto-discovered. Publish the config only if you want to change the defaults:
php artisan vendor:publish --tag=email-rule-config php artisan vendor:publish --tag=email-rule-lang
Requires PHP 8.2+, Laravel 11/12/13, and the intl extension (for
internationalised domains).
Usage
The rule object
use G4T\EmailRule\Rules\VerifiableEmail; // Syntax + DNS + MX. Fast, no SMTP. 'email' => ['required', new VerifiableEmail], // Block throwaway providers 'email' => ['required', VerifiableEmail::make()->rejectDisposable()], // Block shared mailboxes too 'email' => ['required', VerifiableEmail::make()->rejectDisposable()->rejectRole()], // Actually ask the mail server whether the mailbox exists (slow) 'email' => ['required', VerifiableEmail::make()->withSmtp()], // Everything on 'email' => ['required', VerifiableEmail::strict()],
The string rule
For rules built from configuration, or when you prefer strings:
'email' => 'required|verifiable_email', 'email' => 'required|verifiable_email:disposable,role', 'email' => 'required|verifiable_email:strict',
Parameters: disposable, role, smtp, catch_all, fail_closed, strict.
Outside a validator
use G4T\EmailRule\EmailVerifier; $verification = app(EmailVerifier::class)->verify('user@example.com'); $verification->isClean(); // bool $verification->findings(); // [Finding::Disposable, ...] $verification->toArray(); // full detail, ready to log or store
Error messages
Each finding has its own message, so the user is told what is actually wrong:
| Finding | Message |
|---|---|
invalid_syntax |
The email must be a valid email address. |
unresolvable_domain |
The domain of the email does not exist. |
no_mail_exchanger |
The domain of the email cannot receive email. |
disposable |
The email cannot be a temporary or disposable address. |
role |
The email must be a personal address, not a shared mailbox. |
catch_all |
The email could not be confirmed as a real mailbox. |
mailbox_rejected |
The mail server rejected the email. Please check it for typos. |
Publish the language files to change them, or add your own locale under
lang/vendor/email-rule/{locale}/validation.php.
Two decisions worth understanding
It fails open
If DNS is unreachable or the mail server never answers, the address is accepted.
That is deliberate. Rejecting a real customer because your resolver had a bad minute costs you a signup; accepting one questionable address costs you a bounced email. If you would rather have the opposite trade-off:
VerifiableEmail::make()->failClosed()
or set email-rule.fail_open to false.
SMTP is off by default
An SMTP handshake takes seconds, and this rule usually runs inside a form
submission. Without it, the strongest claim the rule can make is "this domain
accepts mail" — which is why no-such-user@gmail.com passes the defaults.
Proving a specific mailbox exists means asking the mail server.
Turn it on per rule:
VerifiableEmail::make()->withSmtp()
…or change the default for every rule:
EMAIL_RULE_SMTP=true EMAIL_RULE_SMTP_HELO=verify.yourdomain.com EMAIL_RULE_SMTP_FROM=verify@yourdomain.com
EMAIL_RULE_SMTP is the default, not a master switch: a rule that asks for
->withSmtp() probes either way, and ->withoutSmtp() opts out either way. To
hard-disable SMTP across an entire application, bind the null probe:
$this->app->bind(SmtpProbe::class, NullSmtpProbe::class);
EMAIL_RULE_SMTP_HELOandEMAIL_RULE_SMTP_FROMmust belong to a domain you control, with matching forward and reverse DNS. Otherwise mail servers greylist the probe and every answer degrades to "unverifiable" — which, since the rule fails open, means the check silently does nothing.Most cloud providers also block outbound port 25 by default.
For a signup form that wants SMTP-grade certainty, the better pattern is to accept the address immediately and verify it on a queue:
dispatch(function () use ($user) { if (! VerifiableEmail::strict()->passes($user->email)) { $user->flagEmailUndeliverable(); } });
Configuration
// config/email-rule.php 'reject' => [ 'invalid_syntax' => true, 'unresolvable_domain' => true, 'no_mail_exchanger' => true, 'disposable' => false, 'role' => false, 'catch_all' => false, 'mailbox_rejected' => true, ], 'fail_open' => true,
These are the defaults for every rule instance; a fluent call on a rule always wins over the config.
Disposable domains
The bundled list covers the providers seen most often and is deliberately conservative — a false positive rejects a real customer.
'disposable' => [ // Replace the bundled list with your own newline-delimited file 'list_path' => storage_path('app/disposable-domains.txt'), // Or extend it 'extra' => ['annoying-provider.com'], // Allow always wins, for providers your customers legitimately use 'allow' => ['fastmail.com'], ],
Subdomains are matched automatically: blocking trashmail.com also blocks
inbox.trashmail.com.
To sync a full upstream blocklist:
php artisan vendor:publish --tag=email-rule-blocklist curl -o storage/app/disposable-domains.txt \ https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf
Caching
DNS answers are cached for 24 hours through your application's cache, so a form with several email fields — or a bulk import — does not repeat lookups.
'cache' => ['enabled' => true, 'store' => 'redis', 'ttl' => 86400],
Swapping the internals
Both network seams are interfaces. Bind your own to use DNS-over-HTTPS, a third-party verification API, or a fake in tests:
$this->app->bind(DnsResolver::class, DohResolver::class); $this->app->bind(SmtpProbe::class, MyProbe::class);
That is exactly how this package's own test suite runs without touching the network.
// In your tests app()->instance(DnsResolver::class, (new FakeDns)->withMx('example.com'));
Testing
composer test # 114 tests composer lint # Pint composer analyse # PHPStan level 6
Licence
MIT.