From Domain Events to Webhooks
I work on an ERP system that integrates with various external systems like warehouse management systems, accounting softwares, weighing bridges, etc. When something changes in our system like an order is created, or a shipment is dispatched, multiple external systems need to know about it.
We use domain events internally, and translate those into HTTP webhooks for external consumers.
Here's how we do it (I've simplified it for this post).
Domain Events
Domain events implement this interface:
interface DomainEvent
{
public function aggregateRootId(): string;
public function displayReference(): string;
public function occurredAt(): \DateTimeImmutable;
public static function eventType(): DomainEventType;
}
#[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.
The DomainEventType enum maps event types to their classes.
We also persist events to an event store. The eventClass() method is used when deserializing stored events, but that's beyond the scope of this post:
enum DomainEventType: string
{
case OrderConfirmed = 'order.confirmed';
// ... more event types
public function eventClass(): string
{
return match($this) {
self::OrderConfirmed => OrderConfirmed::class,
// ... more mappings
};
}
}
The entity records what happened. The repository saves it and dispatches events (via Symfony Messenger).
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
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;
}
}
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.
It is populated by collecting all classes with
#[TriggerWebhook] attribute using Symfony's resource tags,
a feature for tagging classes that aren't services. It was added in PR #59704.
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 the event as dataless notification
// with resource URL, we use this approach
return [
'resourceId' => $event->aggregateRootId(),
'displayReference' => $event->displayReference(),
'occurredAt' => $event->occurredAt()->getTimestamp(),
'topic' => $event::eventType()->value,
// Hardcoded for simplicity.
// 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()}",
];
}
}
interface WebhookSubscriptions
{
/**
* @return iterable<WebhookSubscription>
*/
public function findByTopic(DomainEventType $topic): iterable;
}
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: TheRemoteEventidWebhook-Event: TheRemoteEventname
To customize the signature algorithm or header names, decorate
webhook.signer or
webhook.headers_configurator.
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.
If you're using API Platform, you can use it to document your webhooks in OpenAPI. Unfortunately, it's not documented. So I am adding an example here using the dataless notification example from above, to make it easier for others to find.
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 for verification',
required: true,
schema: ['type' => 'string']
),
new Parameter(
name: 'Webhook-Event',
in: 'header',
description: 'Event type (e.g., order.confirmed)',
required: true,
schema: ['type' => 'string']
),
new Parameter(
name: 'Webhook-Id',
in: 'header',
description: 'Unique delivery identifier',
required: true,
schema: ['type' => 'string']
),
]
)
)
),
),
],
)]
class WebhookPayload
{
public function __construct(
public string $resourceId,
public string $displayReference,
public \DateTimeImmutable $occurredAt,
public string $url,
public string $topic,
) {}
}
Questions? Find me on LinkedIn or Twitter.
← Back to home