Skip to main content

Domain Events — Loosely Coupling Aggregates

What is a domain event?

In the previous chapter you learned about domain services. This chapter covers the "domain event," which expresses an important occurrence that happened in the domain.

The basics of an event-driven architecture

A domain event is a basic element of an event-driven architecture.

The characteristics of a domain event

A domain event has the following characteristics.

  1. It expresses a fact that happened in the past (named in the past tense: OrderConfirmed, PaymentReceived)
  2. It is immutable (it is not changed once it has occurred)
  3. It enables loosely coupled coordination between aggregates
  4. It expresses a business-significant occurrence

The event of an order being confirmed occurs, and multiple listeners react independently (sending email, decreasing stock, and so on).

Why use domain events?

The benefits of loose coupling

The biggest reason to use domain events is loose coupling. Loose coupling gives you the following benefits.

The benefits of loose coupling
  1. Extensibility: realize new features just by adding a listener (no changes to existing code)
  2. Maintainability: because each process is independent, the affected scope is limited
  3. Testability: each listener can be tested individually
  4. Single responsibility: the UseCase stays simple instead of bloating
  5. Reusability: you can attach multiple different processes to the same event

A concrete example: confirming an order on an e-commerce site

When an order is confirmed, the following processing is needed:

  • Decrease the stock
  • Send a confirmation email
  • Notify the shipping department
  • Update the sales report
  • Grant points

If you implement this with tight coupling, all the processing is crammed into the UseCase. Making it loosely coupled (event-driven) turns each process into an independent listener, making it easy to add, remove, and change them.

The problem: tightly coupled code

// Problematic code: all processing is tightly coupled in the UseCase
final class ConfirmOrderUseCase
{
public function execute(OrderId $orderId): void
{
DB::transaction(function () use ($orderId) {
$order = $this->orderRepository->findById($orderId);
$order->confirm();
$this->orderRepository->save($order);

// All the following processing is tightly coupled
$this->decreaseInventory($order); // Decrease the stock
$this->sendConfirmationEmail($order); // Send email
$this->notifyShippingDepartment($order); // Notify the shipping department
$this->updateSalesReport($order); // Update the sales report
$this->grantLoyaltyPoints($order); // Grant points
});
}
}

// Problems:
// 1. The UseCase bloats
// 2. You modify the UseCase every time you add new processing
// 3. It is hard to test (you mock every dependency)
// 4. It violates the single responsibility principle

The solution: loose coupling with domain events

// A design using domain events
final class ConfirmOrderUseCase
{
public function execute(OrderId $orderId): void
{
DB::transaction(function () use ($orderId) {
$order = $this->orderRepository->findById($orderId);
$order->confirm(); // Internally publishes the OrderConfirmed event
$this->orderRepository->save($order);
});

// The event is processed individually by listeners
// - InventoryDecrementListener
// - OrderConfirmationMailListener
// - ShippingNotificationListener
// - SalesReportListener
// - LoyaltyPointListener
}
}

// Benefits:
// 1. The UseCase stays simple
// 2. New processing is just an added listener
// 3. Each listener can be tested independently
// 4. You can keep the single responsibility principle

Implementing domain events

Implementing a domain event has two phases.

With this two-phase approach, you ensure the safety property that even if the transaction fails, the event is not published.

Defining the event class

Define the event as an immutable object.

// app/Domain/Order/Event/OrderConfirmed.php
final class OrderConfirmed
{
/**
* A domain event representing that an order has been confirmed
*
* Defined as an immutable object (readonly properties)
* Named in the past tense (Confirmed = it has been confirmed)
*/
public function __construct(
public readonly OrderId $orderId,
public readonly array $orderLineIds, // Include the information the listeners need
public readonly Money $totalAmount,
public readonly DateTimeImmutable $confirmedAt,
) {}
}

// app/Domain/Order/Event/OrderCancelled.php
final class OrderCancelled
{
/**
* A domain event representing that an order has been cancelled
*/
public function __construct(
public readonly OrderId $orderId,
public readonly string $reason, // Include the cancellation reason too
public readonly DateTimeImmutable $cancelledAt,
) {}
}

// app/Domain/Order/Event/OrderShipped.php
final class OrderShipped
{
/**
* A domain event representing that an order has been shipped
*/
public function __construct(
public readonly OrderId $orderId,
public readonly string $trackingNumber, // Include the tracking number
public readonly DateTimeImmutable $shippedAt,
) {}
}
What information to include in an event

