A composable signal pipeline for email addresses. Every check emits a signal; you decide what the signals mean.
composer require domainvalidity/php-email-signalsuse DomainValidity\EmailSignals\Factory;
// Every bundled check, fully wired: discovered HTTP client and cache,
// downloaded community lists, DNS behind a one-hour cache
$pipeline = Factory::batteriesIncluded();
$report = $pipeline->inspect('first.last+shopping@gmail.com');
$report->labels(); // every label that matched
$report->signalOrFail('mx')->fact('has_mx'); // true
$report->address()->canonical(); // 'first.last@gmail.com'
$report->toArray(); // persist it next to the recordThen make your own call — the package never makes it for you:
$refuseSignup = $report->hasLabel('disposable') || $report->hasLabel('no-mx');A check returns a signal or declines to answer. Every registered check emits exactly one signal, with one of five outcomes:
Matched · Clear · Abstained · Skipped (with a reason) · Errored
So a payload never has silent gaps — "we checked and it was fine" is distinguishable from "we never asked", which a boolean cannot express. That is what makes local-first pipelines work: order your checks cheapest first, and an expensive one reads the context and skips itself, leaving a signal saying so.
- No verdicts. Nothing returns "valid", "safe" or a score. There is
deliberately no
isValid(). - No policy. Refusing a signup, suppressing an address, routing to review — those are decisions about your product, made in your code.
- No privileged labels.
disposabledoes not outrankrelay. - No storage, no egress. The package opens no database, file or socket of its own, and performs no DNS or HTTP unless you wire it.
- No list data, no vendor integrations. Curating lists is upstream's job; talking to a paid verifier is a plugin's job.
- No SMTP probing, and none is coming. Mailbox-existence probing from a library, at consumer scale, gets your IPs blocklisted.
Want an opinionated layer? Build it on top. That is the intended shape, and it is why this is MIT.
Start with the first two; reach for the rest when you need them. Not sure what exists? Everything it can do is the capability index — costs and budgets, batch inspection, decision provenance, diffing, observability, your own data as signals, and more.
| Page | What it covers |
|---|---|
| Getting started | Install, your first pipeline, the one-line factories |
| Concepts | Check, Signal, Report, Outcome, Context, scope, facts |
| Checks | Every bundled check, and writing your own |
| Your own data | Your users table, counters and past reports as signals |
| Decisions | Named rules, decision provenance, the payload, diffing |
| Lists | Downloading, caching, refusing a bad list, re-deriving |
| At scale | Batch inspection, cost and budget, observability |
| Subaddressing | What canonical() claims, and the RFCs behind it |
| Testing | The shipped fakes, and the plugin contract |
| API reference | Every public method, exactly as declared |
| Laravel | A worked framework integration |
The same pages also work as a browsable docsify site. Serve it locally with either of:
npx docsify-cli serve docsphp -S localhost:4173 -t docsOr host it by enabling GitHub Pages for the docs/ folder — the site then lives
at https://domainvalidity.github.io/php-email-signals/.
Also: changelog · security policy
A few things the pipeline shape buys you, each covered in the docs:
- Your own data is a signal source. A users table, counters per domain, the reports you kept — through a small interface, seeing only arrays.
- Auditable. Every list-backed signal names the entry that matched and a
digest of the list in force.
fromArray()reads a payload back,diff()says what moved since, anddecide()records which checks your rule read. - Scoped, so it scales. Each signal says what it is about, and
inspectMany()uses that to run a domain check once per domain across a whole list. - Cost is visible. A check can declare what a run costs; a
Budgetyou keep can refuse it before it runs.
PHP 8.2+. One library —
domainvalidity/php-domain-validator
for Public Suffix List handling — plus PSR interfaces only, with no
implementations: PSR-18/17 (used only by ListFetcher), PSR-16 (only where you
pass a cache) and PSR-20 (only if you want inspected_at). You bring your own
HTTP client, cache and clock — or let ListFetcher::discover() find the ones
already installed (Guzzle, Symfony, Nyholm, Laminas, Slim, symfony/cache),
with no discovery dependency: it is built in.
None of those implementations is actually required. With lists read from
disk the whole package runs on plain PHP — MX included, since the bundled
resolver uses dns_get_record(), and caching included, since the shipped
FilesystemCache is itself a PSR-16 implementation. Downloading lists over
the network is the one thing that needs a PSR-18 client and PSR-17 factory.
See what actually needs a PSR implementation.
MIT — see LICENSE.
