Laravel Email Rule
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.