Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Email Guard Core Laravel Package

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+.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  • The README usage block (EmailGuard constructor options, check(), $result->action, isDenied(), $result->reasons).
  • The Emailsherlock\EmailGuard\Action enum — Allow / Deny / Review is the whole control-flow contract.
  • The linked email-guard-spec repo. The spec, not the README, is where the reason codes and policy semantics are actually defined; you'll want it when deciding what to do with a 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.

Implementation Patterns

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.

Gotchas and Tips

  • A misconfigured API key fails open, silently. If 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.
  • The bundled disposable list is a snapshot. It's a static file in the package: it goes stale between releases. Either schedule 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.
  • Never call the API inline without a timeout. This is a synchronous guard in your request/validation path. Set a short timeout in the transport config (check the constructor options in src/) and catch transport exceptions, falling back to the local verdict. A hanging verify call in a FormRequest is a self-inflicted outage.
  • Catch-all domains are inherently ambiguous. SMTP probes against a catch-all accept every address, so an "allow" there means little. That's precisely why the spec routes it to Review rather than Allow — don't promote it to a hard pass.
  • Normalize before checking. Trim, and lowercase the domain (not necessarily the local part). The library is not going to apply Gmail dot-stripping or plus-tag folding for you — the README's own 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 objects may not round-trip through the cache. If 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.
  • Extension point is the config array, not inheritance. Rather than subclassing 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.
  • This is one layer, not your abuse strategy. Disposable-email blocking does nothing against a determined attacker with a real mailbox. Keep rate limiting, email verification, and (if it matters) a honeypot in place.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc