emailsherlock/email-guard-core
PHP guard for signup and checkout email fields: offline checks catch bad syntax, reserved TLDs, and 73k+ disposable domains with no network calls. Add an EmailSherlock API key for live MX, SMTP, role and catch-all detection. PHP 8.1+.
1. Install and pin. There is no Laravel package wrapper here — this is a framework-agnostic PHP 8.1+ library, so composer require it and expect to write the glue yourself. Because the project is pre-1.0 in spirit (single-digit stars, no dependents, 0.x-style churn risk), commit the lockfile and pin a constraint you're comfortable with.
composer require emailsherlock/email-guard-core
2. Read in this order:
EmailGuard constructor options, check(), $result->action, isDenied(), $result->reasons).Emailsherlock\EmailGuard\Action enum — Allow / Deny / Review is the whole control-flow contract.Review.vendor/emailsherlock/email-guard-core/src/ — the config key list is only partially documented, so skim the constructor for accepted options.3. First use case: signup form, local-only. Start with zero config, no API key, before wiring any policy. It costs nothing and makes no network calls:
use Emailsherlock\EmailGuard\EmailGuard;
$guard = new EmailGuard(); // local checks only: syntax, reserved TLDs, bundled disposable snapshot
$result = $guard->check($email);
if ($result->isDenied()) {
// reject the field, surface a generic message
}
That alone covers the highest-value case in the README: syntactically valid junk like [email protected] and 73k known disposable domains, at zero latency. Ship that first; add the api_key escalation later once you know your abuse volume and your API budget.
Bind it once in a service provider. The library has no service provider of its own, so register a singleton and keep configuration in config/email-guard.php:
// config/email-guard.php
return [
'api_key' => env('EMAILSHERLOCK_API_KEY'), // null disables escalation
'block_on' => ['invalid', 'disposable'],
'review_on' => ['catch_all'],
];
// AppServiceProvider::register()
$this->app->singleton(EmailGuard::class, fn () => new EmailGuard(config('email-guard')));
Never new EmailGuard() at call sites — you'll end up with inconsistent policy across controllers and no single place to flip the API key on.
Wrap it behind your own interface. The value of this pattern is that it lets you swap in a fake in tests and drop the dependency later if the package stalls:
interface EmailChecker { public function check(string $email): Result; }
Bind EmailChecker to an adapter around EmailGuard, and to a stub in TestCase. You do not want feature tests hitting the Verify API.
As a validation rule, for the Deny path only:
class GuardedEmail implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$result = app(EmailChecker::class)->check((string) $value);
if ($result->isDenied()) {
$fail('Please use a different email address.');
}
}
}
Keep Laravel's built-in email rule alongside it — the guard's local syntax check is not a replacement for RFC validation, and the rule runs cheaply.
Handle Review outside validation. A validation rule can only pass or fail; Action::Review means "accept but hold" (typically a catch-all domain where you want a confirmation email or manual check). Model that as a column on your user/lead record (email_status = review) and act on it in the same flow that sends the verification mail — not inside the FormRequest.
Escalate in two tiers. Local checks are free; API calls cost money and latency. Only escalate when local passes and the form is worth the spend (registration with real downstream value, not newsletter signup). The local snapshot is domain-scoped, so cache the outcome by domain, not by address — a disposable-domain decision or MX result doesn't change per mailbox:
$verdict = Cache::remember("email-guard:{$domain}", now()->addDay(), fn () => $guard->check($email));
Skip caching the SMTP-probe/role parts per domain; those are mailbox-level.
Store the reasons, not just the boolean. $result->reasons is your only debugging trail when a real customer gets rejected. Persist it (or a hash plus the reason codes) on the record and log action + reasons — never the raw address, which is PII.
Batch/backfill with a queued job. For imported lists, run the guard in a job with retry/backoff on the API path, and treat API errors as "allow, flag for later" so a provider outage doesn't silently drop signups.
api_key is empty or misspelled, you get local-only checking and no error — the result simply never contains the network-derived reasons (MX, SMTP, role, catch-all). Assert once at boot in local/staging that a configured key actually produces escalated reasons for a known probe address.composer update on a cadence, or use the API's fresh list for the deny decision and treat the bundled list as a fast pre-filter.block_on / review_on defaults are opinionated. Default blocking is ['invalid', 'disposable'] and review is ['catch_all'] only. Role accounts (admin@, noreply@) are detected but not in either list — decide explicitly whether you want them blocked, and remember role detection is only available on the API path.src/) and catch transport exceptions, falling back to the local verdict. A hanging verify call in a FormRequest is a self-inflicted outage.Review rather than Allow — don't promote it to a hard pass.deleted+user274@... example keeps the tag. Do any normalization you care about upstream, and do it consistently, or your domain cache keys will fragment.Action is an enum — use exhaustive match, and give Review a real branch. The README's match ends in Review ...; if you copy a two-branch version, catch-all addresses will either fall through or fatal. Add a default throw, or handle all three arms.Result isn't strictly serializable, caching it directly can produce confusing partial objects on hydrate. Map to a small array (action, reasons) before caching, and rebuild on read.EmailGuard, keep it behind your EmailChecker adapter — that's your seam for swapping the library out (or adding a second provider) when you need to, and the seam is what makes the near-zero adoption count a manageable risk rather than a lock-in.How can I help you explore Laravel packages today?