madeitbelgium/laravel-email-domain-validation
A Laravel validation package that checks email domains via DNS/MX records, helping reject invalid or undeliverable addresses. It adds a simple rule for form requests and validators to improve signup and contact form data quality.
Install and confirm the wiring
composer require madeitbelgium/laravel-email-domain-validation
The service provider is auto-discovered on Laravel 5.5+. If you need to override defaults (resolver, allowlist, timeouts), publish the config:
php artisan vendor:publish --provider="MadeITBelgium\EmailDomainValidation\EmailDomainValidationServiceProvider"
Package tag/config filename varies by version — run php artisan vendor:publish and pick the entry from the list rather than guessing the tag.
Where to look first
README.md — the usage snippet is short; the exact rule FQCN and whether a string alias (e.g. email_domain) is registered is the one thing you must confirm for your installed version.src/Rules/ — the validation rule class is the entire public surface; read it before trusting it in production, it's ~100 lines.config/ — allowlist/blocklist and any DNS settings.First use case: stop typo and dead domains at registration
use MadeITBelgium\EmailDomainValidation\Rules\EmailDomain;
$request->validate([
'email' => ['required', 'string', 'email', new EmailDomain()],
]);
This does what Laravel's built-in email:dns does — resolves the domain part — but with a controllable, testable implementation you can extend. That control is the whole reason to add a dependency here; the DNS check itself is one dns_get_record() call.
FormRequest is the natural home. Put the rule in the rules() method of your registration/profile FormRequest so controllers and Livewire components stay clean:
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new EmailDomain()],
];
}
Order matters — put unique and max before the DNS rule so you don't burn a network round-trip on input that fails cheaply.
Pair with email but never with email:dns. Stacking both doubles the lookup and gives you two different failure messages for the same problem.
Wrap it in your own rule for app-wide policy. The package validates "domain resolves"; most teams actually want "domain resolves AND isn't a disposable provider AND isn't on our blocklist". Compose once:
class WorkEmail implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (in_array(Str::after($value, '@'), config('app.free_email_domains'))) {
$fail('Please use your work email address.');
}
(new EmailDomain())->validate($attribute, $value, $fail);
}
}
Then every FormRequest, API endpoint, and Fortify/Breeze registration flow uses new WorkEmail.
Registration vs. login. Validate the domain on registration and email change only. Running DNS on every login attempt puts a network call on your hottest auth path and locks users out during a resolver blip.
Bulk/import flows. For CSV imports or admin-created users, resolve once and cache per domain rather than per email — a 5,000-row import typically hits 200 distinct domains. Wrap the check in Cache::remember("email-domain:{$domain}", 3600, ...) at the call site; the package won't do this for you.
API surface. Return a field-level 422 rather than a generic error — clients need to distinguish "bad syntax" from "domain doesn't exist" so they can prompt correctly.
Tests will break the moment you enable this. User::factory()->create() uses example.com/example.net, which have no MX records. Either add those to the package's allowlist in config/… for the testing environment, or — better — bind a fake check in TestCase::setUp():
$this->app->bind(EmailDomainChecker::class, fn () => new AlwaysValidChecker());
If the rule instantiates its resolver directly with new, you can't swap it; in that case, guard the rule with app()->environment('production') or use Validator::extend stubs. Read the constructor before assuming it's injectable.
dns_get_record() is blocking and un-timeoutable in PHP. A slow or blackholed resolver stalls the request for the OS default (often 5s+, 2× for retries). Mitigations: only run the rule on low-traffic endpoints, set default_socket_timeout, or front it with your own resolver class behind an interface so you can move it to a queued job later.
No MX ≠ undeliverable. RFC 5321 permits delivery to the A/AAAA record when no MX exists. If the package only checks MX, a handful of small-business domains will be rejected. Verify the behaviour and, if needed, fall back to dns_get_record($domain, DNS_A) yourself.
Punycode and subdomains. bücher.de must be converted to xn--bcher-kva.de before DNS. Str::after($email, '@') is fine for the domain but won't IDN-encode it. idn_to_ascii() first.
Catch-all domains always pass. This rule validates the domain, not the mailbox — [email protected] succeeds. Don't sell it to stakeholders as "verifies the email exists"; it catches typos, dead domains, and dead TLDs, nothing more.
Debugging failures. Reproduce outside Laravel first: dig MX example.com +short or dns_get_record('example.com', DNS_MX). If that works in your shell but the app fails, you're looking at a container DNS problem — check /etc/resolv.conf inside the PHP container, and remember php-fpm and CLI may resolve differently.
Small-package risk. 19 stars and one maintainer means no community safety net. Pin the version in composer.json, and accept that the LGPL-3.0 license (vs. the MIT you'll find on most Laravel packages) is fine to use in closed-source SaaS but has obligations if you fork and redistribute the package itself. Budget an hour to read the source — it's short enough that you can vendor the logic into app/Rules/ and drop the dependency if it ever goes stale or the Laravel constraint lags a major release.
How can I help you explore Laravel packages today?