Skip to content

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.