harungecit/php-email-validator
A lightweight PHP email validator that checks addresses for correct syntax and common formatting rules, helping you reject invalid input in forms, signups, and APIs. Install via Composer and drop it into any PHP project or framework.
composer require harungecit/php-email-validator
php artisan vendor:publish --tag=email-validator-config
The package auto-discovers on Laravel (through Laravel 13), so there is no provider registration. Start by reading two things: config/email-validator.php (lists, block_subdomains, sync.*, format mode, smtp.from_domain) and the README changelog, since the behaviour notes below apply to anyone upgrading from 3.1.
The first use case is registration. Add the package's disposable-email rule object to the email field of your RegisterRequest, after Laravel's own email rule so malformed input fails before you touch the list:
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255', new NotDisposableEmail()],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
];
}
Verify from the console before wiring anything up:
php artisan email-validator check [email protected]
php artisan email-validator stats
check tells you whether it matched and why; stats confirms the bundled list is present and current. Do this first — if the list is stale or the config isn't published, every later debugging session starts from the wrong premise.
Keep the rule objects in FormRequests, not controllers. The rules are plain validation objects, so they compose with Rule::when(), bail, and your existing email rule. Put disposable checks on registration, invites, and newsletter signup. Do not add them to login or password-reset validation — the list grew ~15×, and blocking an existing user from authenticating because their domain was newly classified is a support incident, not a security win.
Treat custom_allowlist as configuration, not code. Business partners, your own test domains, and anything your support team keeps whitelisting belong under lists.custom_allowlist. Keep that array in config/email-validator.php under version control so it survives deploys, and report genuine false positives upstream rather than accumulating ad-hoc overrides.
Use explainable detection for observability, not just pass/fail. checkDisposable() returns the matched domain, its source (curated list vs. aggregator), and whether the match came via a subdomain. Log all three on rejection. When someone disputes a block, "matched foo.com from the aggregator via subdomain" is a resolvable ticket; "invalid email" is not. Feed the same signal into your fraud metrics — a spike in aggregator-sourced matches in one signup cohort is a different problem from a broad spread.
Escalate to isSpoofed() on high-value flows. Homograph detection matters for payouts, email-change confirmation, and password reset — anywhere an attacker benefits from a visually identical domain. Combine it with manual review rather than a hard 422; legitimate users with unusual IDN domains exist, and false positives here are expensive.
Sync on a schedule, from one place.
Schedule::command('email-validator sync')
->daily()
->withoutOverlapping()
->onOneServer();
Writes are atomic and locked, so concurrent runs won't corrupt the file, but onOneServer() still saves pointless work on multi-node deployments. Sync is append-only by default — plan for the fact that it adds domains and never removes them, and that curated removals only arrive with a package release.
Pick your format mode deliberately per surface. strict for public signup, filter or filter_unicode for bulk imports and legacy data where you care about deliverability more than purity. rfc mode will accept things your downstream mailer won't. Mismatched modes between registration and password reset are a classic lockout.
Use getListStatistics() in a health check. A stale list degrades silently. A small Artisan command or scheduled report that asserts list age and domain count catches a broken sync (blocked egress, proxy returning HTML) before your fraud team does. The CLI (vendor/bin/email-validator check|validate|sync|stats) is framework-agnostic, so it also drops into CI to validate seeders and fixtures without booting the app.
Blade directive for cheap conditional UI. Use it to tailor copy ("use a permanent address") rather than to hide fields — client-side hints can't replace server-side validation, and the directive reads the same list.
The list is ~15× larger, so your false-positive rate will move. The build filters public suffixes, Tranco top-100k domains, provider typos, and subdomains of allowlisted domains — but 136,577 entries on an aggregator-derived base is not the same thing as a curated one. Roll out with logging-only first (call checkDisposable() and log, don't reject) for a week, inspect the rejections, allowlist what's legitimate, then enforce.
custom_blocklist / addToBlocklist() now beat the bundled allowlist. This is a real behaviour change. If you previously blocklisted a domain as a workaround for a false positive, or to block a provider the package allowlists, that entry now wins. Audit the array on upgrade — the semantics inverted under you.
smtp.from_domain reads EMAIL_VALIDATOR_SMTP_FROM_DOMAIN now. Rename the variable in every environment, then php artisan config:clear. A silent fallback to a default domain here causes SPF/DMARC alignment failures that look like deliverability problems, not config problems.
Custom typo mappings are additive. They no longer replace the built-ins, so duplicates in your config are harmless but confusing. Prune them and note in a comment that the package ships its own.
OPcache means a sync isn't instantly visible. The compiled index is the feature, but with opcache.validate_timestamps=0 (or a long revalidate TTL) FPM workers keep serving the old list, and CLI and FPM have separate OPcache instances — a php artisan email-validator sync run does not refresh the web workers. Reload PHP-FPM after sync, or run sync at deploy time. Same applies under Octane, where you need octane:reload for config or list changes to take effect.
Failed syncs are quiet by design. HTML responses and empty bodies are rejected to protect the list, which means a corporate proxy or captive portal produces a no-op, not an exception. Log sync outcomes and alert on consecutive failures — this is precisely what stats is for.
block_subdomains is a sharp tool. Enabling it broadens every entry to its subdomains, which is correct for real disposable providers and wrong for anything that slipped past the allowlist filters. The bundled allowlist includes subdomains like nyc.rr.com specifically to handle this; if you enable the flag, verify against your most common ISP and relay domains first.
Writes need the right user. If you use custom file or directory paths for the synced list, the web/FPM user needs write access, not just your deploy user. This works in staging and fails in production more often than any other config issue here.
Privacy relays are not disposable. SimpleLogin and Proton Pass aliases are in the 237-domain allowlist on purpose. If your product blocks them by policy, do it explicitly in custom_blocklist with a comment explaining why — don't assume the list did it for you.
Multi-tenant overrides don't compose cleanly. The list is global config. Per-tenant allowlists require config()->set() before validation, which conflicts with config caching and long-running workers. If tenants genuinely need different rules, wrap the check in your own Rule object that reads tenant settings and delegates to checkDisposable(), rather than mutating global config per request.
How can I help you explore Laravel packages today?