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.7
- rasuvaeff/yii3-webhooks: ^1.0 || ^2.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/property-testing-testo: ^0.6
- 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
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-20 13:33:22 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 || ^2.0 — prefer 2.x: it carries the length-prefixed signature format and the SSRF-hardenedWebhookEndpoint(credentials in the URL, private/loopback hosts rejected unlessallowPrivateNetwork: true). The bridge consumes the same API on both lines; 2.x only rejects endpoint URLs it used to accept, at construction time in your own configuration code.- 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; use Rasuvaeff\Yii3Outbox\RetryPolicy; $processor = new Processor( storage: $outboxStorage, publisher: $publisher, retryPolicy: new RetryPolicy(maxAttempts: 5, delaySeconds: 60), clock: $clock, types: $endpointProvider->configuredTypes(), // only what has endpoints ); // In a background worker or console command: $result = $processor->process();
process() takes no arguments; what the processor claims is decided by
types: at construction. Leave it out and it claims every pending
message in the storage, whatever its type — see the next section before
doing that on an outbox other consumers read from.
Sharing an outbox with other consumers
OutboxWebhookPublisher treats a message type with no configured endpoints
as delivered: publish() returns, the Processor calls markPublished(),
and the message is gone. That is harmless while webhooks are the only
consumer. It is data loss the moment another consumer claims from the same
storage by type — a ClickHouseOutboxExporter from
rasuvaeff/yii3-outbox-clickhouse, for instance, claims only the types it
routes, but an unscoped Processor claims everything, including those, and
the publisher acknowledges them with zero deliveries. Nothing logs it and no
alert fires.
Scope the processor to the types the publisher owns. With
ConfigWebhookEndpointProvider that is one call — configuredTypes() lists
every type that has at least one endpoint — and with a provider of your own
it is the list you would give the provider anyway:
$processor = new Processor( storage: $outboxStorage, publisher: $publisher, retryPolicy: $policy, clock: $clock, types: $endpointProvider->configuredTypes(), );
The scope reaches the storage's claim(), so a foreign message is never even
seen. Two further lines of defence, for when the scope cannot be trusted to
stay in step with the endpoint map:
- A dedicated storage. Give webhook-bound messages their own table
(
yii3-outbox-dbtakes the name throughOutboxTableName) and run thisProcessoron that storage only. - A guarding publisher. Wrap
OutboxWebhookPublisherin aPublisherInterfacethat throwsPublishExceptionfor any type outside an allow-list. Such a message is retried and eventually markedFailedrather than silently acknowledged — visible, and recoverable.
final readonly class OwnedTypesPublisher implements PublisherInterface { /** @param list<string> $types */ public function __construct( private PublisherInterface $inner, private array $types, ) {} public function publish(OutboxMessage $message): void { if (!in_array($message->getType(), $this->types, true)) { throw new PublishException( message: sprintf('Message type "%s" is not owned by the webhook publisher', $message->getType()), outboxMessage: $message, ); } $this->inner->publish($message); } }
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) — see Sharing an outbox |
| 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. - Endpoint URLs are masked before they enter a
PublishExceptionmessage. The outboxProcessorlogs that message verbatim, so an endpoint carrying a credential in its query string (?access_token=…) or in its userinfo component (https://user:pass@…) would otherwise end up in every log sink the application has. Scheme, host, port, path and query keys survive — the log line stays diagnosable; query values, the password and the fragment are replaced with***. Prefer sending credentials as headers configured on theWebhookDispatcherall the same: masking is a safety net, not a licence. - The upstream half of that message is scrubbed too. A failure line is
<masked endpoint>: <upstream error>, and the upstream part is text this package did not write — a delivery'sgetLastError(), or the message of whatever the dispatcher threw. A PSR-18 client routinely puts the whole request URI into it (cURL error 7: Failed to connect ... for https://host/e?access_token=…), so masking only the endpoint would put the credential straight back into the log. Every secret the endpoint URL carries is removed from that text wherever it appears, percent-encoded or not. A short query value (?page=1) makes this over-redact the text around it, which is the cheaper of the two mistakes.
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.