mattketmo/email-checker
PHP library that validates email addresses beyond a simple regex: it checks RFC compliance and DNS MX records, and can connect to the SMTP server to confirm the mailbox actually exists. Framework-agnostic, so it drops into any PHP or Laravel app.
Install the library and confirm which API your pinned version exposes — the package has changed shape across majors (older releases centered on a check()/checkEmail() call returning status constants, current ones expose isDisposable() on EmailChecker\EmailChecker).
composer require mattketmo/email-checker
use EmailChecker\EmailChecker;
$checker = new EmailChecker(); // loads the bundled disposable-domain list
$checker->isDisposable('[email protected]'); // true
$checker->isDisposable('[email protected]'); // false
Places to look first in vendor/mattketmo/email-checker:
src/EmailChecker.php — the public surface you will actually call.src/Adapter/ — the adapter interface plus array/JSON-file implementations; this is how you supply your own domain list instead of the bundled one.data/ (or resources/) — the shipped JSON file of disposable domains. Skim it; it tells you exactly what counts as "disposable" and where false positives will come from.First real use case: gate the registration endpoint. Bind a singleton in a service provider so the JSON list is parsed once per request/worker, not once per validation call:
// app/Providers/AppServiceProvider.php
use EmailChecker\EmailChecker;
public function register(): void
{
$this->app->singleton(EmailChecker::class, fn () => new EmailChecker());
}
Then a Laravel 10+ validation rule:
namespace App\Rules;
use Closure;
use EmailChecker\EmailChecker;
use Illuminate\Contracts\Validation\ValidationRule;
final class NotDisposable implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
$email = mb_strtolower(trim($value));
if (app(EmailChecker::class)->isDisposable($email)) {
$fail('Please use a permanent email address — disposable inboxes are not accepted.');
}
}
}
$request->validate([
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email', new NotDisposable()],
]);
Tiered policy instead of a blanket block. Hard-blocking disposable addresses on free signups is fine; blocking them on a paid checkout or a B2B lead form costs conversions. Practical split:
// app/Support/EmailPolicy.php
public function handle(string $email, string $context): string
{
$disposable = app(EmailChecker::class)->isDisposable(mb_strtolower($email));
return match (true) {
! $disposable => 'allow',
$context === 'checkout' => 'allow', // take the money, flag the account
$context === 'newsletter' => 'shadow', // accept silently, low-value list
default => 'reject',
};
}
For shadow/allow cases, persist a boolean (users.disposable_email) so support and fraud review can filter later. You get the signal without the bounce.
Pair it with a syntax rule and, optionally, an MX check. This library is a domain blocklist, nothing more — it does not validate format or deliverability. Laravel's email rule (which uses egulias/email-validator) plus dns_get_record($domain, DNS_MX) covers the rest:
public function passes(string $email): bool
{
$domain = Str::afterLast($email, '@');
return checkdnsrr($domain, 'MX') || checkdnsrr($domain, 'A');
}
Cache MX results for a day — DNS lookups on every registration will hurt.
Allow your own overrides on top of the shipped list. Wrap the library rather than calling it directly everywhere, so you can subtract the domains your users legitimately use and add the ones that keep slipping through:
// config/email-checker.php
return [
'allow' => ['my-privacy-provider.example'], // never treat as disposable
'disposable' => [], // extra domains you've discovered
];
final class DisposableDomains
{
public function __construct(private EmailChecker $checker) {}
public function isDisposable(string $email): bool
{
$domain = Str::lower(Str::afterLast(trim($email), '@'));
if (in_array($domain, config('email-checker.allow'), true)) {
return false;
}
if (in_array($domain, config('email-checker.disposable'), true)) {
return true;
}
return $this->checker->isDisposable($email);
}
}
This wrapper is also your escape hatch if the package goes stale — the rest of the app only ever sees DisposableDomains::isDisposable().
Custom adapters for a bigger list. The list file is the package's real value, and it's a plain JSON array of domains. If you maintain a merged blocklist (yours + theirs), the adapter interface lets you feed it in without forking:
use EmailChecker\Adapter\ArrayAdapter;
$domains = json_decode(file_get_contents(storage_path('app/disposable.json')), true);
$this->app->singleton(EmailChecker::class, fn () => new EmailChecker(new ArrayAdapter($domains)));
Bulk/backfill work belongs in a job. Auditing an existing user table, or re-scanning after a list refresh, should be a queued command that chunks records — don't loop the checker over 500k rows inside a request.
Testing. Never let tests depend on the shipped list contents — a composer update will break them. Bind a fake in TestCase::setUp():
$this->app->instance(EmailChecker::class, new class extends EmailChecker {
public function isDisposable(string $email): bool
{
return str_ends_with($email, '@disposable.test');
}
});
With constructor-promoted EmailChecker in the rule, resolve the rule from the container (app(NotDisposable::class)) rather than new NotDisposable() so the fake is injected.
Surfacing it to the user. Return a distinct error message and a 422 with a specific code (email_disposable) so the frontend can suggest a different address or an "I use this provider, let me in" support path.
[email protected], [email protected] and [email protected] must all be normalized before the check. mb_strtolower(trim($email)) first; verify your version's behavior on subdomains ([email protected]) empirically rather than assuming — if it doesn't match parent domains, strip to the registrable domain yourself with a PSL-aware helper.isDisposable() result stand in for 'email' in your ruleset.composer install --no-dev --optimize-autoloader, vendor pruning, or unusual opcache/preload setups, confirm the data file is actually present — a missing file may throw or silently behave unexpectedly in production but not locally.EmailChecker per validation reads and decodes the JSON every time. Bind it, and be aware that long-running workers (Octane, queue consumers) hold that instance — if you reload the list at runtime, refresh the container binding too.isValid(), checkEmail() and status constants exist in some versions and not others. Pin an exact constraint in composer.json and read the changelog before upgrading; a minor-looking bump can remove the method your rule calls.use EmailChecker\... across fifty files — the wrapper interface above costs an hour and makes replacement trivial.How can I help you explore Laravel packages today?