Composing Building Blocks

How Ecotone's building blocks compose without orchestration code — one story, nine common composition problems

Most PHP applications grow by accumulating glue: listener services, coordinator classes, job wrappers, cron jobs, status flags on entities. Each new feature adds another piece of code whose only job is to connect things that already exist.

Ecotone's building blocks — Aggregates, Sagas, Command Handlers, Event Handlers, Internal Handlers, Routers, Splitters — compose directly through attributes. No orchestrator class sits in the middle. Adding a new feature almost always means adding only handler's logic, not writing wiring code.

This page tells one story: we'll build an Order Fulfillment system feature by feature. Each section addresses a real composition problem, adds a new pattern to solve it, and keeps everything we built before untouched. You'll see where orchestration code would normally live, and you'll see Ecotone eliminate it.

Prerequisites: Familiarity with Commands, Events, and Aggregates will make this walk-through easier to follow.

Start Here If You Already Use Symfony Messenger, Laravel Queues, or Vanilla PHP

Ecotone is not a replacement broker or a parallel universe. It runs inside your existing Symfony or Laravel app and uses your existing transports:

  • Symfony Messenger transports — Ecotone channels can consume from and publish to the same AMQP, Redis, Doctrine, or SQS transports Messenger uses.

  • Laravel Queues — Ecotone asynchronous channels run on Laravel's queue connection, sharing the same Redis/SQS/database driver your jobs use.

  • Existing handlers keep working — Ecotone builds on top of your transport or provide it's own implementation for more advanced features.

You don't trade your broker for Ecotone. You add Ecotone's composition model on top of the broker you already run.

Before the walkthrough, here's the wedge: a concrete pattern you likely already write in given framework, and what it costs you.

Click Symfony Messenger, Laravel Queues, or Vanilla PHP to see the patterns you likely already write in your stack — and what they cost you in boilerplate, missing primitives, and orchestration code that isn't business logic.

The Story

Problem to solve
Composition pattern
Orchestration code saved

Command → Aggregate → Event

Event publishing service

Aggregate → Aggregate via Event

Coordinating service, listener class

Aggregate → Saga via Event

State machine class, cron job

Command Handler → Internal Handler chain

Coordinator service, command-per-step

Orchestrator → dynamic step sequence

Branching state-machine class

Router → Aggregate / Command Handler

if/else in domain code

Splitter → Aggregate Command per item

Loop in a service

#[Asynchronous] on a single attribute

Job class, serialization plumbing

Distributed Bus between contexts

Transport code in domain

Each section below leads with a focused mini-diagram showing only that section's composition pattern, followed by the code. Solid arrows are normal channel flows; dotted arrows cross a different kind of boundary (a QueryBus call, a failure rerouted through an error channel, or a distributed-bus hop between services).

Saving an aggregate and publishing its events

Pattern: Command → Aggregate → Event

Start with an aggregate that accepts a command, records an event, and ends. No bus injection, no publisher service.

spinner

Works with your ORM: the aggregate above is a plain class. It can equally be a Doctrine ORM entity or an Eloquent Model — Ecotone delegates loading and persistence to your existing repository and runs inside your existing transaction.

The sending side of our system is complete. Everything that follows subscribes to OrderPlaced and other events we'll record — without modifying Order once.

Reacting from one aggregate to another's events

Pattern: Aggregate → Aggregate via Event

Customers earn loyalty points when they place orders. The natural place for that logic is the LoyaltyAccount aggregate itself — not a coordinator service.

spinner

identifierMapping tells Ecotone which aggregate instance to load based on the event's payload. No query, no lookup service — Ecotone loads the right LoyaltyAccount, mutates it, and saves it.

What's absent: no OrderPlacedListener service, no LoyaltyAccountRepository call wired to an event subscriber, no LoyaltyService::credit(). The reaction lives in the aggregate that owns the behaviour.

Click Symfony Messenger or Laravel Queues to see the additional wiring — both event publishing and subscriber side — that those frameworks require on top of the Ecotone code above.

