Symfony Messenger vs Ecotone: The Real Difference
The long answer to "So it's like Symfony Messenger?": Messenger is a transport layer, Ecotone a messaging layer with an architecture layer on top.
Updated on 2026-09-03
I am often asked what the difference is between Ecotone and Symfony Messenger, and the honest answer needs more room than a Reddit or Twitter comment. Messenger is a transport layer, and it does that job well: it gets a message from a publisher to a handler and hides the broker underneath. Ecotone's difference sits one level up, in what the abstraction is built around, and it only becomes visible once a flow has more than one step.
TL;DR: Symfony Messenger is a transport layer — it delivers a message to its handler and hides the broker underneath. Ecotone is a messaging layer — handlers bind to channels, so multi-step flows are wired once instead of redispatched at every step, and its architecture layer provides building blocks on top of that (Aggregates, Sagas, Projections, Orchestrators) which inherits retries, dead-lettering and asynchronicity.
Table of contents
- The question behind the comparison
- Symfony Messenger is a transport layer
- The messaging layer focuses on flows
- Dictate routing in business logic
- Async is about execution, not the message
- Testing the flow, piece by piece
- The outbox is channel composition
- Messaging is not only about async
- Adopt it one flow at a time
- Ecotone is an architecture layer
- Wrapping up
The question behind the comparison
Ecotone vs Symfony Messenger is a question I keep answering in places built for one-liners. Under one of my recent Reddit posts about Ecotone, one of the comments boiled down to: "So it's like Symfony Messenger?" - The implication was clear: if two looks the same way, why bother to change? Another comment put the same sentiment directly: "Why not just use Symfony Messenger. Does everything well."
Messenger is the tool most PHP developers already know and have hands-on experience with, which is exactly why Ecotone gets compared to it most. The honest answer needs more room than a comment thread gives, so this is my longer answer. And the first thing to state plainly about Symfony Messenger is what kind of abstraction it is.
Symfony Messenger is a transport layer
A transport layer does one job, and does it well: it gets a message from one place to another.

On one side there is a publisher, on the other side the handler we want the message delivered to. Everything in between — connecting to the broker, converting the message, consuming it — is complexity Messenger absorbs. Absorbing it is its main goal.
In practice that means we get to work with the message as a plain class. Messenger serializes it into whatever the underlying message broker understands, moves it across, and deserializes it back into the class we sent.
// Symfony Messenger: dispatch a message class,
// the handler registered for that class receives it.
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
final readonly class OrderPlaced
{
public function __construct(public string $orderId) {}
}
// Sending side ($bus is Messenger's MessageBusInterface)
$bus->dispatch(new OrderPlaced('123'));
// Receiving side: the handler registered for the OrderPlaced class
#[AsMessageHandler]
final class SendOrderConfirmation
{
public function __invoke(OrderPlaced $message): void
{
// react to the placed order
}
}
Dispatch a class, the handler registered for that class receives it.
Sending messages this way is much easier than integrating with a broker directly, where connecting, converting and consuming are all yours to handle. That ease is the transport layer's value, and it is real. Messenger goes further than bare delivery, too: a message travels wrapped in an envelope, stamps let metadata travel with the payload, and middleware lets you hook into the dispatch process itself.
Those are extras on top of the model. The core stays the same: deliver a message from sender to handler. Where things start to diverge is what happens when one delivery is not the whole story.
The messaging layer focuses on flows
The messaging layer focuses on message flows rather than message transport. At a high level the two can look similar — there is a bus, there are messages, there are handlers — but the abstraction driving them is different.
The messaging layer is built to help with message flows: take a message and pass it through steps. A single message can travel through different handlers, because the message itself is not coupled to a destination.
To see what that means in practice, take a flow every bank and lending platform runs: loan application approval. An application comes in, and before anyone can say yes it moves through steps — the applicant is verified (does the declared identity and income hold up?), the application is scored (how risky is this loan?), and only then a decision falls. The order is not an implementation detail, it is business policy: nobody scores an unverified applicant, and nobody decides without a score. And each step adds knowledge the next one needs.

