<?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;
}
}
<?php
namespace App\Event;
final class OrderPlaced
{
public function __construct(
private readonly string $orderId,
private readonly string $customerId,
) {
}
public function orderId(): string
{
return $this->orderId;
}
public function customerId(): string
{
return $this->customerId;
}
}
<?php
namespace App\Doctrine;
use App\Entity\RecordsEvents;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Component\Messenger\MessageBusInterface;
class DomainEventSubscriber implements EventSubscriber
{
private array $pendingEntities = [];
public function __construct(private readonly MessageBusInterface $eventBus)
{
}
public function getSubscribedEvents(): array
{
return [Events::onFlush, Events::postFlush];
}
public function onFlush(OnFlushEventArgs $args): void
{
$uow = $args->getObjectManager()->getUnitOfWork();
$changes = array_merge(
$uow->getScheduledEntityInsertions(),
$uow->getScheduledEntityUpdates(),
$uow->getScheduledEntityDeletions(),
);
foreach ($changes as $entity) {
if (in_array(RecordsEvents::class, class_uses($entity) ?: [], true)) {
$this->pendingEntities[spl_object_id($entity)] = $entity;
}
}
}
public function postFlush(PostFlushEventArgs $args): void
{
$entities = $this->pendingEntities;
$this->pendingEntities = [];
foreach ($entities as $entity) {
foreach ($entity->releaseEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
}
}
<?php
namespace App\MessageHandler;
use App\Event\OrderPlaced;
use App\Notification\CustomerNotifier;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class OrderPlacedHandler
{
public function __construct(
private readonly CustomerNotifier $notifier,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(OrderPlaced $event): void
{
$this->logger->info('Order placed', [
'order' => $event->orderId(),
'customer' => $event->customerId(),
]);
$this->notifier->orderConfirmation(
$event->customerId(),
$event->orderId(),
);
}
}
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
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
class CreateDeadJobs < ActiveRecord::Migration[7.1]
def change
create_table :dead_jobs do |t|
t.string :jid, null: false
t.string :queue, null: false
t.string :klass, null: false
Background Job Dead Letter Queue (DLQ) Table
Share this code
Here's the card — post it anywhere.