Include the information a listener needs to complete its processing. However, you do not need to include everything—a listener can retrieve additional information from a repository as needed.

  • Minimal information: the ID only (the listener retrieves the rest via a repository)
  • Recommended: the key information needed for processing (reduces extra queries)
  • Avoid: embedding the entire entity (the event becomes too large)

Publishing an event from the entity (Phase 1: Record)

The entity records an event when its state changes (it does not dispatch yet).

// app/Domain/Order/Order.php
final class Order
{
/** @var object[] An array that temporarily records domain events */
private array $domainEvents = [];

/**
* Confirm the order
*
* Business rules:
* - Must be in a confirmable state
* - Needs at least one line item
*/
public function confirm(): void
{
// Validation of the business rules
if (!$this->status->canBeConfirmed()) {
throw new DomainException('This order cannot be confirmed');
}
if (empty($this->orderLines)) {
throw new DomainException('An order with no line items cannot be confirmed');
}

// Change the state
$this->status = OrderStatus::CONFIRMED;

// Record the domain event (Phase 1)
// Do not dispatch yet (during the transaction)
$this->recordEvent(new OrderConfirmed(
$this->id,
array_map(fn($line) => $line->id(), $this->orderLines),
$this->totalAmount(),
new DateTimeImmutable(),
));
}

/**
* Cancel the order
*/
public function cancel(string $reason): void
{
if (!$this->status->canBeCancelled()) {
throw new DomainException('This order cannot be cancelled');
}

$this->status = OrderStatus::CANCELLED;

// Record the event
$this->recordEvent(new OrderCancelled(
$this->id,
$reason,
new DateTimeImmutable(),
));
}

/**
* Record an event into the internal array
*/
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}

/**
* Retrieve the recorded events and clear them
* The repository calls this method to dispatch the events
*
* @return object[]
*/
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = []; // Clear after retrieving
return $events;
}
}

Implementing it as a shared trait

When multiple entities use event publishing, collect it in a trait.

// app/Domain/Shared/HasDomainEvents.php
trait HasDomainEvents
{
/** @var object[] */
private array $domainEvents = [];

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

/** @return object[] */
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}

public function hasDomainEvents(): bool
{
return !empty($this->domainEvents);
}
}

// Example usage
final class Order
{
use HasDomainEvents;

public function confirm(): void
{
// ...
$this->recordEvent(new OrderConfirmed(...));
}
}

Integration with Laravel's event system

Laravel has a powerful event system built in. By integrating domain events with Laravel's event system, you can make use of the existing infrastructure.

The relationship with Laravel Events

Laravel's official documentation: Laravel Events

Creating an event dispatcher (Phase 2: Dispatch)

// app/Infrastructure/Event/LaravelDomainEventDispatcher.php
final class LaravelDomainEventDispatcher implements DomainEventDispatcherInterface
{
/**
* Dispatch a single domain event
*
* Delivered using Laravel's event() helper
*/
public function dispatch(object $event): void
{
// Dispatch to Laravel's event system
event($event);
}

/**
* Dispatch multiple domain events together
*
* Pass the events the repository pulled from the entity
*/
public function dispatchAll(array $events): void
{
foreach ($events as $event) {
$this->dispatch($event);
}
}
}

// app/Domain/Shared/DomainEventDispatcherInterface.php
interface DomainEventDispatcherInterface
{
/**
* Dispatch a domain event
*
* The infrastructure-layer implementation (LaravelDomainEventDispatcher)
* integrates with Laravel Events
*/
public function dispatch(object $event): void;

public function dispatchAll(array $events): void;
}

Dispatching events in the repository

The repository dispatches events after the transaction succeeds.

// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly DomainEventDispatcherInterface $eventDispatcher
) {}

/**
* Save the order and dispatch the domain events
*
* Processing flow:
* 1. Save to the DB within a transaction
* 2. Dispatch the events after the transaction succeeds
*
* With this order, if the DB save fails, the events are not published either
*/
public function save(Order $order): void
{
// Phase 1: persist within a transaction
DB::transaction(function () use ($order) {
// 1. Save the entity to the DB
$orderModel = OrderModel::updateOrCreate(
['id' => $order->id()->value()],
[
'status' => $order->status()->value,
'total_amount' => $order->totalAmount()->amount(),
// ...
]
);

// Save the order lines too
$this->saveOrderLines($orderModel, $order->orderLines());
});
// The transaction succeeded (once we reach here, it is committed)

// Phase 2: dispatch the events after the transaction completes
// Retrieve the events recorded by the entity
$events = $order->pullDomainEvents();

// Pass them to the event dispatcher (delivered via Laravel Events)
$this->eventDispatcher->dispatchAll($events);
}

