Architecture
Architecture
Three layers, with a deliberate split between what is cryptographic and what is policy.
inbound Request ──► VerifyWebhookSignature (route middleware)
│
└─► Webhooks (manager) ── endpoint: secrets, window, replay
│
├─► SignatureScheme ── pure: bytes in, verdict out
│ └─► Hmac ── the only place a MAC is computed or compared
│
└─► ReplayGuard ── single-use enforcement, if enabled
│
▼
VerifiedWebhook ── the only successful outcome
outbound Http::webhookSignature() ──► SignRequestMiddleware (Guzzle middleware)
│
└─► Webhooks (manager) ──► SignatureScheme::sign()
Both directions go through the same manager and the same scheme object, so the canonical string is written once. When signing and verification live in separate classes — or separate packages — they drift, and the drift shows up as deliveries your own receiver rejects.
Schemes are pure
A SignatureScheme takes a message, a set of secrets, and a verification context, and
either returns a VerifiedWebhook or throws. It reads no configuration, calls no clock,
and touches no cache. Everything time-dependent arrives in the VerificationContext.
That is what makes the cryptographic code testable against published vectors from 2018 without disabling anything or mocking anything — the instant of verification is just an argument.
The manager holds the policy
WebhookSignatureManager is where the ambient decisions live: which secrets are currently
valid, what "now" is, whether single-use enforcement is on. Keeping them out of the schemes
means the security-critical code has no configuration surface to get wrong, and the policy
has one auditable home.
It reads configuration per call rather than capturing it at construction, so a test can add an endpoint and a runtime config change takes effect without rebuilding the container.
One place for cryptography
Support\Hmac is the only class that computes or compares a MAC. Both operations delegate
to PHP's vetted primitives — hash_hmac() and hash_equals(). Nothing is hand-rolled.
The value of the concentration is not the four lines of code; it is that no scheme can
introduce a === comparison, pick an algorithm off the wire, or return early on the first
matching secret. Those are properties of one file rather than of eight.
Deny-by-default, expressed in the types
Verification returns VerifiedWebhook or throws. There is no boolean to ignore and no
partially-trusted intermediate state, so a caller cannot act on a payload that was never
proven. The same rule applies at the configuration layer: an unknown scheme or endpoint
throws rather than falling back to a default, because quietly verifying traffic under the
wrong scheme is worse than an outage.
Outbound signing happens at send time
SignRequestMiddleware is Guzzle middleware rather than a helper the caller invokes. That
placement is the design.
Signing at the call site means the encoded body has to live in a variable and be handed to two places — the signer and the client — where they can silently diverge. Signing from inside the client means the middleware reads the finished PSR-7 request: the body is the exact byte string about to go on the wire, and the URI is the one about to be dialled. There is no second copy to fall out of step with the first, and URL-signing schemes like Twilio work correctly without the caller doing anything special.
It is the same reasoning that puts inbound verification in middleware rather than in each controller: the check belongs where the bytes are, not where someone remembered to call it.
Contracts, not classes
| Contract | Default | Why you might replace it |
|---|---|---|
Webhooks |
WebhookSignatureManager |
Rarely — this is the front door |
SchemeRegistry |
DefaultSchemeRegistry |
Load schemes from somewhere other than config |
SignatureScheme |
eight bundled drivers | A provider we do not ship |
SignsWebhooks |
implemented by all but Mailgun | — |
ReplayGuard |
NullReplayGuard |
A store other than the cache |
Clock |
SystemClock |
Testing, or a clock you trust more |
Depend on the interface, bind the implementation. Every one of these is resolved from the container, which is what makes both the fakes and your own overrides possible.
What is deliberately absent
No migrations, no webhook_calls model, no queued job, no HTTP client for sending. Storing
deliveries and processing them are decisions your application has already made. A library
that made them again would be something to fit around rather than something to drop in —
and the moment its storage shape does not match yours, you are working against it.