rasuvaeff / yii3-outbox-webhooks-bridge
Bridge between yii3-outbox and yii3-webhooks for durable at-least-once webhook delivery
Package info
github.com/rasuvaeff/yii3-outbox-webhooks-bridge
pkg:composer/rasuvaeff/yii3-outbox-webhooks-bridge
Requires
- php: 8.3 - 8.5
- rasuvaeff/yii3-outbox: ^1.0
- rasuvaeff/yii3-webhooks: ^1.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- 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
This package is auto-updated.
Last update: 2026-07-26 10:59:20 UTC
README
Bridges yii3-outbox and yii3-webhooks for durable at-least-once webhook delivery. Each outbox message is converted to a WebhookEvent and dispatched to configured endpoints via an injected WebhookDispatcher.
Using an AI coding assistant? llms.txt has a compact API reference designed for LLMs.
Requirements
- PHP 8.3–8.5
rasuvaeff/yii3-outbox^1.0rasuvaeff/yii3-webhooks^1.0- A
WebhookDispatcherimplementation (e.g. a PSR-18-based adapter in your app) - A
WebhookDeliveryStorageimplementation (e.g.yii3-webhooks-db)
Installation
composer require rasuvaeff/yii3-outbox-webhooks-bridge
Usage
1. Configure endpoints
use Rasuvaeff\Yii3OutboxWebhooksBridge\ConfigWebhookEndpointProvider; use Rasuvaeff\Yii3Webhooks\WebhookEndpoint; $endpointProvider = new ConfigWebhookEndpointProvider(map: [ 'order.created' => [ new WebhookEndpoint(url: 'https://partner-a.example.com/hooks', secret: 'secret-a'), new WebhookEndpoint(url: 'https://partner-b.example.com/hooks', secret: 'secret-b'), ], 'order.paid' => [ new WebhookEndpoint(url: 'https://partner-a.example.com/hooks', secret: 'secret-a'), ], ]);
2. Wire the publisher
use Rasuvaeff\Yii3OutboxWebhooksBridge\OutboxWebhookPublisher; $publisher = new OutboxWebhookPublisher( dispatcher: $dispatcher, // your WebhookDispatcher impl endpointProvider: $endpointProvider, deliveryStorage: $deliveryStorage, // e.g. DbWebhookDeliveryStorage );
3. Run the outbox processor
use Rasuvaeff\Yii3Outbox\Processor; $processor = new Processor( storage: $outboxStorage, publisher: $publisher, clock: $clock, ); // In a background worker or console command: $result = $processor->process(types: ['order.created', 'order.paid']);
Behaviour
| Situation | Result |
|---|---|
Endpoint returns Delivered |
Delivery saved; message marked published |
Endpoint returns Failed |
Delivery saved; PublishException thrown → outbox retries |
| Dispatcher throws | PublishException thrown → outbox retries |
| No endpoints for type | Silent success (zero deliveries, message published) |
| Multiple endpoints, one fails | All dispatched; PublishException thrown → all retried |
Retry is all-or-nothing across endpoints
Fan-out has no partial state. If a type maps to five endpoints and the fifth
fails, publish() throws — and the outbox retries the message, not the one
endpoint. The next attempt dispatches to all five again, so the four that already
succeeded receive the event a second time.
This is deliberate: the bridge keeps no per-endpoint delivery cursor, and adding
one would duplicate state that WebhookDeliveryStorage already records. But it
has consequences you must design for:
| Consequence | What to do |
|---|---|
| Healthy endpoints get duplicates whenever any sibling fails | Receivers must deduplicate on the event id — see below. This is a requirement, not a recommendation |
WebhookDeliveryStorage gets a second row for an endpoint that already succeeded |
Do not put a unique constraint on (event_id, endpoint_url). It will raise a duplicate-key error on a perfectly normal retry, and that error surfaces as a delivery failure rather than as the schema problem it is |
| One permanently broken endpoint keeps the whole message retrying | The message reaches Failed after maxAttempts and stops — but every attempt until then re-delivers to the healthy endpoints. Keep maxAttempts low, or give a flaky endpoint its own message type so its failures cannot drag siblings along |
Event id dedup
The outbox message id is reused as the WebhookEvent id, unchanged. On retry —
including the all-or-nothing retry above — the same id is sent again, which is
what makes receiver-side deduplication possible at all.
Receivers must key idempotency on that id. It travels in the X-Webhook-Id
header when your dispatcher signs with HmacSha256Signer from yii3-webhooks;
with a different dispatcher, make sure the id is transmitted somehow, or
receivers have nothing to deduplicate on.
Two further contract details worth knowing:
occurredAtis the outbox message'screatedAt, not the moment of the delivery attempt. A receiver measuring an SLA fromoccurredAtmeasures from when the event happened, which is correct — but on a backlogged outbox a perfectly healthy delivery can look overdue. Use the transport timestamp if you want delivery latency.- Retry lives in the outbox, not in
yii3-webhooks.WebhookRetryPolicyfrom that package is not used here;Processor'sRetryPolicyis the only retry loop. Configuring both means two schedules for the same event.
Custom endpoint provider
Implement WebhookEndpointProvider to load endpoints from a database, cache, or
any runtime source:
use Rasuvaeff\Yii3OutboxWebhooksBridge\WebhookEndpointProvider; use Rasuvaeff\Yii3Webhooks\WebhookEndpoint; final readonly class DbWebhookEndpointProvider implements WebhookEndpointProvider { public function __construct(private \PDO $db) {} public function getEndpointsForType(string $type): array { // load from DB... } }
Security
- Secrets are never stored in
WebhookDelivery(comes fromyii3-webhooks). - Use
HmacSha256Signer(fromyii3-webhooks) as yourWebhookDispatcher's signer to authenticate outbound requests. - Receivers should validate the signature via
WebhookVerifierand useReplayGuardagainst nonce replay.
Examples
See examples/ for runnable scripts.
Development
docker run --rm -v "$PWD":/app -w /app composer:2 composer build docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix docker run --rm -v "$PWD":/app -w /app composer:2 composer test
License
BSD-3-Clause. See LICENSE.md.