domainvalidity / php-email-signals
Composable email signal pipeline for PHP: disposable providers, role accounts, MX facts and your own rules — every check emits a signal, you decide what they mean.
Package info
github.com/domainvalidity/php-email-signals
pkg:composer/domainvalidity/php-email-signals
Requires
- php: ^8.2.0
- domainvalidity/php-domain-validator: ^3.1
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- nyholm/psr7: ^1.8
- pestphp/pest: ^3.0
- phpstan/phpstan: ^1.10 || ^2.0
- phpunit/phpunit: ^11.5
- squizlabs/php_codesniffer: ^3.8 || ^4.0
- symfony/cache: ^6.4 || ^7.0 || ^8.0
- symfony/var-dumper: ^7.0
Suggests
- nyholm/psr7: Lets Testing\FakeHttpClient build responses without a factory of your own
- psr/http-client-implementation: Any PSR-18 client, so ListFetcher can download the community lists
- psr/http-factory-implementation: Any PSR-17 factory, used with the PSR-18 client
- psr/simple-cache-implementation: Any PSR-16 cache, for list contents and MX lookups
README
Doma(in)Validity — Email Signals
A composable signal pipeline for email addresses. Every check emits a signal; you decide what the signals mean.
composer require domainvalidity/php-email-signals
use 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 record
Then make your own call — the package never makes it for you:
$refuseSignup = $report->hasLabel('disposable') || $report->hasLabel('no-mx');
The one idea
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.
What it will not do
- 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.
Documentation
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 docs
php -S localhost:4173 -t docs
Or 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
Beyond a boolean
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.
Requirements
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.
License
MIT — see LICENSE.
