Part of #184. Depends on #185 (storage + signers). Builds on univeros/messaging.
Goal
Add the outbound side of the webhook framework: WebhookDispatcher (the application-facing API) + WebhookMessage (the DTO that travels through Symfony Messenger) + WebhookHandler (the worker-side handler that performs the signed HTTP POST and records delivery state) + RetryPolicy (encodes the backoff curve) + bin/altair webhook:replay <id> (manual re-dispatch).
Why
Outbound webhooks are the third-party-integration primitive. An application emits an event; the framework signs it, dispatches it via the existing queue infrastructure, retries on transient failures, dead-letters after the configured threshold, records the delivery state so operators (and agents) can inspect and replay. Building this on top of univeros/messaging reuses the queue + retry + DLQ wiring the framework already ships rather than re-inventing it.
Flow
Application:
$dispatcher->dispatch('order.created', $payload, $subscriberUrl, secretName: 'partner-x');
WebhookDispatcher:
- Generates delivery id (ULID).
- Records Delivery row with status=Pending, attempts=0.
- Builds WebhookMessage{ deliveryId, eventName, payload, subscriberUrl, secretName, signerName }.
- Dispatches via Altair\Messaging\Contracts\MessageBusInterface.
Worker (Messenger handler):
- WebhookHandler receives WebhookMessage.
- Resolves Delivery row.
- Builds POST request with X-Signature (via SignerInterface) + X-Timestamp + X-Event-Id + X-Delivery-Id headers.
- Sends via PSR-18 HttpClient.
- On 2xx: Delivery.status = Delivered; record lastResponse; commit.
- On 5xx / network failure: increment attempts; if attempts < max → re-throw RetryableException (Messenger retries via its built-in retry strategy); if attempts >= max → Delivery.status = DeadLettered; commit; throw UnrecoverableMessageHandlingException to dead-letter the envelope.
CLI:
bin/altair webhook:replay <delivery-id>
→ Re-dispatch the failed/dead-lettered delivery; resets attempts to 0; status to Pending.
bin/altair webhook:show-failed [--limit=N]
→ List deliveries with status=DeadLettered.
Shape
src/Altair/Webhooks/Dispatcher/
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookDispatcher.php # application-facing
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookMessage.php # Messenger DTO (readonly)
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookHandler.php # #[AsHandler(WebhookMessage::class)]
\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 RetryPolicy.php # encodes backoff curve; pure value object
src/Altair/Webhooks/Cli/
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookReplayCommand.php # bin/altair webhook:replay
\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 WebhookShowFailedCommand.php # bin/altair webhook:show-failed
Retry policy
RetryPolicy is a small immutable value object:
final readonly class RetryPolicy
{
public const string EXPONENTIAL = 'exponential';
public const string LINEAR = 'linear';
public function __construct(
public int $maxAttempts = 5,
public string $backoff = self::EXPONENTIAL,
public int $baseDelaySeconds = 30,
) {}
public function delayFor(int $attempt): int
{
return match ($this->backoff) {
self::EXPONENTIAL => $this->baseDelaySeconds * (2 ** ($attempt - 1)),
self::LINEAR => $this->baseDelaySeconds * $attempt,
default => $this->baseDelaySeconds,
};
}
}
The handler computes the next attempt time as now + delayFor(attemptNumber) and stamps it on the Delivery; the Messenger transport's retry stamp is wired from this value.
Acceptance criteria
Out of scope
- The
webhook: spec block \xe2\x80\x94 separate issue
- A subscriber-list management surface \xe2\x80\x94 host's call
- Custom retry curves beyond exponential / linear \xe2\x80\x94 add when a real consumer asks
- Webhook signature scheme rotation \xe2\x80\x94 swap the signer registry entry; the dispatcher will sign with whatever the spec said
Notes
The handler's interaction with Messenger's retry needs to be careful: throwing a regular exception triggers Messenger's default retry behaviour with the transport's configured curve, which may not match RetryPolicy. Two options to consider during implementation:
a) Embed the next-attempt delay in the exception (RetryableException::withDelay($seconds)) so Messenger respects it.
b) Configure the transport's retry strategy to read from the handler's stored Delivery row.
Pick whichever is less fragile against Messenger version drift. Document the choice in the package docs (#TBD).
WebhookMessage should be a final readonly class decorated with #[AsHandler(WebhookHandler::class)] (or however univeros/messaging registers handlers \xe2\x80\x94 mirror the existing pattern in src/Altair/Messaging/*).
Part of #184. Depends on #185 (storage + signers). Builds on
univeros/messaging.Goal
Add the outbound side of the webhook framework:
WebhookDispatcher(the application-facing API) +WebhookMessage(the DTO that travels through Symfony Messenger) +WebhookHandler(the worker-side handler that performs the signed HTTP POST and records delivery state) +RetryPolicy(encodes the backoff curve) +bin/altair webhook:replay <id>(manual re-dispatch).Why
Outbound webhooks are the third-party-integration primitive. An application emits an event; the framework signs it, dispatches it via the existing queue infrastructure, retries on transient failures, dead-letters after the configured threshold, records the delivery state so operators (and agents) can inspect and replay. Building this on top of
univeros/messagingreuses the queue + retry + DLQ wiring the framework already ships rather than re-inventing it.Flow
Shape
Retry policy
RetryPolicyis a small immutable value object:The handler computes the next attempt time as
now + delayFor(attemptNumber)and stamps it on the Delivery; the Messenger transport's retry stamp is wired from this value.Acceptance criteria
WebhookDispatcher::dispatch()records aDeliveryrow inDeliveryStoreInterface+ dispatchesWebhookMessageviaMessageBusInterfaceWebhookHandlerperforms the signed POST, updatesDelivery.statusbased on outcome, and uses Symfony Messenger's retry mechanism for transient failuresstatus=DeadLetteredand surface inbin/altair webhook:show-failedbin/altair webhook:replay <delivery-id>resolves a delivery (or a unique prefix), resets it to Pending, and re-dispatches the same payloadwebhook: { direction: out }, the scaffolder emits a binding for the dispatcher rather than a middleware (issue #TBD for spec block handles the emission side)ClientInterfacemock andInMemoryDeliveryStoreso they don't hit the network or stand up RedisOut of scope
webhook:spec block \xe2\x80\x94 separate issueNotes
The handler's interaction with Messenger's retry needs to be careful: throwing a regular exception triggers Messenger's default retry behaviour with the transport's configured curve, which may not match
RetryPolicy. Two options to consider during implementation:a) Embed the next-attempt delay in the exception (
RetryableException::withDelay($seconds)) so Messenger respects it.b) Configure the transport's retry strategy to read from the handler's stored
Deliveryrow.Pick whichever is less fragile against Messenger version drift. Document the choice in the package docs (#TBD).
WebhookMessageshould be afinal readonly classdecorated with#[AsHandler(WebhookHandler::class)](or howeveruniveros/messagingregisters handlers \xe2\x80\x94 mirror the existing pattern insrc/Altair/Messaging/*).