# Ecotone: CQRS, event sourcing and sagas as PHP attributes

> Ecotone is a PHP architecture layer that puts command, query and event handling onto your own classes through attributes, so you can adopt CQRS, DDD and event sourcing without rewriting the application around a bus. It sits on top of Symfony Messenger or Laravel Queue rather than replacing them, and its Enterprise features sit behind a second licence.

**ecotoneframework/ecotone** — Ecotone shifts the focus to the business code. In order to make it happen, enables Message-Driven Architecture with DDD, CQRS, Event Sourcing in PHP.

- Repository: https://github.com/ecotoneframework/ecotone
- Website: https://ecotone.tech
- Stars: 576 · Forks: 24
- Language: PHP
- License: NOASSERTION
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/ecotoneframework-ecotone

## An architecture layer, not a replacement for your queue

The README makes the positioning unusually clear. Ecotone is not a replacement for Symfony Messenger or Laravel Queue, it is the architecture layer on top, and your existing HTTP layer, transports and jobs keep working.

That sentence resolves the question most PHP teams have about a library like this. A CQRS or event sourcing framework usually arrives with an opinion about how messages travel, and the cost of adopting it is that you move your queue configuration, your job classes and possibly your HTTP layer into the new system. Ecotone's approach is the opposite. It reads the attributes on the classes you already have and takes responsibility for the dispatch, retry and routing around them, while the transport stays where it was.

The framing of the project is the same in the repository description: Ecotone shifts the focus to the business code, and in order to make it happen, enables Message-Driven Architecture with DDD, CQRS and Event Sourcing in PHP. The homepage is ecotone.tech and the documentation sits at docs.ecotone.tech.

The lineage is stated too. The library is built on Enterprise Integration Patterns, described as the same pattern language behind Spring Integration, Axon, NServiceBus and Apache Camel, brought to PHP as attribute-driven code. That matters for what you can find when something goes wrong, because the pattern vocabulary in the docs will be the one those systems use.

## Attributes replace the wiring

The example the README opens with is two classes and nothing else in the way. The first is a service that handles a command and publishes an event:

```php
class OrderService
{
    #[CommandHandler]
    public function placeOrder(PlaceOrder $command, EventBus $eventBus): void
    {
        // your business logic
        $eventBus->publish(new OrderWasPlaced($command->orderId));
    }

    #[QueryHandler('order.getStatus')]
    public function getStatus(string $orderId): string
    {
        return $this->orders[$orderId]->status;
    }
}
```

The second is a handler in another part of the application that reacts to the event asynchronously:

```php
class NotificationService
{
    #[Asynchronous('notifications')]
    #[EventHandler]
    public function whenOrderPlaced(OrderWasPlaced $event, NotificationSender $sender): void
    {
        $sender->sendOrderConfirmation($event->orderId);
    }
}
```

The README summarises the claim as that's the entire setup. No bus configuration, no handler registration, no retry config, no serialization wiring. Ecotone reads your attributes and handles the rest, and the list of what it then takes care of includes the command, query and event bus wired automatically from the attributes, event routing where handlers subscribe without manual wiring, async execution, failure isolation, retries and dead letter, and OpenTelemetry tracing across sync and async flows.

Two details in the code are worth pausing on. The query handler carries a routing key, order.getStatus, which is how a query is addressed to a specific method rather than to a class. And the async handler declares the channel name in the attribute, notifications, which is the same name the test in a later section uses to run that channel.

## One handler's failure does not become another's

The bullet on failure isolation is the one that separates this from a hand-rolled event dispatcher. Each event handler gets its own copy of the message, so one handler's failure never blocks another.

That is a real design decision rather than a convenience. In a naive implementation the event bus hands one message to a list of subscribers, and a subscriber that throws stops the ones after it, which means a broken email template can block a stock update. With a copy per handler, each subscriber fails alone.

The retry story is described alongside it. Failed messages retry automatically, and permanently failed ones go to a dead letter queue you can inspect and replay. The replay part is what makes a dead letter queue useful rather than a bin, because a message you can send again is a message you can fix without re-triggering the whole flow.