private function saveOrderLines(OrderModel $orderModel, array $orderLines): void
{
// Processing to save the order lines
// ...
}
}
Why dispatch after the transaction?

Dispatching an event within the transaction causes the following problems:

  1. The event listener runs
  2. Then the transaction is rolled back
  3. The event was published even though it is not reflected in the DB (inconsistency)

By dispatching after the transaction succeeds, the event is published only after the data is confirmed saved in the DB, so consistency is maintained.

With nested transactions, dispatch "after the outermost commit"

The save() above dispatches immediately after leaving the transaction inside the repository. This works correctly only when save() is the outermost transaction (updating a single aggregate).

As in Chapter 15, when a UseCase wraps multiple aggregates in an outer DB::transaction(), the transaction inside the repository is a SAVEPOINT, not a real commit. Therefore, at the point save() dispatches, the outer transaction is not yet committed, and even if it is rolled back afterward, the event has already been published—the very inconsistency we wanted to avoid happens here.

When you span multiple aggregates, use Laravel's DB::afterCommit() or the ShouldDispatchAfterCommit on the event/listener to dispatch after the outermost commit completes.

// Dispatch only after the outermost transaction commits
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));

Implementing listeners

A listener reacts to an event and runs specific processing. Each listener has a single responsibility.

// app/Application/Listener/SendOrderConfirmationEmail.php
final class SendOrderConfirmationEmail
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly Mailer $mailer,
) {}

/**
* Handle the OrderConfirmed event
*
* Responsibility: send the order confirmation email
*/
public function handle(OrderConfirmed $event): void
{
// Get the order ID from the event and retrieve the details from the repository
$order = $this->orderRepository->findById($event->orderId);

// Send an email to the customer
$this->mailer->to($order->customerEmail())
->send(new OrderConfirmationMail($order));
}
}

// app/Application/Listener/DecreaseInventoryOnOrderConfirmed.php
final class DecreaseInventoryOnOrderConfirmed
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly InventoryRepositoryInterface $inventoryRepository,
) {}

/**
* Handle the OrderConfirmed event
*
* Responsibility: decrease the stock when an order is confirmed
*/
public function handle(OrderConfirmed $event): void
{
// Retrieve the order information
$order = $this->orderRepository->findById($event->orderId);

// Decrease the stock for the product of each order line
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId());

// Decrease the stock with the entity's business logic
$inventory->decrease($line->quantity());

// Persist the change
$this->inventoryRepository->save($inventory);
}
}
}

// app/Application/Listener/GrantLoyaltyPointsOnOrderConfirmed.php
final class GrantLoyaltyPointsOnOrderConfirmed
{
/**
* Handle the OrderConfirmed event
*
* Responsibility: grant loyalty points when an order is confirmed
*/
public function handle(OrderConfirmed $event): void
{
// Get the information directly from the event (no extra query needed)
// Business rule: grant 1% of the purchase amount as points (truncate the fraction)
$points = (int) floor($event->totalAmount->amount() * 0.01);

// Processing to grant points
// ...
}
}

The characteristics of a listener:

  • Single responsibility: one listener handles only one process
  • Independence: it does not know about the existence of other listeners
  • Loose coupling: it does not know the event's publisher (Order)

Registering events and listeners (AppServiceProvider)

Event registration in Laravel 11 and later

In Laravel 11, EventServiceProvider (the $listen property) was removed. Register the mapping of events to listeners using Event::listen() inside AppServiceProvider::boot() (see Laravel 11 Events). If you type the handle() argument of a listener, you can rely on event auto-discovery and omit the registration.

// app/Providers/AppServiceProvider.php

use Illuminate\Support\Facades\Event;

class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// To register multiple listeners for one event, call Event::listen multiple times
Event::listen(OrderConfirmed::class, SendOrderConfirmationEmail::class);
Event::listen(OrderConfirmed::class, DecreaseInventoryOnOrderConfirmed::class);
Event::listen(OrderConfirmed::class, GrantLoyaltyPointsOnOrderConfirmed::class);

Event::listen(OrderCancelled::class, SendOrderCancellationEmail::class);
Event::listen(OrderCancelled::class, RestoreInventoryOnOrderCancelled::class);

Event::listen(OrderShipped::class, SendShippingNotificationEmail::class);
}
}

Synchronous vs asynchronous

Synchronous processing

By default, events are processed synchronously.

In synchronous processing, everything completes within the same request.

Asynchronous processing (queues)

Time-consuming processing such as sending email can be made asynchronous using a queue.

