DEV Community

Cover image for From Domain Events to Webhooks
Faizan Akram Dar
Faizan Akram Dar

Posted on • Originally published at faizanakram.me

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;
}
Enter fullscreen mode Exit fullscreen mode
#[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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode
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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()}",
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode
interface WebhookSubscriptions
{
    /**
     * @return iterable<WebhookSubscription>
     */
    public function findByTopic(DomainEventType $topic): iterable;
}
Enter fullscreen mode Exit fullscreen mode
class WebhookSubscription
{
    public function __construct(
        public string $url,
        public string $secret,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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 RemoteEvent id
  • Webhook-Event: The RemoteEvent name

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Originally published on my blog

Questions? Find me on LinkedIn or Twitter.

Top comments (0)