The Messenger and Laravel versions above aren't alternatives — they're additions. You still need the LoyaltyAccount aggregate method that credits points and records LoyaltyPointsEarned. What those frameworks add on top is both the event-publishing call at the write site and the subscriber-side routing and lookup — which Ecotone collapses into $this->recordThat(...) inside the aggregate and #[EventHandler(identifierMapping: ...)] on the receiver.

Coordinating long-running workflows with state and timeouts

Pattern: Aggregate → Saga via Event

Payment coordination is long-running: request payment, wait for gateway confirmation, retry on timeout, escalate after SLA. That's a Saga — a stateful workflow bound to an identifier.

spinner

Two different events, two different sources — one is our domain event carrying orderId in payload, the other is a gateway callback carrying orderId in message headers. identifierMapping handles both.

See Identifier Mapping for the full set of mapping strategies: payload, headers, and expression references to DI services.

Built on patterns you already know: like aggregates, a Saga can be a Doctrine ORM entity or an Eloquent Model — Ecotone reuses your existing repository for loading and persistence, and runs inside your existing transaction. You're not learning a new persistence story; you're applying messaging composition on top of the patterns you already use.

Time-based actions — payment timeout via #[Delayed]

Payments don't always come back. The gateway may go silent, the customer may close the tab, the webhook may never fire. We need to time the saga out — but without standing up a cron job, a scheduler service, or a polling worker. Adding a delayed handler on PaymentRequested does it: the same event that starts the payment also schedules its timeout, and the broker holds the message for us until the deadline.

spinner

Three things happen for free:

  1. The same event drives both the immediate and delayed paths. PaymentRequested reaches start() synchronously and onTimeout() 30 minutes later. No second message, no scheduled job — the broker holds the delayed copy until the deadline.

  2. The timeout is a normal saga method. It loads the same PaymentProcess instance via identifierMapping, sees the up-to-date status, and decides whether to fire PaymentTimedOut or no-op. State is consulted at execution time, not at scheduling time.

  3. Replaces the cron job entirely. No "every minute, look for orders older than 30 minutes that are still pending" query. No watcher service. No SLA-table polling. The deadline is encoded in the message itself.

Click Symfony Messenger or Laravel Queues to see the wiring required to deliver the same 30-minute timeout — separate message/job class, manual dispatch of the delayed message, manual saga lookup at delivery time.

Click Symfony Messenger or Laravel Queues to see the additional wiring — event publishing, saga loading, identifier extraction from payload vs headers — that those frameworks require on top of the Ecotone saga above.

The Messenger and Laravel versions above aren't alternatives — they're additions. You still need the PaymentProcess saga class with payment-state and transition logic. What those frameworks add on top is publishing each event at every write site, plus saga loading and identifier extraction from payload vs headers on the subscriber side — which Ecotone collapses into $this->recordThat(...) inside the saga and two #[EventHandler(identifierMapping: ...)] attributes on the receivers.

Passing a message through a multi-step pipeline

Pattern: Command Handler → Internal Handler chain

Before placing an order we want to compute pricing, apply discounts, then save. Each step is its own handler; they chain via outputChannelName.

spinner

The final link targets the aggregate we already have:

Click Symfony Messenger or Laravel Queues to see how the same four-step pipeline becomes four message classes, four handlers, and four bus injections in those frameworks — because a single message cannot be passed along through multiple handlers.