To be fair before any code: when all three steps run in one process and are allowed to fail together, a single handler calling three services could be right call. The steps become messages the moment one of them must survive a crash, call an external system, or run in the background — and a loan flow, with a credit bureau in the middle of it, is exactly that case. That is the flow we are building.
When building a flow like this on Symfony Messenger, we will often need to retrigger the publisher: the handler that finished its step creates the next message class and dispatches it, so the next handler can be reached. And because a redispatch is a new message, everything the earlier steps established has to be carried in that class — the fields accumulate at every hop.
// Symfony Messenger: the loan flow — each step is its own handler, and
// moving forward means creating the next message and dispatching again.
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final class VerifyApplicantHandler
{
public function __construct(
private ApplicantVerifier $verifier,
private MessageBusInterface $bus,
) {}
public function __invoke(LoanApplication $application): void
{
$verification = $this->verifier->verify($application);
// the same data, repackaged into a new class plus what this step
// added — reusing LoanApplication is not an option here: the class
// is the routing, dispatching it again would land right back here
$this->bus->dispatch(new ScoreLoanApplication(
$application->applicationId,
$application->amount,
$verification,
));
}
}
#[AsMessageHandler]
final class ScoreLoanApplicationHandler
{
public function __construct(
private RiskScoring $scoring,
private MessageBusInterface $bus,
) {}
public function __invoke(ScoreLoanApplication $message): void
{
$score = $this->scoring->score($message);
$this->bus->dispatch(new DecideOnLoanApplication(
$message->applicationId,
$message->amount,
$message->verification, // retyped from the previous class
$score,
));
}
}
#[AsMessageHandler]
final class DecideOnLoanApplicationHandler
{
public function __invoke(DecideOnLoanApplication $message): void
{
// approve or reject, based on everything carried along
}
}
Each step finishes by dispatching the next message class to reach the next handler.
Anyone who has built flows this way knows where it leads: each handler is coupled to the next through the message class between them, ScoreLoanApplication and DecideOnLoanApplication exist only to carry the flow forward — and look at what they carry: every field an earlier step established, retyped at every hop. The flow itself is not declarative; it is implied by dispatch calls scattered across handlers. It is a common enough pain that people keep asking for first-class chaining on top of the transport, and keep hand-rolling their own.
In Ecotone this looks much different, because Ecotone is a messaging layer. The message is not bound to a destination, so pushing a message from one place to another is a matter of simple binding. Between the sending bus and the receiving handler there is a message channel.

