Swappable contracts
Swappable contracts
Every capability is an interface resolved from the container. Depend on the interface, bind your own implementation where the default does not fit.
| Contract | Default | Replace it when |
|---|---|---|
Webhooks |
WebhookSignatureManager |
Rarely — this is the front door |
SchemeRegistry |
DefaultSchemeRegistry |
Schemes come from somewhere other than config |
ReplayGuard |
NullReplayGuard / CacheReplayGuard |
You want a store other than the cache |
Clock |
SystemClock |
Testing, or a clock you trust more than the host's |
A different replay store
The cache-backed guard covers most cases, but a delivery ledger you already keep in the database is a better source of truth — it survives a cache flush, which a security control arguably should.
use Cbox\WebhookSignature\Contracts\ReplayGuard;
class LedgerReplayGuard implements ReplayGuard
{
public function firstSeen(string $key, int $ttlSeconds): bool
{
// MUST be atomic. Two concurrent deliveries with the same key must not
// both be told they are new — a unique index does this, a SELECT-then-INSERT
// does not.
try {
DB::table('webhook_deliveries')->insert([
'key' => hash('sha256', $key),
'expires_at' => now()->addSeconds($ttlSeconds),
]);
return true;
} catch (UniqueConstraintViolationException) {
return false;
}
}
}
$this->app->singleton(ReplayGuard::class, LedgerReplayGuard::class);
The atomicity requirement is the whole contract. Providers retry on timeout, so the same delivery genuinely does arrive twice at once; a read followed by a write lets both copies through under exactly the conditions the guard exists for.
A different clock
$this->app->singleton(Clock::class, fn () => new class implements Clock {
public function now(): int
{
return CarbonImmutable::now()->getTimestamp();
}
});
Useful if your application already centralises time, or if you want verification to use a clock source you trust more than the host's — every timestamp check is a comparison against this, so drift here is drift in the replay window.
A different registry
$this->app->singleton(SchemeRegistry::class, function () {
$registry = new DefaultSchemeRegistry([new GithubScheme, new StripeScheme]);
foreach (Partner::query()->cursor() as $partner) {
$registry->register(HmacScheme::fromConfig($partner->slug, $partner->scheme_definition));
}
return $registry;
});
For a platform where partners define their own signature conventions in your database rather than in your config file.
Lookups stay deny-by-default whatever you build: an unknown name throws rather than resolving to something permissive, because verifying traffic under the wrong scheme is worse than an outage.