From Domain Events to Webhooks
Source: Dev.to
Domain Events
Domain events implement this interface:
<?php
interface DomainEvent
{
public function aggregateRootId(): string;
public function displayReference(): string;
public function occurredAt(): \DateTimeImmutable;
public static function eventType(): DomainEventType;
}
Example Event
<?php
#[TriggerWebhook]
class OrderConfirmed implements DomainEvent
{
public function __construct(
private string $orderId,
private string $orderNumber,
private \DateTimeImmutable $confirmedAt,
) {}
public function aggregateRootId(): string
{
return $this->orderId;
}
public function displayReference(): string
{
return $this->orderNumber;
}
public function occurredAt(): \DateTimeImmutable
{
return $this->confirmedAt;
}
public static function eventType(): DomainEventType
{
return DomainEventType::OrderConfirmed;
}
}
The #[TriggerWebhook] attribute marks this event for webhook delivery.
Event‑type Enum
The DomainEventType enum maps event types to their concrete classes.
(We also persist events to an event store; the eventClass() method is used when deserialising stored events – that part is omitted here.)
<?php
enum DomainEventType: string
{
case OrderConfirmed = 'order.confirmed';
// … more event types
public function eventClass(): string
{
return match ($this) {
self::OrderConfirmed => OrderConfirmed::class,
// … more mappings
};
}
}
Entity
The entity records what happened. The repository saves it and dispatches events (via Symfony Messenger).
<?php
class Order
{
/** @var DomainEvent[] */
private array $events = [];
public function confirm(\DateTimeImmutable $confirmedAt): void
{
$this->status = OrderStatus::Confirmed;
$this->events[] = new OrderConfirmed(
orderId: $this->id,
orderNumber: $this->orderNumber,
confirmedAt: $confirmedAt,
);
}
public function releaseEvents(): array
{
$events = $this->events;
$this->events = [];
return $events;
}
}
<?php
class OrderRepository
{
public function __construct(
private EntityManagerInterface $em,
private MessageBusInterface $eventBus,
) {}
public function save(Order $order): void
{
$events = $order->releaseEvents();
$this->em->persist($order);
$this->em->flush();
foreach ($events as $event) {
$this->eventBus->dispatch($event);
}
}
}
Webhooks
A message handler processes all domain events, but only sends webhooks for those in the $webhookTopics array.
The array is populated by collecting all classes with the #[TriggerWebhook] attribute using Symfony’s resource tags (added in PR #59704).
<?php
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Uid\Factory\UuidFactory;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
use Symfony\Component\Webhook\Subscriber;
#[AsMessageHandler]
class WebhookSender
{
/**
* @param DomainEventType[] $webhookTopics
*/
public function __construct(
private MessageBusInterface $bus,
private WebhookSubscriptions $subscriptions,
private NormalizerInterface $normalizer,
private UuidFactory $uuidFactory,
#[Autowire(param: 'webhook.topics')]
private array $webhookTopics,
) {}
public function __invoke(DomainEvent $event): void
{
if (!\in_array($event::eventType(), $this->webhookTopics, true)) {
return;
}
$payload = $this->createPayload($event);
$remoteEvent = new RemoteEvent(
name: $event::eventType()->value,
id: $this->uuidFactory->create()->toString(),
payload: $payload,
);
foreach ($this->subscriptions->findByTopic($event::eventType()) as $subscription) {
$this->bus->dispatch(
new SendWebhookMessage(
new Subscriber($subscription->url, $subscription->secret),
$remoteEvent,
),
);
}
}
private function createPayload(DomainEvent $event): array
{
// Option 1: Send the event directly
// return $this->normalizer->normalize($event);
// Option 2: Send a dataless notification with a resource URL (used here)
return [
'resourceId' => $event->aggregateRootId(),
'displayReference' => $event->displayReference(),
'occurredAt' => $event->occurredAt()->getTimestamp(),
'topic' => $event::eventType()->value,
// … additional fields as needed
];
}
}
Handler flow
- Checks whether the event’s type is in the configured
$webhookTopics. - Normalises the event (or builds a custom payload).
- Wraps the payload in a
RemoteEvent. - Dispatches a
SendWebhookMessagefor each subscription that matches the topic.
Example payload generation (optional)
// In practice, we use ApiPlatform\Metadata\IriConverterInterface to generate resource URLs,
// you can use a similar strategy.
[
'url' => "https://example.com/api/orders/{$event->aggregateRootId()}",
];
Subscription Interface & Model
<?php
interface WebhookSubscriptions
{
/**
* @return iterable<WebhookSubscription>
*/
public function findByTopic(DomainEventType $topic): iterable;
}
<?php
class WebhookSubscription
{
public function __construct(
public string $url,
public string $secret,
) {}
}
The Symfony\Component\Webhook\Subscriber class is Symfony’s representation of a webhook consumer. It holds the webhook URL and secret. When Symfony’s webhook transport sends the HTTP POST request, it automatically adds these headers:
- Webhook-Signature – HMAC‑SHA256 signature using the secret (format:
sha256=...) - Webhook-Id – The
RemoteEventid - Webhook-Event – The
RemoteEventname
To customise the signature algorithm or header names, decorate:
TIP: Route SendWebhookMessage to an async transport for non‑blocking delivery. Configure a retry strategy (delay + multiplier) to handle temporary failures. If event order is important, use a FIFO queue.
Documenting Webhooks with API Platform (OpenAPI)
If you’re using API Platform, you can document your webhooks in OpenAPI. Below is an example using the dataless‑notification pattern shown earlier.
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Attributes\Webhook;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\Parameter;
use ApiPlatform\OpenApi\Model\PathItem;
use ApiPlatform\OpenApi\Model\Response;
#[ApiResource(
operations: [
new Post(
openapi: new Webhook(
name: 'Webhook',
pathItem: new PathItem(
post: new Operation(
operationId: 'resource_webhook',
tags: ['Webhooks'],
responses: [
'2XX' => new Response(
description: 'Return 2xx to acknowledge receipt'
),
'default' => new Response(
description: 'Non-2xx triggers retry: 5m, 25m, 2h5m'
),
],
summary: 'Webhook notification for order events',
description: 'Sent when subscribed order events occur. Event type in Webhook-Event header.',
parameters: [
new Parameter(
name: 'Webhook-Signature',
in: 'header',
description: 'HMAC‑SHA256 signature of the payload',
required: true,
),
new Parameter(
name: 'Webhook-Id',
in: 'header',
description: 'Unique identifier of the RemoteEvent',
required: true,
),
new Parameter(
name: 'Webhook-Event',
in: 'header',
description: 'Name of the event (topic)',
required: true,
),
],
),
),
),
),
],
)]
class Order
{
// …
}
Additional OpenAPI Example
<?php
#[OA\Get(
path: '/webhooks',
operationId: 'listWebhooks',
tags: ['Webhooks'],
summary: 'List all webhooks',
description: 'Retrieve a paginated list of webhook subscriptions.',
parameters: [
new Parameter(
name: 'page',
in: 'query',
description: 'Page number for pagination',
required: false,
schema: ['type' => 'integer', 'default' => 1]
),
new Parameter(
name: 'perPage',
in: 'query',
description: 'Number of items per page',
required: false,
schema: ['type' => 'integer', 'default' => 20]
),
],
responses: [
new OA\Response(
response: 200,
description: 'Successful response',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(
property: 'data',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/WebhookPayload')
),
new OA\Property(
property: 'meta',
type: 'object',
properties: [
new OA\Property(property: 'total', type: 'integer'),
new OA\Property(property: 'page', type: 'integer'),
new OA\Property(property: 'perPage', type: 'integer')
]
)
]
)
)
]
)]
class WebhookPayload
{
public function __construct(
public string $resourceId,
public string $displayReference,
public \DateTimeImmutable $occurredAt,
public string $url,
public string $topic,
) {}
}
Originally published on my blog