The message is just a data record flying over the channel. The handler is bound to the channel, not to the message, so you can take any message and pass it through. This model also allows handlers to be connected into flows, because the wiring abstraction is the channel: one handler's output channel is simply the next handler's input channel. Here is the exact same loan flow — verify, score, decide — built the messaging way:
// Ecotone: the same loan flow, three handlers connected through channels.
// Each handler's output channel is the next handler's input channel,
// and a handler's return value travels onward as the next message payload.
final class LoanApplicationProcess
{
#[CommandHandler(routingKey: 'loan.apply', outputChannelName: 'loan.score')]
public function verify(LoanApplication $application): LoanApplication
{
$verification = $this->verifier->verify($application);
return $application->addVerification($verification);
}
#[InternalHandler(inputChannelName: 'loan.score', outputChannelName: 'loan.decide')]
public function score(LoanApplication $application): LoanApplication
{
return $application->withRiskScore($this->scoring->score($application));
}
#[InternalHandler(inputChannelName: 'loan.decide')]
public function decide(LoanApplication $application): void
{
// approve or reject
}
}
The same loan flow wired through channel names, readable in one place.
Look closely at verify and score: each takes the LoanApplication, adds what it just established — addVerification, withRiskScore — and returns it. The same message simply continues over the channel in its new state; a handler is free to modify the payload and pass it through, because the message is just data flying between channels. On Symfony Messenger each of those steps had to build a brand-new message class and dispatch it, only to carry the changed state forward — and reusing the class was never an option there, because the class is the routing.
No intermediate message classes, no redispatching — the flow reads top to bottom in one place, and the business policy (verify, then score, then decide) is visible as wiring.
Those channel names are not grep-and-pray strings, either. The binding is a compiled process: Ecotone builds and verifies the wiring when the application is compiled, so the feedback comes without executing any code — a misspelled channel fails the build with the exact name in hand, not in production at 3 a.m. And nothing stops the names from living as constants.
And here is where the channel abstraction actually pays off. Scoring calls the credit bureau, so we want it queued and retried — the step must survive a crash. In Ecotone that is one attribute on that one step, and the flow does not change:
// Going asynchronous is one attribute on one step — the wiring stays.
#[Asynchronous('scoring')]
#[InternalHandler(inputChannelName: 'loan.score', outputChannelName: 'loan.decide', endpointId: 'loan.scoring')]
public function score(LoanApplication $application): LoanApplication
{
return $application->withRiskScore($this->scoring->score($application));
}
One attribute makes the step asynchronous; the flow's wiring stays untouched.
On the transport layer this exact change is where the redispatch pattern becomes mandatory: a new message class, a transport routed in configuration, a handler dispatching it — the flow restructured just to change how one step executes - which Ecotone's messaging layer comes down to #[Asynchronous].
There is one more thing the messaging layer gives this flow. Not everything a step establishes belongs in the domain object — who verified, when, with which provider, what the raw score was. That is context about the message, not the message itself.
Symfony Messenger has a concept for exactly this: stamps — metadata attached to the envelope a message travels in. Attaching one means writing a stamp class and adding it at dispatch:
// Symfony Messenger: metadata travels as a stamp on the envelope.
use Symfony\Component\Messenger\Stamp\StampInterface;
final readonly class VerificationStamp implements StampInterface
{
public function __construct(
public string $provider,
public DateTimeImmutable $verifiedAt,
) {}
}
$this->bus->dispatch(
new ScoreLoanApplication(/* ...all the fields again... */),
[new VerificationStamp('kyc-x', $this->clock->now())],
);
A stamp class, attached at dispatch, riding the envelope.
Stamps do their job well for what they are built for: instructing the transport and the middleware — delay this, retry that. But look at what dispatch() actually does: it wraps the message in a brand-new envelope carrying only the stamps passed at that very call. Nothing from the previous hop travels along. Keeping one piece of context alive across three steps means remembering to re-attach it three times — forget once, and the context is silently gone. And the handler receives the message, not the envelope — so context a handler needs tends to end up as fields on the message class after all. That is the burden to see clearly: on a transport layer, carrying context through a flow is the developer's job, at every single hop.
In a messaging layer this context has a natural home, because a real Message flies over the channel — payload plus headers. A handler can enrich the flow's metadata and let the payload continue untouched. In fact, the verification from our flow does not even need to modify the domain object at all: the verify step can attach it as a header instead, and the LoanApplication continues onward exactly as it arrived:
// Ecotone: enrich the flow's metadata instead of the payload.
// The returned array is merged into the message headers; the
// LoanApplication continues onward unchanged.
#[ChangingHeaders]
#[CommandHandler(routingKey: 'loan.apply', outputChannelName: 'loan.score')]
public function verify(LoanApplication $application): array
{
return ['verification' => $this->verifier->verify($application)];
}
#[InternalHandler(inputChannelName: 'loan.decide')]
public function decide(
LoanApplication $application,
#[Header('verification')] Verification $verification,
): void
{
// decide with the verification at hand
}
The enrichment travels with the message, every downstream handler can read it — and neither a message class nor the domain object had to grow a field for it.
So why do the two keep getting confused between Messaging and Transport layer, between Ecotone and Symfony Messenger? Because for simple cases they look the same. A flow with a single message handler shows no difference at all: one message goes in, one handler receives it, done. The difference becomes more and more obvious as flows grow beyond a single handler, and the more advanced flow - the greater the difference.
Dictate routing in business logic
Say a message should reach different handlers depending on its content. On Symfony Messenger, which handler runs is decided by the message class: handlers bind to the class, and that binding is fixed up front. A decision the business makes at runtime — "digital orders take a different path" — has no class to live in, so it lands on you: code has to decide, then create the right message to reroute into each flow, then dispatch again.
// Symfony Messenger take on dynamic routing: decide in code, then repack
// the same data 1:1 into the class of the chosen flow, dispatch again.
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final class HandlePlaceOrder
{
public function __construct(private MessageBusInterface $bus) {}
public function __invoke(PlaceOrder $order): void
{
if ($order->isDigital) {
$this->bus->dispatch(new DeliverDigitalOrder(
$order->orderId,
$order->productId,
$order->customerEmail,
));
} else {
$this->bus->dispatch(new ShipPhysicalOrder(
$order->orderId,
$order->productId,
$order->customerEmail,
));
}
}
}
Deciding, repacking the same data 1:1, dispatching again — per branch.
Notice what the two branches actually do: nothing. The data does not change at all — every property is repacked 1:1, and DeliverDigitalOrder and ShipPhysicalOrder exist only so the same payload can reach a different handler. More often than not, this kind of routing simply does not get built — or it grows into complex, hard-to-test end to end code.
With messaging this becomes easy, because it comes down to rerouting the message to a different channel. In Ecotone a router is a method that returns the name of the channel the same message should continue on — the PlaceOrder travels on untouched:// Ecotone: a Router returns the channel the message should be routed to.
final class OrderRouter
{
#[Router(inputChannelName: 'order.place')]
public function route(PlaceOrder $order): string
{
return $order->isDigital
? 'order.deliverDigital'
: 'order.shipPhysical';
}
}
Routing as business logic: return the channel, the layer delivers.
A router can also return several channel names, fanning the same message out to multiple flows. The handler on the other side never learns who decided, and the router never learns what happens next. The channel does the wiring.
Async is about execution, not the message
So far the flows were chains. Events introduce the other shape: publish–subscribe. When OrderWasPlaced happens, several flows react — notify the customer, update the orders-list read model. Each subscriber is its own feature, its own flow. And this is usually the moment we want to go asynchronous.
Here the messaging layer draws a line worth seeing clearly: a message is a data record. It carries what happened — it does not state how it wants to be executed. Asynchronicity is a property of the execution, not of the data.
On Symfony Messenger, asynchronous is declared on the message: the class is routed to a transport.
# messenger.yaml: the MESSAGE is what goes async — the class routes
# to a transport, and every handler subscribed to it rides that delivery.
framework:
messenger:
routing:
App\Event\OrderWasPlaced: async
Async declared where the class is routed: on the message.
// Both subscribers share the one delivery of OrderWasPlaced:
// same transport, same retry policy, same dead letter, same consumer.
#[AsMessageHandler]
final class NotifyCustomer
{
public function __invoke(OrderWasPlaced $event): void { /* send email */ }
}
#[AsMessageHandler]
final class UpdateOrdersList
{
public function __invoke(OrderWasPlaced $event): void { /* update read model */ }
}
Two features, one delivery — they ride together.
In Ecotone the event stays a data record, and each handler declares its own execution — that is what #[Asynchronous] marks:
// Ecotone: async is marked on the EXECUTION. Each handler picks its own
// channel and receives its own copy of the event — or stays synchronous.
final class OrderSubscribers
{
#[Asynchronous('notifications')]
#[EventHandler(endpointId: 'order.notify')]
public function notifyCustomer(OrderWasPlaced $event): void { /* send email */ }
#[Asynchronous('projections')]
#[EventHandler(endpointId: 'order.project')]
public function updateOrdersList(OrderWasPlaced $event): void { /* update read model */ }
#[EventHandler]
public function audit(OrderWasPlaced $event): void { /* same event, synchronous */ }
}
Each handler declares its own execution; the event message never says how it wants to be run.

