A domain event is a record of something that already happened in the domain -
OrderPlaced, PaymentReceived, InvoiceIssued. It's named in the past tense because it
describes a fact, not a request. Aggregates raise these events as they change, and other parts
of the system react. That's how you decouple the thing that happened from the things that
should follow.
What is a domain event?
When an order is placed, plenty needs to happen: send a confirmation email, reserve stock, notify the warehouse, update analytics. The naive version crams all of it into one method:
public function place(): void
{
$this->status = OrderStatus::Placed;
$this->mailer->sendConfirmation($this); // now Order needs a mailer
$this->warehouse->notify($this); // and a warehouse client
$this->analytics->track('order_placed'); // and an analytics client
}
The Order now depends on email, warehouse, and analytics services. Its real job - order
rules - is buried under plumbing, it's miserable to test, and every new side effect means
editing the aggregate again. The order shouldn't have to know who cares that it was placed.
Record the fact, don't do the work
Instead, the aggregate records that something happened and moves on. The event is a tiny, immutable value object describing the fact.
<?php
declare(strict_types=1);
final class OrderPlaced
{
public function __construct(
public readonly string $orderId,
public readonly DateTimeImmutable $occurredOn,
) {}
}
final class Order
{
private OrderStatus $status = OrderStatus::Pending;
/** @var object[] */
private array $events = [];
public function __construct(
public readonly string $id,
) {}
public function place(): void
{
$this->status = OrderStatus::Placed;
// Just record the fact. No emails, no warehouse, no analytics here.
$this->events[] = new OrderPlaced($this->id, new DateTimeImmutable());
}
/** @return object[] Hand the recorded events to whoever will dispatch them. */
public function releaseEvents(): array
{
$events = $this->events;
$this->events = [];
return $events;
}
}
enum OrderStatus: string
{
case Pending = 'pending';
case Placed = 'placed';
}
The Order knows nothing about email or stock. It records OrderPlaced and forgets. Later,
after the aggregate is saved, something collects the events with releaseEvents() and lets
interested handlers react - one sends the email, another reserves stock. Add a fifth side
effect tomorrow and Order doesn't change at all.
Two ordering details matter here. Record the event after the state change succeeds, in the
same method, so you never emit OrderPlaced for an order that tripped a later check. And note
that events sit on the aggregate until something calls releaseEvents() - forget that call
after saving and they pile up silently and never fire.
Why past tense matters
The name isn't cosmetic. PlaceOrder (imperative) is a command - a request that might
still be rejected. OrderPlaced (past tense) is an event - a fact that already happened
and can't be undone. Handlers react to facts. Past-tense naming keeps that line sharp and
stops you sneaking "please do X" logic into something meant to say "X happened".
Common mistake: doing the side effect inside the aggregate
The mistake is drifting back to calling the mailer or the warehouse from inside place().
That re-couples the aggregate to infrastructure and defeats the point. The aggregate's job
ends at recording the event. Dispatching it and reacting to it happen outside, after the
aggregate is saved.
This lesson covers only the DDD building block - a plain object that records a fact. Turning events into a full messaging system (dispatchers, queues, choreography, event sourcing) is its own subject, covered later in the event-driven architecture chapter. Here, an event is just an immutable object an aggregate produces.
FAQ
Domain event vs command?
A command is a request to do something, named as an instruction: PlaceOrder,
ShipOrder. It can be rejected. A domain event is a record that something already
happened, named in the past tense: OrderPlaced, OrderShipped. It's a fact, so handlers only
react to it - they don't get to say no.
Should the event carry the whole aggregate?
No - keep it small. Carry the id and the few facts a handler needs (orderId, occurredOn,
maybe a total). A handler that needs the full aggregate can load it by id. A fat event becomes
a hidden coupling to the aggregate's internal shape.
When do the events actually get dispatched?
Usually right after the aggregate is saved, so you never announce something that didn't persist. The aggregate records the events; the layer that saves it releases and dispatches them. That ordering belongs to the application layer, which a later chapter covers in full.