Execution Modes
PHP Event Sourcing Projection Execution Modes
The Problem
Your projection runs in the same request as the command handler, and under heavy load it slows down your API. Or you have multiple projections and don't want one slow projection to block others. How do you control when and where projections execute, and what consistency trade-offs come with each choice?
Execution modes determine where your projection runs (same process or background worker) and when it processes events (immediately or later). Each mode comes with different consistency guarantees.
Choosing the Right Mode
This is about where and when execution happens, and what consistency consequences you accept:
Consistency
Immediate
Eventual
Eventual
Transaction
Same as command
Batched, per-batch commits
Batched, per-batch commits
Triggering
On event publish
On event via channel
Polls database at intervals
Best for
Low write volume, testing
Production workloads
Dedicated background worker
You can start with synchronous projections for simplicity, and switch to asynchronous later by adding a single attribute — no code changes needed in your projection handlers.
Synchronous Event-Driven (Default)
By default, projections execute synchronously — in the same process and the same database transaction as the Command Handler that produced the events.
#[ProjectionV2('ticket_list')]
#[FromAggregateStream(Ticket::class)]
class TicketListProjection
{
// No additional attributes needed — synchronous is the default
#[EventHandler]
public function onTicketRegistered(TicketWasRegistered $event): void
{
// This runs in the same transaction as the command
}
}Synchronous projections run within the same database transaction as the Event Store changes. When you query the Read Model right after a command, you always get consistent, up-to-date data.
When to use:
Low write volume — a few writes per second
Testing — immediate feedback, no async complexity
Simple applications — where eventual consistency adds unnecessary complexity
Trade-off: If the projection is slow (complex queries, external calls), it slows down the entire command handling. For high-throughput scenarios, consider asynchronous execution.
Asynchronous Event-Driven
To decouple the projection from the command handler, mark it as asynchronous. The event is delivered via a message channel and processed by a background worker:
The projection code stays exactly the same — you just add #[Asynchronous('projections')]. Ecotone handles delivering the trigger event via the projections channel.
To start the background worker:
When to use:
High write volume — projection processing shouldn't slow down commands
Multiple projections — each can process at its own pace
Production workloads — decoupled, resilient processing
Trade-off: Data in the Read Model may be slightly behind the Event Store (eventual consistency). If you query immediately after a command, you might get stale results.
Batch Size and Flushing
By default, projections load up to 1000 events per batch. You can customize this with #[ProjectionExecution]:
How Batching Works
Events are processed in batches, and each batch is wrapped in its own database transaction. After each batch:
#[ProjectionFlush]handler is called (if defined)The projection's position is saved
The transaction is committed
This prevents one massive transaction from locking your database tables for the entire projection run. Even if you have 100,000 events to process, the database is only locked for one batch at a time.
Ecotone automatically manages transactions at batch boundaries. In async mode, each batch gets its own transaction — not the entire message processing. If you use Doctrine ORM, Ecotone also flushes and clears the EntityManager at batch boundaries automatically, preventing memory leaks.
Polling (Enterprise)
Polling projections run as a dedicated background process that periodically queries the event store for new events:
Last updated
Was this helpful?