Each asynchronous handler receives its own copy of the event, delivered over its own channel. That makes isolation the default, not a project: when sending the email fails and retries, the orders-list projection is not touched — it already consumed its own copy. Each flow retries alone, delays alone, scales alone, dead-letters alone — and can even stay synchronous while its sibling goes async, because the message never said how it wanted to be executed.
Testing the flow, piece by piece
The messaging model pays one more dividend that rarely makes it into comparisons: testing. When flows are built from handlers bound to channels, you can pick the part of the flow you want to test, isolate it out, and run against just that.
First, the transport-layer version. Testing our loan flow with the scoring step asynchronous means testing across a transport, and the usual glue looks like this — an in-memory transport, assertions against what was sent, and a hand-built worker to pump the queued message through the second step:
// Symfony Messenger: testing a flow whose second step is async —
// in-memory transport ('in-memory://' in the test env), then a
// hand-built worker to pump the message through.
$bus = self::getContainer()->get(MessageBusInterface::class);
$bus->dispatch(new LoanApplication('123', 25000));
/** @var InMemoryTransport $transport */
$transport = self::getContainer()->get('messenger.transport.async');
self::assertCount(1, $transport->getSent());
// run the async step: a real Worker, told to stop after one message
$eventDispatcher = new EventDispatcher();
$eventDispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(1));
(new Worker(['async' => $transport], $bus, $eventDispatcher))->run();
// ...now assert on what ScoreLoanApplicationHandler did
Infrastructure standing between the test and the question it asks.
In Ecotone the test speaks the flow's own language. You choose the classes under test, back the channel with an in-memory queue, and release the asynchronous step by hand — synchronously, inside the test:
// Ecotone: pick the flow, run it, release the async step in the test.
$ecotone = EcotoneLite::bootstrapFlowTesting(
[LoanApplicationProcess::class],
[LoanApplicationProcess::class => new LoanApplicationProcess($verifier, $scoring)],
enableAsynchronousProcessing: [
SimpleMessageChannelBuilder::createQueueChannel('scoring'),
],
);
$ecotone->sendCommandWithRoutingKey('loan.apply', new LoanApplication('123', 25000));
$ecotone->run('scoring'); // executes score — and decide follows over the wiring
// ...assert on the outcome
Pick the flow, run it, release the async step by hand.
This is also how every Ecotone behavior in this article was checked while writing it — including the isolation proof a few paragraphs back: the same bootstrap, a failing subscriber, two run() calls. The flow you ship is the flow you test, one piece at a time.
The outbox is channel composition
Once flows go asynchronous, another question appears, and it decides whether your system can be trusted: the database change and the message about it must agree. Publish before the transaction commits, and a rollback leaves your system announcing something that never happened. Commit first and publish after, and a broker outage right between the two loses the message: the order exists, but the rest of the system will never hear about it.
The known answer is the outbox pattern: store the outgoing message in the database, inside the same transaction as the business change. On Symfony Messenger the closest built-in move is routing the message to the Doctrine transport, so the insert rides the business transaction:
# messenger.yaml: the Doctrine transport becomes the outbox — the insert
# joins the business transaction, and the database becomes the queue.
framework:
messenger:
transports:
outbox: 'doctrine://default'
routing:
App\Message\OrderWasPlaced: outbox
The outbox in the transport model: the outgoing message routed to the Doctrine transport.
That works — but now the queue is the database: workers poll the table, and every consumer you add polls it harder. Manageable at moderate scale, yet the load lands on the very database you were protecting, and it grows with the traffic you meant to offload.
An outbox is not only about storing messages in a database, though. What you actually want is to store in the database yet handle from the broker, so consumers scale against RabbitMQ, SQS or Kafka, not against your writes. Because in Ecotone channels are named building blocks, that combination is composition — a Combined Channel:
#[ServiceContext]
public function orderChannel(): CombinedMessageChannel
{
return CombinedMessageChannel::create(
'orders',
['database_channel', 'rabbit_channel'], // outbox first, broker second
);
}
One named channel composed from two: database for atomicity, broker for consumption.
A handler goes asynchronous over the combined channel like over any other:
#[Asynchronous('orders')]
#[EventHandler(endpointId: 'notifyAboutNewOrder')]
public function notifyAboutNewOrder(OrderWasPlaced $event): void
{
// stored with the business transaction, handled on the broker
}
Same attribute as any async handler; the outbox is the channel's concern.

