From Domain Events to Webhooks

Published: (December 27, 2025 at 06:11 PM EST)
5 min read
Source: Dev.to

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

  1. Checks whether the event’s type is in the configured $webhookTopics.
  2. Normalises the event (or builds a custom payload).
  3. Wraps the payload in a RemoteEvent.
  4. Dispatches a SendWebhookMessage for 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 RemoteEvent id
  • Webhook-Event – The RemoteEvent name

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

Questions? Find me on LinkedIn or Twitter.

Back to Blog

Related posts

Read more »