php 156 lines · 4 tabs

Dispatch Domain Events After Doctrine Flush with a postFlush Subscriber in Symfony

Shared by codesnips Sep 2026
4 tabs
<?php

namespace App\Entity;

trait RecordsEvents
{
    private array $recordedEvents = [];

    protected function recordThat(object $event): void
    {
        $this->recordedEvents[] = $event;
    }

    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];

        return $events;
    }
}

#[\Doctrine\ORM\Mapping\Entity]
class Order
{
    use RecordsEvents;

    #[\Doctrine\ORM\Mapping\Id]
    #[\Doctrine\ORM\Mapping\Column(type: 'guid')]
    private string $id;

    #[\Doctrine\ORM\Mapping\Column]
    private string $status = 'draft';

    public function place(string $customerId): void
    {
        if ($this->status !== 'draft') {
            throw new \DomainException('Order already placed.');
        }

        $this->status = 'placed';
        $this->recordThat(new \App\Event\OrderPlaced($this->id, $customerId));
    }

    public function id(): string
    {
        return $this->id;
    }
}
4 files · php Explain with highlit

This snippet shows the classic tension between the Doctrine unit of work and side effects: domain events (sending mail, calling webhooks, updating read models) must only fire once the database transaction has actually committed, otherwise a rolled-back flush leaves the system dispatching events for changes that never persisted. The pattern is to record events on the aggregate during the flush, then release them to Symfony Messenger from Doctrine's postFlush lifecycle event, when the write is guaranteed durable.

In RecordsEvents trait, aggregates accumulate events in memory instead of dispatching them directly. recordThat() appends a plain event object, and releaseEvents() returns and clears the buffer in one call, which is important so the same event is never dispatched twice if the entity survives multiple flushes in a long-running worker. The Order entity uses it in place(), recording an OrderPlaced message rather than reaching for the bus inside domain logic, keeping the model free of infrastructure.

The OrderPlaced event is a tiny immutable value object carrying only the identifiers a downstream consumer needs. It is a Messenger message, not a Doctrine entity, so it must be self-contained and serializable for async transports.

DomainEventSubscriber is where the ordering matters. It listens on both onFlush and postFlush. During onFlush it walks the unit of work's scheduled insertions, updates, and deletions to collect entities that record events, because after the flush the scheduled-change sets are empty. It only drains the recorded events in postFlush, once the SQL has committed, dispatching each through the MessageBusInterface. The subscriber is registered on the doctrine.event_subscriber tag so Doctrine invokes it automatically.

Finally OrderPlacedHandler is an ordinary #[AsMessageHandler] that reacts to the event, here notifying the customer. Because dispatch happens post-commit, the handler can safely re-read the order and trust that it exists. The main trade-off is that events dispatched synchronously still run inside the request but after commit, so a failure there will not roll back the order; routing the message to an async transport makes that boundary explicit and gives retries.


Related snips

Share this code

Here's the card — post it anywhere.

Dispatch Domain Events After Doctrine Flush with a postFlush Subscriber in Symfony — share card
Link copied