The message is written to the database channel in the same transaction as your business state, then forwarded to the broker channel, and handled there. The stored message and the committed change go together, and scaling workers is a broker concern again, not database load.
Messaging is not only about async
It would be easy to walk away thinking the messaging layer is a story about asynchronous processing. The layer is about communication, whether that communication happens asynchronously or synchronously. The flow is a chain of channels either way; async is just a property of a channel. And that means everything the layer gives — retries, dead letter, interception — is available even when the whole flow runs synchronously.
Take a payment webhook. The provider calls your HTTP endpoint and you handle it right there, in the same request, no queue in sight. But a webhook is precious: if handling throws and you answer with an error, you are betting on the provider's redelivery policy. What you want is the resilience of a queue, without the queue.
In Ecotone the CommandBus itself is an entrypoint into the messaging layer, so you can extend it and shape the flow it opens. Retries and the dead letter are core open-source Ecotone on asynchronous flows; what follows brings them to a fully synchronous one as a single interface, and this extended bus is part of Ecotone Enterprise — judge for yourself whether it earns that. A bus built for receiving payment webhooks:
#[InstantRetry(retryTimes: 2)]
#[ErrorChannel('dbal_dead_letter')]
interface PaymentWebhookBus extends CommandBus
{
}
Two attributes shape the whole flow behind this bus.
// The webhook controller — a fully synchronous flow
public function paymentWebhook(Request $request, PaymentWebhookBus $bus): Response
{
$bus->sendWithRouting('payment.webhook.received', $request->toArray());
return new Response();
}
The controller stays a controller; the resilience lives in the flow it opens.
There is no command class here, and that is not an omission. A routing key and an array are enough: what flies over the channel is simply the payload of the Message, so the handler can type-hint an array just as well as a class.
Handling still happens inside the request, synchronously. But the flow is now shaped the way we wanted: a transient failure — a deadlock, a hiccup on an external call — is retried instantly before anyone hears about it. An unrecoverable failure does not bubble up as a 500: the message lands in the dead letter, stored for review and replay, this can happen, because we store it as a Message - and Message can be stored in Dead Letter Storage no matter if it's being executed synchronously or asynchronously.
Adopt it one flow at a time
One practical note before the last stop: adopting a messaging layer is not a rewrite. Ecotone runs alongside Symfony Messenger in the same application — nothing about your existing handlers has to move. Pick one flow, the one whose redispatching hurts most, build it on channels, and let the rest follow only when it earns it. Ecotone can even use your existing Symfony Messenger transports as its message channels, so the infrastructure you already run keeps serving.
Ecotone is an architecture layer
Everything so far was the messaging layer: channels, bindings, flows wired once. That layer is why any kind of flow your system may need can be formed. But Ecotone builds one level higher still: on top of the messaging layer it provides a whole architecture layer, not a messaging platform only.
The architecture layer is a set of building blocks that wire directly into your flows. Command Handlers and Event Handlers are the standard ones. Beyond them: Aggregates, which receive Commands directly and send Events out. Sagas, long-running processes that subscribe to Events and drive a flow over time. Projections, which read from Event Sourcing streams to build read models. And Orchestrators, which decide a flow dynamically from a set of conditions.
An Aggregate shows the model best: the Command lands on the Aggregate directly, no service plumbing in between, and the Events it records flow out over channels of their own.
#[Aggregate]
final class Order
{
use WithEvents;
#[Identifier]
private string $orderId;
private function __construct(string $orderId)
{
$this->orderId = $orderId;
$this->recordThat(new OrderWasPlaced($orderId));
}
#[CommandHandler]
public static function place(PlaceOrder $command): self
{
return new self($command->orderId);
}
}
Command straight to the Aggregate; events out over their own channels.

