Starting with Event Sourcing in PHP
How to build event-sourced aggregates and projections in PHP with Ecotone — native event store, snapshots, projections, replay, and tested in-memory.
Updated on 2026-05-17
A customer claims they bought a product for €79 last March, but your products table currently shows €89 with one row per product. You can’t answer their question — the price now is all you have. The audit log shows when the row was updated, not what the actual price was at the moment of purchase. That gap is the one event sourcing closes by design.
The short version. Event sourcing stores state as a sequence of events (“PriceWasChanged from €89 to €79”), not as the latest row. To know the state at any moment, replay the events up to that moment. To answer queries fast, project events into a denormalised read model. Ecotone has native event sourcing built-in, with the same attribute-driven API as the rest of the framework.
State as a stream — not a row
Here’s the standard mutable model:
final class Product
{
public function __construct(
private string $id,
private int $priceCents,
) {}
public function changePrice(int $newPriceCents): void
{
$this->priceCents = $newPriceCents; // history lost
}
}The event-sourced version doesn’t mutate state — it records what happened:
#[EventSourcingAggregate]
final class Product
{
private string $id;
private int $priceCents;
#[CommandHandler]
public static function create(CreateProduct $command): array
{
return [new ProductWasCreated($command->id, $command->priceCents)];
}
#[CommandHandler]
public function changePrice(ChangePrice $command): array
{
return [new PriceWasChanged($this->id, $this->priceCents, $command->newPriceCents)];
}
#[EventSourcingHandler]
public function applyCreated(ProductWasCreated $event): void
{
$this->id = $event->productId;
$this->priceCents = $event->priceCents;
}
#[EventSourcingHandler]
public function applyPriceChanged(PriceWasChanged $event): void
{
$this->priceCents = $event->newPriceCents;
}
}Two things to notice. The command handler doesn’t change state directly — it returns an event. The event-sourcing handler is what mutates the in-memory state, and it’s also what runs during replay when Ecotone rebuilds the aggregate from history. The same event type goes through the same code whether it’s being recorded for the first time or replayed five years later.
Projections — answering questions fast
Replaying every event for every read would be slow. Projections denormalise events into read models optimised for the queries you actually run:
#[ProjectionV2("price_history")]
#[FromAggregateStream(Product::class)]
final class PriceHistoryProjection
{
#[EventHandler]
public function whenChanged(PriceWasChanged $event, Connection $db): void
{
$db->insert("price_history", [
"product_id" => $event->productId,
"old_price" => $event->oldPriceCents,
"new_price" => $event->newPriceCents,
"changed_at" => (new \DateTimeImmutable())->format("c"),
]);
}
#[QueryHandler("product.priceAt")]
public function priceAt(string $productId, \DateTimeImmutable $at, Connection $db): int
{
return (int) $db->fetchOne(
"SELECT new_price FROM price_history
WHERE product_id = ? AND changed_at <= ?
ORDER BY changed_at DESC LIMIT 1",
[$productId, $at->format("c")]
);
}
}Projections are derived state: drop the table and replay, and you get the same data back. That’s the contract that makes event sourcing safe to evolve — you can change the projection logic, replay, and the read model rebuilds itself.
For the deep-dive on rebuilds, blue-green projection deployments, and partitioning, see Your Projections Will Fail — Make Them Resilient.
Sending the command, reading the projection
$commandBus->send(new CreateProduct($id, 8900));
$commandBus->send(new ChangePrice($id, 7900));
// later...
$priceLastMarch = $queryBus->sendWithRouting(
"product.priceAt",
[$id, new \DateTimeImmutable("2026-03-15")]
);The command bus and query bus are auto-registered — just inject them where you need them.
What you get for free
Beyond the basic aggregate + projection pair, Ecotone’s native event sourcing ships with:
- Snapshots — for aggregates with thousands of events, snapshot the latest state to skip replay from zero. Snapshotting docs.
- Event versioning / upcasting — rename fields or split events without breaking historic data.
- Gap detection — projections can detect missing events and recover.
- Backfill + rebuild — populate a new projection from historic events without downtime. Backfill docs.
- Multi-tenant streams — one event store, isolated streams per tenant.
- EcotoneLite testing — aggregate and projection tests in-memory, deterministic, no event store setup needed.
Symfony, Laravel, or standalone — same code
# Symfony
composer require ecotone/symfony-bundle ecotone/event-sourcing
# Laravel
composer require ecotone/laravel ecotone/event-sourcing
# Standalone
composer require ecotone/lite-application ecotone/event-sourcingCommon questions
Do I need event sourcing for everything?
No. Event sourcing earns its keep where history matters — financial ledgers, audit-heavy domains, anything where “what was true at time X” is a real question. For CRUD-shaped domains, state-stored aggregates are simpler. Ecotone supports both side by side.
Where are events stored?
Ecotone’s event store sits on top of your existing database — PostgreSQL, MySQL, or MariaDB through Doctrine DBAL. No separate database to operate; the events live in the same connection as the rest of your data, so backups and migrations stay simple.
How do I test event-sourced aggregates?
EcotoneLite::bootstrapFlowTesting() with the aggregate registered. Send commands, assert recorded events. No event store, no database. Tests run in <100ms.
Wrapping up
Event sourcing isn’t a silver bullet, but for any domain where “what happened?” is a real question, it’s the only model that gives you a complete answer. The setup cost in 2026 PHP is one composer require and one attribute on your aggregate. Companion reads: CQRS in PHP for the read/write split that event sourcing builds on, and Event Handling in PHP for the synchronous publish-subscribe pattern.
Full surface in the Ecotone event sourcing docs; runnable example at the quickstart repo.
Dariusz Gafka is a Software Architect and author of the Ecotone Framework. He writes about event sourcing, CQRS, and message-driven PHP architecture.