Reconstructing State from Events
In traditional applications, the current state is stored and its history is discarded. Event sourcing turns this principle around: not the state, but the sequence of events that led to it, is the primary source of truth. The current state is always derivable, and therefore fully auditable, capable of time travel, and easy to debug.
Table of Contents
- 1. Why event sourcing and when it makes sense
- 2. Domain events: the language of the domain as code
- 3. Aggregates: building state from events
- 4. Event store: persisting and loading events
- 5. Event replay: reconstructing state at any point in time
- 6. Projections: building readable views on the event stream
- 7. Snapshots: optimizing performance for long event streams
- 8. Integrating Symfony Messenger as an event bus
- 9. Event sourcing vs. classic data storage
- 10. Summary
- 11. FAQ
1. Why event sourcing and when it makes sense
Event sourcing is not a pattern for every application, but for certain domains it is the best-suited data storage model there is. The core idea: what happened in a system matters more than its current state. An online shop order object that only stores the status "cancelled" loses the information of who cancelled it, when, and why. An event sourcing approach instead stores: OrderPlaced, PaymentReceived, ItemShipped, OrderCancelled, each with a timestamp, user and context for every event. The current state can always be derived from this chain, and the complete history is available for free.
The practical benefit falls into three areas: auditing (a complete audit trail with no additional effort), debugging (every bug can be traced back to the exact event stream that caused it), and time travel (the state at any point in the past can be reconstructed by replaying the event stream up to that point). In regulated industries such as fintech, insurtech and health care, this is often not optional but legally required. Symfony provides all the tools needed to implement event sourcing without external frameworks, using Doctrine DBAL, Messenger and the event system.
2. Domain events: the language of the domain as code
Domain events are immutable objects that describe a fact that has already happened in the past. Their names are always in the past tense: OrderPlaced, PaymentFailed, UserRegistered. Domain events are not commands: they do not describe what should be done, but what has already been done. That is a fundamental difference: commands can be rejected, while events are facts that have already occurred and cannot be undone (at most compensated for by an opposing event).
In PHP, domain events are readonly classes with all relevant data of the event passed as constructor parameters. They contain no logic, no methods other than accessors, and are serializable for persistence in the event store. Every event carries an aggregate ID (to identify the event stream of a specific aggregate), a sequence number (for correct ordering), a timestamp and, optionally, a user context. This metadata is not part of the domain itself, but of the event store envelope that carries the event.
<?php
declare(strict_types=1);
namespace App\Order\Event;
use App\Shared\ValueObject\OrderId;
use App\Shared\ValueObject\Money;
/**
* Domain Event: fired when a customer places an order.
* Immutable, describes a fact that already happened.
*/
final readonly class OrderPlaced
{
public function __construct(
public readonly OrderId $orderId,
public readonly string $customerId,
public readonly Money $totalAmount,
public readonly array $items, // [{productId, qty, price}]
public readonly \DateTimeImmutable $placedAt,
) {}
}
/**
* Domain Event: fired when payment for an order is confirmed.
*/
final readonly class PaymentReceived
{
public function __construct(
public readonly OrderId $orderId,
public readonly string $transactionId,
public readonly Money $amount,
public readonly \DateTimeImmutable $receivedAt,
) {}
}
/**
* Domain Event: fired when a customer cancels an order.
* Contains reason for full audit trail, no information is lost.
*/
final readonly class OrderCancelled
{
public function __construct(
public readonly OrderId $orderId,
public readonly string $cancelledBy, // user id or 'system'
public readonly string $reason,
public readonly \DateTimeImmutable $cancelledAt,
) {}
}
3. Aggregates: building state from events
An aggregate in event sourcing is not a Doctrine entity with direct setter methods. It is a class that builds its own state exclusively through domain events. The aggregate's methods validate business rules and produce events, but they do not change state directly. Instead, the produced event is applied through an internal apply() method that updates the aggregate's state. This mechanism ensures that the state is always consistent with the event history: whether it is built by freshly producing an event or by replaying from the event store, the result is identical.
This pattern has an important consequence for the design: the apply() methods contain only state changes, no business rules and no validation. Business rules live in the aggregate's command methods. This clearly separates what happens during replay (only building state, no re-validation) from what happens when the event is originally produced (validation plus state change). This distinction is what makes efficient replay possible: the event store delivers the events, the aggregate applies them in order and ends up in the correct state, without ever having to re-check the business rules.
<?php
declare(strict_types=1);
namespace App\Order;
use App\Order\Event\{OrderPlaced, PaymentReceived, OrderCancelled};
use App\Shared\ValueObject\{OrderId, Money};
/**
* Order Aggregate, state is built exclusively through Domain Events.
* Business rules in command methods; state changes only in apply() methods.
*/
final class Order
{
private OrderStatus $status = OrderStatus::Draft;
private bool $paymentReceived = false;
private array $recordedEvents = [];
private function __construct(
private readonly OrderId $id,
) {}
/**
* Factory method: create a new order and record the OrderPlaced event.
*/
public static function place(OrderId $id, string $customerId, array $items, Money $total): self
{
$order = new self($id);
$order->recordThat(new OrderPlaced($id, $customerId, $total, $items, new \DateTimeImmutable()));
return $order;
}
/**
* Command method: confirm payment, validates business rules, records event.
*/
public function receivePayment(string $transactionId, Money $amount): void
{
if ($this->status === OrderStatus::Cancelled) {
throw new \DomainException('Cannot receive payment for a cancelled order.');
}
if ($this->paymentReceived) {
throw new \DomainException('Payment already received for this order.');
}
$this->recordThat(new PaymentReceived($this->id, $transactionId, $amount, new \DateTimeImmutable()));
}
/**
* Command method: cancel the order, validates state, records event.
*/
public function cancel(string $cancelledBy, string $reason): void
{
if ($this->paymentReceived) {
throw new \DomainException('Cannot cancel an order that has already been paid.');
}
$this->recordThat(new OrderCancelled($this->id, $cancelledBy, $reason, new \DateTimeImmutable()));
}
// Apply methods, state changes only, no business logic, no validation
private function applyOrderPlaced(OrderPlaced $event): void
{
$this->status = OrderStatus::Pending;
}
private function applyPaymentReceived(PaymentReceived $event): void
{
$this->paymentReceived = true;
$this->status = OrderStatus::Paid;
}
private function applyOrderCancelled(OrderCancelled $event): void
{
$this->status = OrderStatus::Cancelled;
}
// Infrastructure: record event internally and apply immediately
private function recordThat(object $event): void
{
$this->applyEvent($event);
$this->recordedEvents[] = $event;
}
// Replay from event store, apply without recording
public function applyEvent(object $event): void
{
$method = 'apply' . (new \ReflectionClass($event))->getShortName();
if (method_exists($this, $method)) {
$this->{$method}($event);
}
}
public function popRecordedEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
public function getId(): OrderId { return $this->id; }
}
4. Event store: persisting and loading events
The event store is the primary persistence layer in an event-sourced system. It stores events as immutable, append-only entries: there is no UPDATE and no DELETE, only INSERT. Every entry contains: the aggregate ID, the event type (class name), the serialized event data, a sequence number (for ordering within an aggregate) and metadata (timestamp, user context). The sequence number also serves as an optimistic locking mechanism: if two processes load and modify the same aggregate concurrently, one of them fails on save with a UNIQUE constraint error, because the same sequence number cannot be stored twice.
The event store implementation in Symfony uses Doctrine DBAL for direct SQL operations, without Doctrine ORM. Events are not entities, they are simple records in an append-only table. Serialization of the event data is done as JSON with a Symfony Serializer that uses PHP attributes for its mapping configuration. The event_type field contains the fully qualified class name of the event, so the correct target class can be chosen when deserializing. When an aggregate is loaded, all events for an aggregate ID are loaded in sequence-number order and applied to the aggregate one after another.
5. Event replay: reconstructing state at any point in time
Event replay is the ability to reconstruct the state of an aggregate, or of an entire system, at any point in the past. For a single aggregate, that means: filtering the event store by aggregate ID and loading only the events up to a given timestamp or sequence number. The aggregate is rebuilt with these events and represents its state at exactly that point in time. That is the time-travel capability event sourcing provides, one that no classic update-in-place system can reproduce.
System-wide replay matters for projections: when a new projection (for example, a new read model) is introduced, it must be backfilled retroactively with the historical events. To do that, all events from the store are loaded chronologically and run through the new projection. The result is a fully populated read model that looks as if the projection had existed from the very beginning. For large event streams, this replay can take hours; snapshots (next chapter) and parallel replay strategies solve that performance problem.
<?php
declare(strict_types=1);
namespace App\Order\Infrastructure;
use App\Order\Order;
use App\Shared\ValueObject\OrderId;
use Doctrine\DBAL\Connection;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Event Store implementation using Doctrine DBAL, append-only, no ORM.
*/
final readonly class DoctrineEventStore
{
public function __construct(
private Connection $connection,
private SerializerInterface $serializer,
) {}
/**
* Append recorded events to the event store, fails on duplicate sequence (optimistic locking).
*/
public function append(OrderId $aggregateId, array $events, int $expectedVersion): void
{
$this->connection->beginTransaction();
try {
foreach ($events as $i => $event) {
$this->connection->insert('event_store', [
'aggregate_id' => (string) $aggregateId,
'aggregate_type' => Order::class,
'event_type' => $event::class,
'event_data' => $this->serializer->serialize($event, 'json'),
'sequence' => $expectedVersion + $i + 1,
'occurred_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
]);
}
$this->connection->commit();
} catch (\Throwable $e) {
$this->connection->rollBack();
throw $e; // UNIQUE constraint violation on sequence = optimistic locking failure
}
}
/**
* Load all events for an aggregate, optionally up to a specific timestamp (time travel).
*
* @return list<object> Domain event objects in sequence order
*/
public function load(OrderId $aggregateId, ?\DateTimeImmutable $until = null): array
{
$qb = $this->connection->createQueryBuilder()
->select('*')
->from('event_store')
->where('aggregate_id = :id')
->setParameter('id', (string) $aggregateId)
->orderBy('sequence', 'ASC');
if ($until !== null) {
$qb->andWhere('occurred_at <= :until')
->setParameter('until', $until->format(\DateTimeInterface::ATOM));
}
return array_map(
fn(array $row) => $this->serializer->deserialize(
$row['event_data'],
$row['event_type'],
'json',
),
$qb->fetchAllAssociative(),
);
}
}
6. Projections: building readable views on the event stream
Projections are the CQRS counterpart to the event-sourced aggregate: while the aggregate is responsible for write operations and builds its state from events, projections are responsible for read operations and build denormalized read models. A projection listens for events and updates its own table or record, optimized for queries, with exactly the fields and structure the display needs, without joins, without complex aggregations at query time.
In Symfony, projections are implemented as message handlers in Symfony Messenger that react to domain events. The advantage: they can be processed asynchronously by a queue worker, without blocking the write transaction. A projection for an order overview listens for OrderPlaced, PaymentReceived and OrderCancelled and updates an order_summaries table that can be queried directly for the admin overview. If the projection becomes corrupted, or a new view is needed, you simply drop the table and replay the entire event stream.
7. Snapshots: optimizing performance for long event streams
For long-lived aggregates that have accumulated hundreds or thousands of events, replay on load becomes expensive. A snapshot is a stored intermediate copy of the aggregate's state at a given point in time. When loading an aggregate, the latest snapshot is loaded first, then only the events since that snapshot are applied, instead of all events from the very beginning. The result is the same consistency as without a snapshot, but only a fraction of the events need to be loaded and processed.
Snapshots in Symfony are stored in their own table containing the serialized aggregate representation and the sequence number of the last applied event. One strategy for creating snapshots: after every nth modification (for example, every 100 events), or after a certain time interval. Important: snapshots are not a replacement for the event store, they are an optimization. Events are still stored in full and can be used for replay, audit and time travel. A corrupted snapshot can be regenerated at any time by rebuilding it from the event store.
8. Integrating Symfony Messenger as an event bus
Symfony Messenger is the natural integration point for event sourcing in Symfony. After the domain events are stored in the event store, they are additionally dispatched via Messenger as messages, for asynchronous processing by projections, notification handlers and external-system synchronizers. Messenger takes care of retry logic, dead-letter-queue handling and parallel workers, all infrastructure that is critical for event sourcing, because any failed event handler can leave projections in an inconsistent state.
Combining event sourcing with Symfony Messenger enables an outbox pattern: events are stored atomically with the aggregate in the event store, and only afterward delivered to subscribers by Messenger. If the Messenger dispatch fails, the events are still sitting in the event store and can be redispatched through a recovery mechanism. That guarantees exactly-once semantics: even if a worker process crashes, the event is not lost, it will be delivered again after the restart. Idempotent handlers (ones that can receive an event more than once without producing incorrect results) are the prerequisite for this.
9. Event sourcing vs. classic data storage
A direct comparison makes the strengths and weaknesses of event sourcing concrete. Not every project benefits from event sourcing: the implementation complexity and the mental model shift are real costs that must be justified by the benefits.
| Criterion | Classic data storage | Event sourcing | Assessment |
|---|---|---|---|
| Audit trail | Additional audit table needed | Included for free | Event sourcing wins clearly |
| Read queries | Direct SQL queries | Projections required | Classic is simpler |
| Debugging | Only current state visible | Full event history | Event sourcing wins |
| Implementation complexity | Low, CRUD with an ORM | High, event store, projections, replay | Classic is simpler |
| Time travel / replay | Not possible | Built in | Only with event sourcing |
Event sourcing pays off when: a complete audit trail is legally or functionally required, state must be reconstructed at past points in time, the domain is complex enough to justify the extra effort, or CQRS is used for separate read and write scaling. It does not pay off for: simple CRUD applications without complex domain logic, when the team has no DDD experience, or when the initial complexity cannot be offset by long-term maintainability gains.
Mironsoft
Symfony architecture, event sourcing and CQRS implementation
Want to implement event sourcing and CQRS in Symfony?
We support the introduction of event sourcing in Symfony projects, from aggregate modeling through the event store to projections, snapshots and Messenger integration.
Domain modeling
Designing aggregates, domain events and bounded contexts for event sourcing architectures
Event Store & Replay
Implementing an append-only event store, building a snapshot strategy and replay mechanism
Projections & CQRS
Building read models asynchronously with Symfony Messenger and optimizing them for high-performance queries
10. Summary
Event sourcing in Symfony is not magic, but it is a fundamental paradigm shift in data storage. State is not a truth that gets stored, it is the result of applying all historical events. Aggregates build their state through apply methods, the event store stores events append-only, and projections build denormalized read models for performant read access. The replay mechanism makes state reconstructible at any point in time and projections regenerable. Symfony Messenger distributes events asynchronously for scalable, decoupled processing.
The investment pays off for domains with high audit requirements, complex business logic and a need for full transparency. In regulated industries, the complete audit trail that comes as a free side effect of event sourcing is often decisive for compliance requirements. The combination with CQRS and Symfony Messenger produces an architecture optimized both for complex write operations and for high-performance read operations.
Symfony Event Sourcing, the Essentials at a Glance
Domain Events
Immutable readonly classes named in the past tense. Describe facts, not commands. Carry an aggregate ID, sequence number and timestamp.
Aggregate + Apply
Command methods validate and produce events. Apply methods only change state. The separation enables clean replay without re-validation.
Event Store
Append-only DBAL table. Sequence number = optimistic locking. Time travel by loading up to a given timestamp. Populate projections through a system-wide replay.
Projections
Messenger handlers build denormalized read models. Asynchronous, idempotent, regenerable at any time via event replay from the store.