An Orchestrator — part of Ecotone Enterprise — makes the flow itself a runtime decision. It returns the list of channels the message should travel through, and the messaging layer executes them step by step:
final class OrderProcessing
{
#[Orchestrator(inputChannelName: 'order.process')]
public function decide(PlaceOrder $order): array
{
return $order->isDigital
? ['order.verify', 'order.deliverDigital']
: ['order.verify', 'order.reserveStock', 'order.ship'];
}
}
Return the steps, the layer walks them.
You do not think about building and wiring these blocks anymore; the messaging layer underneath covers that for you. And because they are built on that layer, every block inherits its abilities: retries on failure, a dead letter when an unrecoverable failure happens, going asynchronous by adding an attribute:
#[Aggregate]
final class Order
{
// ...
#[Asynchronous('orders')]
#[CommandHandler(endpointId: 'order.cancel')]
public function cancel(CancelOrder $command): void
{
// retried on failure, dead-lettered when unrecoverable
}
}
And there are of different patterns which we can combine together, as if messaging layer provides given feature, you can use it with any building block you want:
#[Aggregate]
final class Order
{
// ...
#[Delayed(new TimeSpan(hours: 24))]
#[Asynchronous('orders')]
#[EventHandler(endpointId: 'order.expire')]
public function expire(OrderWasPlaced $event): void
{
// cancel out the Order if not completed within 1 day. This will be executed automatically due to Delayed attribute after 24 hours.
}
}That is what the architecture layer means: the blocks are yours to pick, and the wiring, with everything the wiring grants, is already there.
Wrapping up
At first glance Ecotone can look similar to a transport layer. It is the kind of difference that reveals itself in layers: the longer you work with it, the more you find already wired — until you realize the architecture layer has been carrying work you used to own.
That is the honest answer to "so it's like Symfony Messenger?". Things that looked like a challenge, or were simply too complex to justify building — dynamic routing, the outbox, isolated subscriber flows — become simple and straightforward once handlers bind to channels instead of messages. They were always possible on a transport layer; they just cost application logic at every step. You can build flows on any abstraction. The right one is the one where the flow's hard parts stop being your code.
And if Ecotone's messaging layer makes you curious, you do not have to commit to anything: you can run Ecotone over your existing Symfony Messenger transports and adopt it one flow at a time.
About the author: Dariusz Gafka is a Software Architect and author of the Ecotone Framework. He writes about event sourcing, CQRS, and PHP architecture patterns.