The Messenger and Laravel versions above aren't alternatives — they're additions. The business logic of validation, pricing, discount application, and aggregate persistence stays the same. What those frameworks add on top is N command/job classes, N dispatch sites, and (in Laravel's case) per-step state loading between handlers — which Ecotone collapses into one outputChannelName per step on methods that pass the message object directly to the next link.

Computing the workflow shape from data

Pattern: Orchestrator → dynamic step sequence

The static pricing chain from the previous section hard-coded a four-step pipeline with outputChannelName. That's perfect when every order follows the same recipe. Fulfillment is different: digital downloads skip shipping, gift orders need wrapping, fraud-flagged orders need an extra verification step. Instead of branching with a chain of Routers, we compute the step list from data and let an #[Orchestrator] execute it.

spinner

Compare with the static chain (previous section) and the Router (next section): static chain = fixed linear sequence; Router = one decision point between two paths; Orchestrator = entire sequence computed from data. Each is the right tool for a different shape of workflow.

Enterprise feature — honest framing: Orchestrator lives in Ecotone's paid Enterprise bundle. The open-source equivalent is to combine static chains, Routers, and Sagas (covered in the surrounding sections) — that covers most workflow shapes, at the cost of not being able to return the step list as data. Reach for Orchestrator when the sequence itself is the thing that changes per message.

See Orchestrators: Declarative Workflow Automation for the full reference.

Click Symfony Messenger or Laravel Queues to see why dynamic-step workflows are usually not built in those frameworks — the plumbing volume makes them not worth it.

The honest summary. In Messenger and Laravel, workflows whose step sequence depends on data are technically possible but rarely worth building — the plumbing volume (command classes, handlers, dispatch sites, "what's next" decisions sprayed across handlers) costs more than the feature delivers. So teams reach for status flags, conditional fields on entities, big if/else blocks in service classes, or just don't model the workflow at all and let it emerge from request handlers.

Ecotone's Orchestrator collapses this to one method that returns an array of channel names. Patterns that weren't worth building become worth building — a fulfillment workflow that picks 3-of-7 steps based on order type is ten lines of code, not a 200-line refactor. The composition model lowers the cost floor for advanced patterns enough that they show up in projects where they previously wouldn't have.

Branching a flow without if/else in domain code

Pattern: Router → Aggregate / Command Handler

A Router's job is dynamic redirection — at runtime it inspects the message and returns the channel name to send it to next. Branching becomes a first-class step in the pipeline rather than an if/else baked into a handler. B2B customers require approval before placing; B2C orders skip straight to the aggregate. The Router decides per message — business code stays branch-free.

spinner

The pricing chain we built earlier is unchanged. The aggregate is unchanged. We added one Router and one InternalHandler — the B2B path is now a fully isolated concern.

Adding more variants later: a new flow for wholesale customers is a new case in the Router plus a new InternalHandler. Nothing downstream has to know.

Fanning out one event to per-item operations

Pattern: Splitter → Aggregate Command per item

When an order is placed, each line item needs a stock reservation on its own Stock aggregate. A Splitter fans out; each output message targets the aggregate's command handler.

spinner

Wire the event into the splitter with one more subscription:

Per-item isolation: each reservation is a separate message. Retries, failures, and concurrency are per-product-aggregate — not per-order.

Click Symfony Messenger or Laravel Queues to see the additional wiring — manual per-item dispatch, per-item aggregate lookup, per-item StockReserved event publishing — that those frameworks require on top of the Ecotone Splitter above.

The Messenger and Laravel versions above aren't alternatives — they're additions. You still need the Stock aggregate with a reserve() method and the ReserveStock command shape. What those frameworks add on top is manual fan-out, per-item repository lookup, and explicit publishing of both OrderPlaced upstream and StockReserved at every item — which Ecotone collapses into #[Splitter] returning an array and $this->recordThat(...) inside the aggregate.

Moving a handler to async

Pattern: #[Asynchronous] on a single attribute

The loyalty handler from the aggregate-to-aggregate section doesn't need to block the order-placement request. Crediting points is fire-and-forget from the customer's point of view — they don't refresh and check their points balance immediately after checkout. Adding one attribute moves the loyalty work off the request thread onto a broker — every retry, DLQ, and isolation guarantee kicks in automatically.

spinner

Declare the channel once — any transport works:

You can apply #[Asynchronous] to any handler in any chain we've built so far. The pricing chain, the stock fan-out, the payment saga — each is a candidate for an independent queue with independent retry and priority settings.

Click Symfony Messenger or Laravel Queues to see why moving one subscriber from sync to async per-handler isn't a one-line change in those frameworks.

The Messenger and Laravel versions above aren't alternatives — they're additions. You still need the loyalty-crediting business logic from the aggregate-to-aggregate section. What those frameworks add on top is transport routing per message class (Messenger) or per-listener queue config (Laravel) — and in Messenger's case, the routing is at the wrong granularity (per message class, not per handler), forcing you back into command-per-subscriber to regain isolation.

Splitting bounded contexts across services

Pattern: Distributed Bus between contexts

Payment is a bounded context that deserves its own service and database. The Order service stays as-is; the Payment service exposes its handlers over a distributed bus.

spinner

Inside the Order service — publish across the boundary:

Inside the Payment service — consume across the boundary:

No shared classes required. Each service defines its own OrderPlaced shape. Ecotone converts between them via registered converters. Messages flow over any supported broker — switching RabbitMQ for SQS is a configuration change.

The Saga from earlier can live on either side. Everything we built before continues to work — splitting the system is an infrastructure decision, not a code rewrite.

Failures and Resume

At some point, one handler will fail. A mailer will time out. A third-party API will 500. In most message-bus setups this cascades — all subscribers on the event get re-delivered, already-succeeded work gets repeated, and the only way to recover is to manually purge the queue or write compensating logic.

Ecotone's per-handler isolation turns this into a surgical operation: only the failing handler's message goes to its Dead Letter Queue. Everything else stays done. When you resume, only the failed handler replays — not the chain.

Here's what happens when OrderPlaced triggers three async subscribers and one fails:

spinner

Three things to notice:

  1. Handlers 1 and 2 stay complete. Ecotone delivered a separate copy of the message to each subscriber. A failure in handler 3 does not roll back the others, does not re-enqueue them, does not cause them to see the message twice.

  2. Handler 3's message lands in its own DLQ after the configured retry policy is exhausted — not the "global DLQ" for the whole event.

  3. Replay is per-handler. An operator (or an automated replay job) pulls the failed message out of the DLQ and sends it back to handler 3 only. The other subscribers are untouched; the aggregate state is untouched; no compensation logic runs.

Wiring the error channel and retry policy is one ServiceContext:

One configuration block, three retries with exponential backoff, then to Dead Letter. Same policy works for RabbitMQ, SQS, Kafka, Redis, or Database channels — you don't rewrite it per broker.

At-least-once semantics and idempotency. The dotted arrows throughout this page — failures, emissions, distributed-bus hops — all carry at-least-once guarantees. Make handlers idempotent at the handler level (or use #[Deduplicated] with a stable key expression) when the operation isn't naturally safe to retry. Ecotone stores deduplication state in your application database, so it survives redeployments.

Failures aren't a separate discipline in Ecotone. They flow through the same channel composition model as the happy path, which is why resuming from them is a one-handler operation rather than a full-chain rewind.

The Real Cost

The honest comparison isn't a file count. It's a cost dynamic that goes two ways — and Ecotone removes both.

Option A — you don't build it. Most of what this page demonstrated (a routing-slip Orchestrator computed from data, a Splitter with per-item retry, a Router that branches dynamically, a Saga with a 30-minute timeout, per-handler isolation on event fan-out) is technically possible to assemble by hand on top of any framework. But the cost of standing each one up — designing the message flow, wiring step-to-step, persisting interim state, threading correlation through, building the test harness — is high enough that most teams don't build them. The feature ships as a status column and a polling job, or as an if/else in a controller, or doesn't ship at all. The composition stays in someone's head as "we'd do that if it were cheap." The cost shows up as features the product never gets.

Option B — you build it, and now you maintain it. The orchestration code is real. It needs tests. It needs to be understood by everyone touching the feature. It needs updating when the flow changes. The developer reading the codebase has to understand both the business flow ("when an order is placed, credit loyalty, reserve stock, request payment, time it out at 30 minutes…") and the orchestration scaffolding that makes the flow run ("the OrderPlacedDispatcher fans out, the PaymentSagaStateMachine has a status enum that gates handler entry, the FulfillmentOrchestrator dispatches based on a switch on order type, the timeout-check job loads the saga and decides whether to act"). The wiring becomes part of the surface area you carry forward — every refactor crosses both layers.

Ecotone collapses both options. The composition is the attribute — there is nothing to scaffold and nothing to skip. A handler that needs to be a step in a chain gets outputChannelName. A handler that needs to time out gets #[Delayed]. A handler that needs to fan out gets #[Splitter]. The flow is testable as a flow with EcotoneLite::bootstrapFlowTesting. The thing you read in the code is the business flow — there is no orchestration layer underneath that you also have to read, test, and maintain.

Everything else — retry policies, dead-letter routing, correlation/causation propagation, per-handler isolation, transport conversion — is code you no longer own.

Works With AI-Assisted Coding

There's a second audience reading your code now: the AI assistants and agents you use to generate and refactor it. The composition model in this walk-through changes what they have to load and what they have to write.

Less context to load per reasoning step. When flows compose through attributes instead of orchestrator classes, an agent answering "what happens when OrderPlaced fires" doesn't need to read a listener registry, a process manager, a state-machine class, and a dispatcher. The subscribers are methods with #[EventHandler] on the event's class — the relationship is structural, not threaded through glue code. An agent can answer "what pipeline does this handler belong to?" from the file in front of it, without following dispatch chains across the repository.

Less code to generate per change. Adding a new reaction to OrderPlaced is one method with one attribute. Not a new listener class, not a new transport route, not a new job DTO, not a new dispatch site, not a new config entry. Each iteration of code generation touches a small, focused surface — faster cycles, fewer tokens, fewer places to introduce subtle bugs. The dispatch-site problem ("you forgot to emit OrderPlaced at this write location") disappears when the aggregate emits it automatically via recordThat().

Less plumbing for the AI to get subtly wrong. Every feature an AI generates that carries its own orchestration — dispatch calls, state machines, listener registration, transport routing, retry configuration — is a new place where the AI could get something subtly wrong, and a new piece of code you have to read, review, and write tests for. Ecotone's attributes compose against framework-level behaviour that has already been tested on every release: publishing, delivery, per-handler isolation, conversion, retry, dead-lettering, correlation. The AI applies an attribute; the framework handles the rest. Review focus shifts from "did the AI wire this feature's plumbing correctly" to "is the business logic inside this handler correct" — because orchestration is abstracted away, the remaining thing to verify is the domain behaviour itself, not the scaffolding around it. Fewer new paths to audit per generation cycle, and the audit that remains is the one that actually matters.

Review capacity scales with diff size. When AI generates large amounts of code, review is where bugs get caught — or missed. Review quality degrades with diff length: past a certain point reviewers skim rather than read, miss edge cases, and rubber-stamp changes that smuggle in regressions. This is especially true when a diff contains framework plumbing that looks familiar from a hundred prior reviews — attention fatigues, pattern-matching replaces reading, and the subtle bug hides in the familiar-looking scaffolding. Keeping each generated cycle small — business logic plus a single attribute, not business logic plus a listener class plus a transport config plus a dispatch site — keeps reviewers' limited attention on the code that actually needs scrutiny. As AI-assisted generation scales up across a team, controlling what reaches review is the safety valve, and higher-level abstractions are one of the few techniques that downsize each change to the parts that matter.

Consistent patterns across building blocks. #[EventHandler] works the same way on an aggregate method, a saga method, or a free-standing service. #[CommandHandler], #[Asynchronous], #[Identifier] — all applicable everywhere they make sense. An agent that learned the attribute once applies it everywhere, without fresh scaffolding reasoning per task.

Flow maps are shorter. To understand an Ecotone flow, an agent reads attribute-annotated methods and follows channel names. To understand the equivalent Messenger flow it reads handler classes, dispatcher classes, a state-machine class, transport config, and the routing yaml — then reconstructs the graph. The Ecotone map fits in a smaller context window; the Messenger map often doesn't for a non-trivial flow.

The same property that lets a new human engineer read one handler and understand where it fits also lets an AI assistant plan a change from a smaller context snapshot — and produce a diff that matches the shape of the codebase instead of inventing yet another coordinator class.

Where This Takes You

Each composition pattern is documented in depth on its own page.

The composition model is the same at every scale. Start with a single handler on day one, and the same attributes carry you through a distributed system on year three — without the orchestration code.

Last updated

Was this helpful?