The resiliency row of the feature table widens the same area: retries, error channels, a dead letter queue with replay, the Outbox pattern, and deduplication. The Outbox pattern in particular is the standard answer to the dual-write problem, where a message is published and a database row is written, and a crash between the two leaves the two out of step. The deduplication entry covers the other side of that problem, which is that a retried message has to be safe to process twice.

## Async means naming a transport, not configuring a queue

The attribute on the notification handler is #[Asynchronous('notifications')], and the README says it routes to RabbitMQ, SQS, Kafka, Redis, or DBAL. Those five names are the transport options.

That is the shape of the design in one line. The business code says which logical channel a message belongs to, and the infrastructure decides what that channel is made of. The asynchronous communication row lists the capabilities around it: delayed messages, scheduling, priority, TTL, and dynamic channels.

The delayed and scheduled message support is the feature that tends to matter most in practice. A domain that needs to send a reminder in three days, or retry a payment tomorrow, ends up writing a scheduler job by hand in most PHP applications, and here it is a property of the channel rather than a bespoke service.

The multi-tenancy row is worth reading as well, since it says per-tenant connections, event stores, and async channels. That is a substantial feature for a library to offer, and it is the kind of thing that is either exactly what you need or irrelevant, depending on whether your application serves one customer or many.

Data protection appears in the same table as field-level encryption and PII masking for messages, which is the acknowledgement that once a message goes to a broker, whatever it contains is now in a system with its own access rules.

## Event sourcing and durable workflows share the same foundation

The feature table separates event sourcing from durable workflows, but the machinery underneath them is the same.

Event Sourcing is described as an event store, projections with catch-up, partitioned and streaming modes, event versioning and upcasting, and snapshots. Upcasting is the part that matters for anyone planning to keep an event store for years, because it is what lets you change the shape of a past event without rewriting the history that holds it.

Durable Workflows covers stateless workflows, sagas, orchestrators using a routing slip, and event-sourced sagas with full replay. The README describes these as long-running processes that survive crashes, deploys and restarts on the DB and broker you already run, which is the defining property of a durable workflow engine. The routing slip is the specific mechanism, a persisted list of steps that the engine works through and can resume from, and the domain modelling row covers aggregates and sagas, state-stored or event-sourced, all via attributes.

The orchestration row adds the Enterprise Integration Patterns side: EIP routing, splitters, filters, transformers, and multi-step orchestrators, where the multi-step orchestrators are marked Enterprise.

That marker appears twice in the table, on the multi-step orchestrators and on the Service Map in the observability row, and it is the boundary of what the open source package covers.

## Testing a flow means loading only the classes you need

The testing story is the part that decides whether a library like this is usable, and it starts with a simple claim: extract a specific flow and test it in isolation, only the services you need.

```php
$ecotone = EcotoneLite::bootstrapFlowTesting([OrderService::class]);

$ecotone->sendCommand(new PlaceOrder('order-1'));

$this->assertEquals('placed', $ecotone->sendQueryWithRouting('order.getStatus', 'order-1'));
```

Only OrderService is loaded, no notifications and no other handlers, so the test exercises the flow being verified rather than the whole application. There is no broker involved at all.

The second example adds the asynchronous side with an in-memory channel:

```php
$notifier = new InMemoryNotificationSender();

$ecotone = EcotoneLite::bootstrapFlowTesting(
    [OrderService::class, NotificationService::class],
    [NotificationSender::class => $notifier],
    extensionObjects: [SimpleMessageChannelBuilder::createQueueChannel('notifications')],
);

$ecotone
    ->sendCommand(new PlaceOrder('order-1'))
    ->run('notifications');

$this->assertEquals(['order-1'], $notifier->getSentOrderConfirmations());
```

The mechanism is worth understanding. A fake implementation of the notification sender is bound in the second argument, the channel is created by a builder that produces an in-memory queue, and the run call pumps that channel inside the test process.

The README's claim about this is that you can swap the in-memory channel for DBAL, RabbitMQ or Kafka in production and the test stays the same, because Ecotone runs the consumer in-process so switching transports never changes how you test. That is the strongest argument the project makes, and it is also the part to check against your own setup, since it assumes your handlers have no other in-process state.

