Projections with State

PHP Event Sourcing Stateful Projections

The Problem

You need to count all tickets or calculate a running total across events, but you don't want to create a database table just for a counter. How do you keep state between event handler calls without external storage?

Projection State

Ecotone allows projections to carry internal state that is automatically persisted between executions. The state is passed to each event handler and can be updated by returning a new value.

This is useful for:

  • Counters and aggregates (total count, running average)

  • Throw-away projections that calculate a result, emit an event, and then get deleted

  • Projections that don't need an external database table

Passing State Inside Projection

Mark a method parameter with #[ProjectionState] to receive the current state. Return the updated state from the handler:

#[ProjectionV2('ticket_counter')]
#[FromAggregateStream(Ticket::class)]
class TicketCounterProjection
{
    #[EventHandler]
    public function when(
        TicketWasRegistered $event,
        #[ProjectionState] TicketCounterState $state
    ): TicketCounterState {
        return $state->increase();
    }
}

Ecotone resolves the #[ProjectionState] parameter and passes the current state. The returned value becomes the new state for the next event handler call.

State is shared across all event handlers in the same projection — if handler A updates the state, handler B receives the updated version.

circle-check

Fetching the State from Outside

To read projection state from other parts of your application, create a Gateway interface with #[ProjectionStateGateway].

Global Projection State

For a globally tracked projection, the gateway has no parameters — there's only one state to fetch:

Ecotone automatically converts the stored state (array or serialized data) to the declared return type (TicketCounterState). If you have a converter registered, it will be used:

circle-check

Partitioned Projection State (Enterprise)

For a partitioned projection, each aggregate has its own state. Pass the aggregate ID as the first parameter:

Usage:

Ecotone resolves the stream name and aggregate type from the projection's configuration, then composes the partition key internally. You only need to pass the aggregate ID — the rest is handled automatically.

Multi-Stream Partitioned Projections (Enterprise)

When a partitioned projection reads from multiple streams, Ecotone needs to know which stream the aggregate ID belongs to. Use #[FromAggregateStream] on the gateway method to disambiguate:

Each method targets a specific stream — so you can fetch state for a Calendar aggregate or a Meeting aggregate from the same multi-stream projection.

circle-info

#[FromAggregateStream] on the gateway method is only needed when the projection reads from multiple streams. For single-stream projections, Ecotone resolves the stream automatically.

High-Performance Projections with Flush State (Enterprise)

For projections that need to process large volumes of events quickly — during backfill or rebuild — you can combine #[ProjectionState] with #[ProjectionFlush] to build extremely performant projections.

The idea: instead of doing a database INSERT on every single event, you accumulate state in memory across the entire batch, and then persist it in one operation during flush.

With a batch size of 1000, this projection processes 1000 events without a single database write, then does one bulk persist during flush. During a rebuild over millions of events, this is dramatically faster than writing on every event.

circle-info

Using #[ProjectionState] in #[ProjectionFlush] methods is available as part of Ecotone Enterprise.

circle-check

Demo

Example implementation using Ecotone Lite.arrow-up-right

Last updated

Was this helpful?