Search by

rasuvaeff / yii3-outbox-webhooks-bridge

rasuvaeff

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

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v1.1.0 2026-09-18 10:01 UTC

This package is auto-updated.

Last update: 2026-09-20 13:33:22 UTC


README

Stable Version Total Downloads Build Static analysis Psalm level License Русская версия

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.0
  • rasuvaeff/yii3-webhooks ^1.0 || ^2.0 — prefer 2.x: it carries the length-prefixed signature format and the SSRF-hardened WebhookEndpoint (credentials in the URL, private/loopback hosts rejected unless allowPrivateNetwork: 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 WebhookDispatcher implementation (e.g. a PSR-18-based adapter in your app)
  • A WebhookDeliveryStorage implementation (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-db takes the name through OutboxTableName) and run this Processor on that storage only.
  • A guarding publisher. Wrap OutboxWebhookPublisher in a PublisherInterface that throws PublishException for any type outside an allow-list. Such a message is retried and eventually marked Failed rather 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:

  • occurredAt is the outbox message's createdAt, not the moment of the delivery attempt. A receiver measuring an SLA from occurredAt measures 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. WebhookRetryPolicy from that package is not used here; Processor's RetryPolicy is 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 from yii3-webhooks).
  • Use HmacSha256Signer (from yii3-webhooks) as your WebhookDispatcher's signer to authenticate outbound requests.
  • Receivers should validate the signature via WebhookVerifier and use ReplayGuard against nonce replay.
  • Endpoint URLs are masked before they enter a PublishException message. The outbox Processor logs 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 the WebhookDispatcher all 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's getLastError(), 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.