// app/Application/Listener/SendOrderConfirmationEmail.php
final class SendOrderConfirmationEmail implements ShouldQueue // Run on a queue
{
use Queueable;

public function handle(OrderConfirmed $event): void
{
// This processing is run by a queue worker
$order = $this->orderRepository->findById($event->orderId);
$this->mailer->to($order->customerEmail())
->send(new OrderConfirmationMail($order));
}
}

The response returns immediately, and the heavy processing is run later by a queue worker.

Processing typeUse caseImplementation
SynchronousStock allocation (needs immediate consistency)A regular listener
AsynchronousSending email, notificationsImplement ShouldQueue

The difference from event sourcing

note

Note: domain events and event sourcing are different concepts.

  • Domain events: used for notification/coordination between aggregates. The state is held by the entity.
  • Event sourcing: rebuilds the state from the history of events. Events are the single source of truth.

This book deals with domain events; event sourcing is out of scope.

Ensuring idempotency

An event listener should be designed so that processing the same event multiple times causes no problems.

// Bad: a non-idempotent listener (a problem on duplicate execution)
final class GrantLoyaltyPointsOnOrderConfirmed
{
public function handle(OrderConfirmed $event): void
{
// Calculate the points from the event's totalAmount (1% of the purchase amount)
$points = (int) floor($event->totalAmount->amount() * 0.01);

// Adds points every time (the customer is assumed to be resolved from the order ID)
$this->pointService->addPointsForOrder($event->orderId, $points);
}
}

// Good: an idempotent listener
final class GrantLoyaltyPointsOnOrderConfirmed
{
public function handle(OrderConfirmed $event): void
{
// Check whether it has already been processed
if ($this->pointHistory->existsByOrderId($event->orderId)) {
return; // Skip if already processed
}

$points = (int) floor($event->totalAmount->amount() * 0.01);
$this->pointService->addPointsForOrder($event->orderId, $points);
$this->pointHistory->recordProcessed($event->orderId);
}
}
Why does idempotency matter?
  • A queue worker's retry may reprocess the same event
  • In distributed systems, "at-least-once" delivery is common
  • A non-idempotent listener causes data inconsistency on duplicate processing

Designing eventual consistency

With domain events, you can choose between immediate consistency and eventual consistency.

Consistency modelCharacteristicWhen to use
Immediate consistencyThe same transactionStrict consistency is needed (such as payments)
Eventual consistencyAsynchronous eventsSome delay is acceptable (notifications, reports)

Considerations for eventual consistency

// Considerations when using eventual consistency
final class DecreaseInventoryOnOrderConfirmed implements ShouldQueue
{
public function handle(OrderConfirmed $event): void
{
// Note: if this listener fails,
// the order is confirmed but the stock has not been decreased

// Countermeasure 1: retry configuration
// Countermeasure 2: a compensating transaction on failure
// Countermeasure 3: monitoring with a Dead Letter Queue
}

public int $tries = 3; // Retry up to 3 times

public function failed(OrderConfirmed $event, \Throwable $e): void
{
// Notification/compensation processing on failure
Log::error('Failed to decrease stock', ['orderId' => $event->orderId->value()]);
}
}

Design guidelines for domain events

GuidelineDescription
Name in the past tenseSuch as OrderConfirmed, PaymentReceived
Include the needed informationSo that listeners can process without extra queries
Make it immutableDefine it with readonly properties
Dispatch after the transactionPublish the event after persistence succeeds

Summary

Key points

PointDescription
The role of a domain eventExpresses an important occurrence in the domain and enables loosely coupled coordination between aggregates
The advantage of event-driven designImproves extensibility, maintainability, and testability
Two-phase implementationPhase 1: record (inside the entity), Phase 2: dispatch (in the repository)
Integration with LaravelRealized with the event() helper and listeners
Synchronous/asynchronousAsynchronous processing is possible with ShouldQueue
Choosing consistencyChoose immediate vs eventual consistency appropriately

When to use domain events

✓ Use them:
- Coordination between aggregates (order confirmed → decrease stock)
- Secondary processing (order confirmed → send email)
- Extensible processing (you may add new listeners)

✗ Do not use them:
- Processing within a single aggregate (completed by an entity method)
- Processing that requires immediate consistency (should complete within the same transaction)
- When ordering guarantees matter (events have no ordering guarantee)

Reference resources

Resources to learn more about domain events:

Next steps

Domain events are a powerful but complex pattern. It is best to implement them synchronously at first, and consider asynchronous processing or event sourcing as needed.

In the next chapter, we look in detail at clean architecture in practice with Laravel.