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.

Maintainers

Package info

github.com/domainvalidity/php-email-signals

pkg:composer/domainvalidity/php-email-signals

Transparency log

Statistics

Installs: 55

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.2.0 2026-08-20 10:42 UTC

This package is auto-updated.

Last update: 2026-08-20 12:14:11 UTC


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. disposable does not outrank relay.
  • 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, and decide() 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 Budget you 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.