rasuvaeff / yii3-webhooks-db
Database-backed delivery and nonce storage for rasuvaeff/yii3-webhooks
Requires
- php: 8.3 - 8.5
- psr/clock: ^1.0
- rasuvaeff/yii3-webhooks: ^2.0
- yiisoft/db: ^2.0
- yiisoft/db-migration: ^2.1
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/rector-named-literals: ^1.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/cache: ^3.2
- yiisoft/db-sqlite: ^2.0
- yiisoft/injector: ^1.2
- yiisoft/test-support: ^3.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-22 13:16:34 UTC
README
Database storage for rasuvaeff/yii3-webhooks deliveries and nonces: a
production-grade record of delivery attempts and atomic replay protection.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model.
Requirements
- PHP 8.3+
rasuvaeff/yii3-webhooks^2.0yiisoft/db^2.0yiisoft/db-migration^2.1psr/clock^1.0
Installation
composer require rasuvaeff/yii3-webhooks-db
Usage
use Psr\Clock\ClockInterface; use Rasuvaeff\Yii3Webhooks\WebhookDelivery; use Rasuvaeff\Yii3WebhooksDb\DbNonceStorage; use Rasuvaeff\Yii3WebhooksDb\DbWebhookDeliveryStorage; $deliveries = new DbWebhookDeliveryStorage(db: $db); $nonces = new DbNonceStorage(db: $db, clock: $clock); $delivery = WebhookDelivery::create(event: $event, endpoint: $endpoint); $deliveries->save(delivery: $delivery); $accepted = $nonces->add(nonce: $signature->getValue());
Under yiisoft/config this package binds only WebhookDeliveryStorage and
NonceStorage.
More than one worker
findPending() hands the same rows to every worker that asks and knows nothing
about backoff, so two workers deliver the same event twice and a backlog of
deliveries waiting out their backoff fills every batch while ready ones behind
them starve. claimReady() leases instead:
$now = $clock->now(); $batch = $deliveries->claimReady( now: $now, readyThresholds: $policy->readyThresholds($now), maxAttempts: $policy->getMaxAttempts(), leaseSeconds: 300, limit: 100, ); foreach ($batch as $delivery) { // ... deliver, then move it out of the claim: // $deliveries->markDelivered($delivery->withAttempt($now)); // $deliveries->markFailed($delivery->withAttempt($now, error: $error)); // or, for a retryable failure: // $deliveries->save($delivery->withAttempt($now, error: $error)); // $deliveries->releaseClaim($delivery); }
Ownership is a lease, not a status: a claimed delivery stays Pending and
becomes claimable again once claimed_at is older than leaseSeconds, so a
worker that dies strands nothing. leaseSeconds must outlive the slowest
delivery attempt, or two workers get the same delivery. A delivery that is out
of attempts is handed out — nothing else could ever mark it Failed.
readyThresholds() comes from WebhookRetryPolicy in rasuvaeff/yii3-webhooks;
the backoff rule is the core's and is never re-derived here. Each key governs
every attempt count from itself up to the next key, so a map built by hand may
skip counts without stranding the deliveries that land on them.
DbWebhookDeliveryStorage declares ClaimingDeliveryStorage, which is how a
worker picks the claiming path:
if (!$storage instanceof ClaimingDeliveryStorage) { throw new RuntimeException($storage::class . ' cannot claim; run a single worker instead'); }
Retention
Nothing else in this package removes a delivery row, so the table grows for as long as the application runs:
$deleted = $deliveries->deleteOlderThan(new DateTimeImmutable('-90 days'));
The default statuses are the terminal ones. Passing WebhookDeliveryStatus::Pending
deletes work that was never done — a decision the caller has to make out loud.
Migration
Register the bundled migration
(Rasuvaeff\Yii3WebhooksDb\Migration\M260612000000CreateWebhookTables)
by namespace — no vendor paths:
// config/common/di/migration.php use Yiisoft\Db\Migration\Service\MigrationService; return [ MigrationService::class => [ 'setSourceNamespaces()' => [['App\\Migration', 'Rasuvaeff\\Yii3WebhooksDb\\Migration']], ], ];
./yii migrate:up
Two migrations live in that namespace: M260612000000CreateWebhookTables creates
both tables, and M260822120000AddDeliveryClaimColumns adds the claimed_at /
claimed_by columns claimReady() needs. An installation that already ran the
first one only gets the second. down() on the second works on MySQL and
PostgreSQL only — yiisoft/db-sqlite cannot drop a column.
yiisoft/db-migration resolves the migration through Injector::make(), so
it picks up the table-name value objects from the container the same way the
storages do — no manual wiring needed beyond setSourceNamespaces() above.
Set the table names in params — the same values reach the migration and
both storages (as WebhookDeliveryTableName / WebhookNonceTableName):
// config/common/params.php 'rasuvaeff/yii3-webhooks-db' => [ 'deliveryTable' => 'my_webhook_deliveries', 'nonceTable' => 'my_webhook_nonces', 'table_prefix' => '', // prepended to both; e.g. 'rsv_' → rsv_my_webhook_deliveries ],
Index names follow the table names, so two installations can share one PostgreSQL schema — index names are unique per schema there, not per table.
Do not configure the migration through the DI container.
M...::class => ['__construct()' => [...]]does not work: the migration is built byInjector::make(), which resolves arguments by type and never reads a container definition keyed by the migration's own class. Worse, adding that definition makes the container fatal at build time in every request, because the class is not autoloadable until the migration runner requires it. That recipe was documented in 1.x; it never worked.
API reference
DbWebhookDeliveryStorage
| Method | Description |
|---|---|
save(delivery) |
Inserts the delivery, or updates the attempt state of one already stored; never writes the status of an existing row |
findPending(limit) |
Returns pending deliveries, oldest first — no lease, no backoff |
claimReady(now, readyThresholds, maxAttempts, leaseSeconds?, limit?) |
Leases the ready deliveries to this worker alone |
releaseClaim(delivery) |
Gives a lease back early; false when there was none |
markDelivered(delivery) |
Stores the delivery as succeeded, if it is still pending |
markFailed(delivery) |
Stores the delivery as failed, if it is still pending |
deleteOlderThan(threshold, ...statuses) |
Deletes deliveries created before the threshold; returns the row count. The terminal statuses are the default — passing statuses explicitly can include Pending, which deletes work that was never done |
getById(id) |
Loads a delivery by id |
DbNonceStorage
| Method | Description |
|---|---|
has(nonce) |
Whether the nonce is already known |
add(nonce) |
Atomic insert; returns false on a duplicate |
deleteOlderThan(threshold) |
Drops stale nonces for retention cleanup |
Security
DbNonceStorage::add()relies on the primary key and catches duplicate-key errors — that is what makes replay protection atomic instead of a check-then-write race.DbWebhookDeliveryStoragepersistsWebhookDeliveryfields only; endpoint secrets are never stored.- Keep nonce rows for at least the webhook timestamp tolerance window: prune them sooner and a replay becomes possible again.
- With more than one worker, use
claimReady().findPending()gives every worker the same rows, and the receiver sees the same event delivered twice. endpoint_urlis stored verbatim.WebhookEndpointrefuses credentials in the URL, so nothing new can put a secret there — but rows written by an older core version may still carryhttps://user:pass@host/. Audit the column once and rewrite what you find; use endpointheadersfor authentication, they never reach the database.
Examples
See examples/ for a runnable SQLite example.
Development
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check
License
BSD-3-Clause. See LICENSE.md.