Emitting Events

PHP Event Sourcing Projection Event Emission

The Problem

A user adds money to their wallet. The MoneyWasAddedToWallet event is published. A notification service subscribes to that event and sends a "Your new balance is X" WebSocket message. The user clicks the notification, opens the dashboard, and sees the old balance.

The notification arrived before the projection finished updating the read model.

This is not a bug — it is the consequence of subscribing to what happened in the domain when what you actually need is the moment the read model reflects that fact.

Why Common Workarounds Fail

  • Version-based retry — the subscriber polls the read model until the version matches. Adds latency, adds load, leaks projection internals to consumers.

  • Synchronous projections — keep everything in the command's transaction. Solves the race but blocks every write on every projection; one slow projection slows down the whole write path, plus failure in projection rollbacks the event itself.

  • Event enrichment — pack the new balance directly into MoneyWasAddedToWallet. Couples every subscriber to the projection's internal schema, inflates the domain event, and still doesn't help anyone who reads other fields from the read model.

What you actually want is a second event — a derived fact — that fires after the projection has committed.

The Solution: Emit Events from Projections

Instead of subscribing to domain events (which fire before the projection updates), subscribe to events emitted by the projection itself — these fire after the Read Model is up to date.

Emit the Event

Use EventStreamEmitter inside your projection to emit events after updating the Read Model:

Emitted events are stored in the projection's own stream.

Events are stored in a stream called project_{projectionName}. In the example above: project_wallet_balance.

The Cost of Per-Event Emission

Every projected event now does three things: read current state, write the updated read model, and emit a derived event. That is at least one extra write to the Event Store per domain event.

Worse, most of those emissions are noise. If a wallet receives twenty MoneyWasAddedToWallet events in the same batch — a payroll run, a webhook replay, a bulk import — you emit twenty WalletBalanceWasChanged events, even though every downstream consumer only cares about the final balance for the batch.

For low-volume read models the cost is negligible. For high-volume ones it dominates the workload.

Batched Emission with Flush

When per-event emission becomes a bottleneck, move emission into #[ProjectionFlush]. The handler accumulates state across the batch; the flush handler writes the read model and emits a single derived event per partition per batch.

This pattern requires #[Partitioned] — the flush state is per-aggregate, so emission stays meaningful (one event per wallet per batch, not one event for the whole batch across all wallets).

Twenty MoneyWasAdded events in the same batch now produce one WalletBalanceWasChanged per wallet, with the final balance. The read model write and the emission still commit together — the consistency guarantee is untouched.

Subscribing to Emitted Events

After emitting, you can subscribe to these events just like any other event — in a regular event handler or even another projection:

Linking Events to Other Streams

In some cases you may want to emit an event to an existing stream (for example, to provide a summary event) or to a custom stream:

Public API Streams

linkTo becomes especially useful for publishing a curated event stream that other teams or other applications consume. Rather than exposing your raw domain events — which leak internal aggregate structure and change every time you refactor — emit a named stream of high-level business facts:

  • completed_orders

  • cancelled_subscriptions

  • kyc_approved_customers

  • flagged_transactions

The downstream consumer subscribes to that stream. You own the contract; the domain stays free to evolve.

Enriching the published event is fine — even encouraged. A consumer reading completed_orders should not have to query back into your service for the customer, total, or shipping carrier. Pack what consumers need onto the emitted event:

This is the opposite of enriching domain events. Domain events stay minimal — only what the aggregate needs to rebuild itself. The published contract is where enrichment belongs: you control its shape, and consumers stay decoupled from your internals.

Cross-Aggregate Correlation

Some business facts only exist when multiple aggregates have reached a particular state. An order is "fully completed" when the Order, Payment, and Shipment aggregates have all hit their terminal states. None of those aggregates can emit OrderFullyCompleted on their own — they don't know about each other.

A projection that subscribes to all three streams and tracks which conditions have been met can emit the derived fact when the conjunction is reached:

The projection is the only place in the system that knows when "fully completed" happens. It owns the definition; the rest of the system just consumes completed_orders.

Pipelines: Derived Streams as Input for Other Projections

Emitted events are just events. Another projection can subscribe to them and emit further derived facts — a multi-stage pipeline of progressively higher-level signals:

Each stage is autonomous: its own position tracker, its own failure handling, its own ability to catch up after a crash. If high_value_wallets fails, wallet_balance keeps running; risk_assessment pauses until high_value_wallets recovers, then resumes from where it stopped.

You build a chain of derived facts the same way you build the first one — there's no special syntax for "pipeline projections."

Controlling Event Emission

During Rebuild

When a projection is rebuilt (reset and replayed from the beginning), emitted events could be republished — causing duplicate notifications and duplicate linked events.

Ecotone handles this automatically: events emitted during a reset/rebuild phase are not republished or stored. This is safe by default.

With ProjectionDeployment (Enterprise)

You can also explicitly suppress event emission by setting live: false on #[ProjectionDeployment]:

This is important because backfill will emit events — it replays historical events through your handlers, and if those handlers call EventStreamEmitter, all those events will be published to downstream consumers. If you're backfilling a projection with 2 years of history, that means thousands of duplicate notifications.

Use live: false during backfill to prevent this, then switch to live: true once the projection is caught up. This is the pattern used in blue-green deployments.

#[ProjectionDeployment] is available as part of Ecotone Enterprise.

Deleting the Projection

When a projection is deleted, Ecotone automatically deletes the projection's event stream (project_{name}).

Custom streams created via linkTo are not automatically deleted — they may be shared with other consumers.

Demo

Example implementation using Ecotone Lite.

Last updated

Was this helpful?