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.

Maintainers

Package info

github.com/hussein4alaa/laravel-email-rule

pkg:composer/g4t/email-rule

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.0.1 2026-08-02 18:14 UTC

This package is auto-updated.

Last update: 2026-08-02 18:17:15 UTC


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_HELO and EMAIL_RULE_SMTP_FROM must 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.

laravel-email-rule