## A read-only repository and a licence with two halves

The first line of the README says this is a read only repository, and that to contribute you should use the Ecotone-Dev repository at github.com/ecotoneframework/ecotone-dev. That is worth knowing before you open a pull request, because a patch aimed at the wrong repository will go nowhere.

The licence is the other thing to read carefully. The repository listing records it as a custom licence that GitHub cannot classify, and the root holds two files, LICENSE and LICENSE-ENTERPRISE. The feature table marks the multi-step orchestrators and the Service Map as Enterprise, which matches the second file's name, and the docs site is where the boundary between the two sets is described. Without reading both files you cannot tell which of the documented modules you are entitled to use, and the packagist badges point at a licence page that presumably repeats the same split.

The repository layout shows a package with real quality tooling around it. There is a src directory, an Api directory, a tests directory, a composer.json, a phpstan.neon with a phpstan-baseline.neon, a phpunit.xml.dist, and a rector.php for automated refactoring. The workflow badge points at a split-testing workflow, which is the pattern used to run a test suite against multiple PHP versions or dependency combinations at once.

The installation route is by framework. Symfony uses composer require ecotone/symfony-bundle, which is described as Symfony Messenger compatible, with bundle auto-configuration, Doctrine ORM integration and pure POPOs. Laravel uses composer require ecotone/laravel, described as working with Eloquent. The topics on the repository list cqrs, ddd, ecotone, event-sourcing, laravel, php and symfony, the default branch is main, there are no GitHub releases, and the last push was on 2026-10-19.

## Conclusion

Ecotone fits a PHP team that wants command and event handling without hand-written bus wiring, and that is willing to keep the existing framework's HTTP and queue layers in place while it changes the shape of the domain code. It is the wrong choice if you want a small library with a single dependency, since the durable workflow, event sourcing and multi-tenancy features pull in a wide surface, and the wrong choice if the Enterprise features are what you need, because those are covered by a separate licence file. Verify first which of the documented modules your plan actually requires, that the read-only repository's counterpart ecotone-dev is where your pull request should go, and that your PHP version satisfies the requirement declared on Packagist.

## FAQ

### Is Ecotone a replacement for Symfony Messenger or Laravel Queue?

No. The README states that Ecotone is the architecture layer on top, and your existing HTTP layer, transports and jobs keep working. It provides the command, query and event handling and the routing around them rather than the transport itself.

### How do I install Ecotone for Symfony or Laravel?

For Symfony, composer require ecotone/symfony-bundle, which is Messenger compatible and includes bundle auto-configuration and Doctrine ORM integration. For Laravel, composer require ecotone/laravel, which works with Eloquent. Both packages are published on Packagist.

### How do I test an Ecotone flow without a broker?

Use EcotoneLite::bootstrapFlowTesting with the list of classes the flow needs, then send a command and a query against it. For asynchronous handlers you can add an in-memory channel through SimpleMessageChannelBuilder::createQueueChannel and run that channel inside the test process with the run call.

### What happens when an Ecotone event handler fails?

Each event handler gets its own copy of the message, so one handler's failure never blocks another. Failed messages retry automatically, and permanently failed ones go to a dead letter queue that you can inspect and replay.

### Which transports can an Ecotone asynchronous channel use?

The README lists RabbitMQ, SQS, Kafka, Redis and DBAL as the routing targets for asynchronous messages, with delayed messages, scheduling, priority, TTL and dynamic channels supported on top.

### Where should I send a pull request for Ecotone?

The README states that the repository is read only and that contributions should be made in the Ecotone-Dev repository at github.com/ecotoneframework/ecotone-dev.

## Sources

- [ecotoneframework/ecotone on GitHub](https://github.com/ecotoneframework/ecotone)
- [Issues](https://github.com/ecotoneframework/ecotone/issues)
- [Project website](https://ecotone.tech)
- [README](https://github.com/ecotoneframework/ecotone/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/ecotoneframework-ecotone
