Only this pageAll pages
Powered by GitBook
Couldn't generate the PDF for 219 pages, generation stopped at 100.
Extend with 50 more pages.
1 of 100

Ecotone

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Modelling

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Asynchronous Communication

Asynchronous communication in PHP —

Ecotone is an architecture layer for asynchronous messaging in PHP. It adds the primitives a dispatcher alone doesn't ship — transactional outbox, per-handler failure isolation, deduplication, multi-tenant routing, and consistent #[Asynchronous] / #[Delayed] / #[Priority] / #[TimeToLive] semantics across every broker. Existing Symfony Messenger transports and Laravel Queue channels stay in place as the underlying transport when they're already configured; the architectural primitives sit on top.

For a PHP team building asynchronous messaging — background jobs, event-driven side effects, scheduled work, retries with exponential backoff, multi-broker estates, multi-tenant routing — Ecotone moves any handler to async with one attribute (#[Asynchronous('channel')]), gives you the outbox in one configuration line (CombinedMessageChannel), and exposes #[Delayed], #[Priority], #[TimeToLive], and scheduled messages through one consistent attribute model (each broker's own primitive — RabbitMQ delayed exchange, SQS message timer, Redis sorted-set + polling — is what does the actual waiting underneath). RabbitMQ, Apache Kafka, Amazon SQS, Redis, DBAL, Symfony Messenger transports, Laravel Queue channels — handler code is broker-agnostic.

The competition is Symfony Messenger and Laravel Queues / Horizon — mature dispatchers that don't ship the architectural primitives (outbox, per-handler isolation, deduplication, multi-tenant routing) that production async messaging adds on top. This page walks the differences.

The Problem You Recognize

You added async processing to handle background work — emails, payment processing, data syncing. New problems appeared:

  • Failed jobs disappear silently or retry forever. No visibility into why.

  • A duplicate webhook hits the same handler twice — double charges or duplicate emails.

  • Going async required touching every handler — queue config, serialization, retry logic per handler.

What the Industry Calls It

Asynchronous communication (also: async messaging, message-driven architecture) — applications communicate by passing messages through a broker rather than synchronous calls. Patterns layered on top include:

  • Outbox — atomic publication of a message together with the database write that produced it.

  • Per-handler failure isolation — a copy of every message dispatched to each handler, so one failing subscriber doesn't trigger sibling re-runs.

  • Idempotency / deduplication — handlers tolerate duplicate delivery without producing duplicate side effects.

The PHP options are Symfony Messenger (the de-facto Symfony dispatcher), Laravel Queues with Horizon (the Laravel-native job runner with the Horizon operator UI), and Ecotone — which adds an architectural layer above them.

How Ecotone Solves It

Async with one attribute, on any broker

The handler is broker-agnostic. Switching from Redis to RabbitMQ is a #[ServiceContext] configuration change — no handler code touched.

Outbox in one DBAL transaction, execution on the broker

CombinedMessageChannel writes the message to the database in the same DBAL transaction as the business write, then dispatches actual handler execution onto the broker. One outbox poller drains the database (low traffic, single workload). Many consumers handle the broker side (high traffic, horizontally scalable). The dual-write problem is solved at the primitive level.

Per-handler failure isolation by default

Each subscriber gets its own copy of the message. If reserveStock fails, only reserveStock retries — sendConfirmation is unaffected. This is the architectural difference from Symfony Messenger's single-envelope dispatch model: in Messenger, the first throwing consumer aborts dispatch for siblings and retry re-runs every consumer.

Delayed, priority, TTL, scheduled — uniform across brokers

#[Delayed] accepts a TimeSpan, an exact DateTimeImmutable, or an expression (#[Delayed(expression: 'payload.dueDate')]). The delay is per-handler on the async channel — the same event can fire one handler immediately and another in 24 hours. Priority, TTL, and scheduled messages have the same shape across every broker; the broker's native primitives (RabbitMQ delayed exchange, SQS message timer, Redis sorted-set + polling worker) are abstracted behind one attribute model.

Dynamic Channels — multi-tenant routing in one deployment (Enterprise)

tenant_resolved is a dynamic channel whose actual destination resolves at runtime — based on a header, payload field, or expression. One handler, N tenants, isolated per-tenant queues. No fork in handler code; no namespace-per-tenant infrastructure.

Retry, error channels, dead letter — at the channel, not per handler

Configure recovery policy once at the channel level. Every handler on that channel inherits exponential-backoff retry, an error channel, and a DBAL-backed dead-letter queue with replay. No per-handler boilerplate.

How It Compares

Dimension
Symfony Messenger
Laravel Queues / Horizon
Raw broker library (php-amqplib, predis, AWS SDK)
Ecotone

Existing Messenger and Horizon investments stay in place — Ecotone uses Messenger transports and Laravel Queue channels underneath when they're already configured — and adds the primitives (outbox, per-handler isolation, deduplication, multi-tenant routing) that the dispatchers don't ship.

Next Steps

  • — #[Asynchronous], pollers, async event handlers

  • — #[Delayed] with TimeSpan, DateTime, and expressions

  • — CombinedMessageChannel

The retry of a failed event triggers every event handler again. Two handlers already succeeded; now they run again, sending duplicate emails.
  • The "write to the database, then publish a message" pattern is a dual-write — sometimes the DB commits and the publish fails, sometimes vice versa, and downstream services miss state.

  • You want to route some traffic to RabbitMQ and some to SQS based on tenant. The configuration becomes a fork in code, not a routing decision.

  • Switching from Redis to RabbitMQ for one channel means rewriting handler code, not changing one config line.

  • Delayed and scheduled messages — fire-after-N-hours, fire-at-time-X, recurring schedules.
  • Multi-tenant routing — per-tenant channels in one deployment, header-resolved at runtime.

  • Outbox (atomic write + message)

    Not built in; needs a separate package

    Not built in; needs a separate package (e.g., spatie/laravel-outbox)

    You implement it

    CombinedMessageChannel — one configuration line

    Multi-broker transparency

    Pluggable transports, but Messenger-shaped

    Pluggable queue connections, Laravel-shaped

    One broker per library

    One handler attribute, any broker (RabbitMQ, Kafka, SQS, Redis, DBAL, Messenger transports, Laravel Queue channels)

    Delayed messages

    DelayStamp, transport-dependent

    delay() on the queue, transport-dependent

    Transport-specific

    #[Delayed] — uniform attribute, per-handler

    Priority

    Transport-dependent

    Per-queue or per-job

    Transport-specific

    #[Priority] — uniform attribute

    Scheduled / cron messages

    Symfony Scheduler (separate component)

    Laravel Scheduler (artisan schedule)

    Hand-rolled cron

    #[Scheduled] and the Scheduler integration

    Idempotency / deduplication

    Not built in

    Not built in

    You implement it

    #[Deduplicated] — handler-level and gateway-level

    Multi-tenant routing

    Custom middleware

    Custom middleware / per-tenant queues

    Custom

    Dynamic Channels — header-routed at runtime

    Operator UI

    Failure transport browsing via CLI / community packages

    Horizon (excellent)

    None

    OpenTelemetry spans, DBAL DLQ rows queryable from any SQL tool, MCP for AI-assisted introspection

    Integration

    Symfony-native

    Laravel-native

    Anywhere

    Symfony Bundle, Laravel Provider, or Ecotone Lite for any PSR-11 container — runs on top of Messenger transports and Laravel Queue channels too

    Per-Handler Failure Isolation — the copy-per-subscriber model

  • Retries — exponential backoff at the channel

  • Error Channel and Dead Letter — failure quarantine and replay

  • Idempotency (Deduplication) — #[Deduplicated]

  • Unreliable Async Processing — the resiliency-focused framing of the same primitives

  • Microservice Communication — service-to-service async via Distributed Bus

  • Per-handler failure isolation

    No — single envelope across all handlers; first throwing consumer affects siblings on retry

    Per-job isolation when listeners are ShouldQueue; in-process listeners share the dispatch lifecycle. No copy-per-subscriber semantics for events fanning out to mixed sync/async handlers

    You implement it

    Yes — copy per subscriber, by default, sync or async

    As You Scale: Ecotone Enterprise adds Dynamic Message Channels for tenant-routed traffic, Asynchronous Message Buses for end-to-end async dispatch, Kafka integration, and Command Bus Instant Retries for transient-failure recovery on synchronous commands.

    Asynchronous Handling
    Delaying Messages
    Outbox Pattern
    #[Asynchronous('notifications')]
    #[EventHandler]
    public function sendWelcomeEmail(UserRegistered $event): void
    {
        // Runs asynchronously on whatever broker 'notifications' is wired to —
        // RabbitMQ, Kafka, SQS, Redis, the database, a Messenger transport,
        // or a Laravel Queue channel. Handler code doesn't change.
    }
    #[ServiceContext]
    public function durableChannel(): CombinedMessageChannel
    {
        return CombinedMessageChannel::create(
            'outbox_sqs',
            ['database_channel', 'amazon_sqs_channel'],
        );
    }
    
    #[Asynchronous('outbox_sqs')]
    #[EventHandler]
    public function notifyAboutNewOrder(OrderWasPlaced $event): void
    {
        // Message committed in the same DBAL transaction as the OrderWasPlaced write.
        // No dual-write — if the transaction commits, the message is durable.
        // Execution dispatched to SQS where consumers scale horizontally.
    }
    #[Asynchronous('notifications')]
    #[EventHandler]
    public function sendConfirmation(OrderPlaced $event): void { /* ... */ }
    
    #[Asynchronous('inventory')]
    #[EventHandler]
    public function reserveStock(OrderPlaced $event): void { /* ... */ }
    #[Delayed(new TimeSpan(hours: 24))]
    #[Asynchronous('async')]
    #[EventHandler(endpointId: 'reminder')]
    public function sendCartReminder(CartAbandoned $event): void { /* ... */ }
    
    #[Asynchronous('orders.high_value')]
    #[Priority(10)]
    #[EventHandler]
    public function processHighValueOrder(OrderPlaced $event): void { /* ... */ }
    #[Asynchronous('tenant_resolved')]
    #[EventHandler]
    public function process(SomeEvent $event): void { /* ... */ }
    ErrorHandlerConfiguration::createWithDeadLetterChannel(
        'error_channel',
        RetryTemplateBuilder::exponentialBackoff(1000, 2)->maxRetryAttempts(3),
        'dbal_dead_letter',
    )

    Solutions

    Common challenges Ecotone solves for Laravel and Symfony developers

    Every feature in Ecotone exists to solve a real problem that PHP developers face as their applications grow. Find the situation that matches yours:

    If you recognize this...
    See

    Business logic is scattered across controllers, services, and listeners — nobody can explain end-to-end what happens when an order is placed

    Queue jobs fail silently, a retry re-fires handlers that already succeeded, or a duplicate webhook double-charges the customer

    A multi-step process lives across event listeners, cron jobs, and is_processed columns — adding or reordering a step means editing many files

    Support asks "what exactly happened to this order?" and the trail is in logs, timestamps, and hope

    Services talk over HTTP with custom retry logic per pair, and one service going down cascades into the others

    You're evaluating whether PHP can carry enterprise architecture, with the alternative being a rewrite in Java or .NET

    Works with: Laravel, Symfony, and Standalone PHP

    Scattered Application Logic
    Unreliable Async Processing
    Complex Business Processes
    Audit Trail & State Rebuild
    Microservice Communication
    PHP for Enterprise Architecture

    Extending Messaging (Middlewares)

    Extending messaging with Interceptors and Middlewares in Ecotone PHP

    Ecotone exposes hooks at every step of a message's lifecycle. Use Interceptors (Before/Around/After/Presend) to add cross-cutting behaviour like logging, authorization, retries, or transactions without touching your handlers. Use Message Headers to read and write metadata that flows alongside the payload. Use Custom Gateways to attach policies (retries, dedup, error channels) to typed bus interfaces.

    Message HeadersInterceptors (Middlewares)Intercepting Asynchronous EndpointsExtending Message Buses (Gateways)

    Aggregates & Sagas

    Quick start with Aggregates and Sagas in Ecotone PHP

    Demo

    Read Blog Post

    Building Blocks: Aggregates, Sagas, Event Sourcing

    Scheduling in PHP

    Quick start with scheduled tasks and periodic processing in PHP

    Demo

    Read Blog Post

    Read more about Scheduling in PHP and Ecotone

    Event Sourcing PHP

    Quick start with Event Sourcing in Ecotone PHP

    Store events instead of current state — get a full audit trail and rebuildable read models for free.

    Demo

    Read Blog Post

    Microservices PHP

    Microservices, Message-Driven, Event-Driven Architecture in PHP

    Cross-service messaging with guaranteed delivery — send commands and share events between PHP services.

    Demo

    Read Blog Post

    Resiliency and Error Handling

    Outbox pattern implementation in PHP

    Automatic retries, dead letter queues, and message replay — production resilience without per-handler boilerplate.

    Demo

    Read Blog Post

    Doctrine ORM

    Symfony demo with Doctrine ORM and Ecotone

    Demo

    Read Blog Post

    Read more about Doctrine ORM Ecotone

    Business Interface

    Business Interfaces for type-safe messaging in Ecotone PHP

    You inject CommandBus into a controller. Three controllers later, every call site has the magic-string 'ticket.create' and a new CreateTicket(...). The bus signature is mixed → mixed. There's no single place that documents what your domain can do, no IDE help, and refactoring the CreateTicket class is a search-and-replace across the codebase.

    A Business Interface is your domain's typed API: interface TicketApi { public function create(CreateTicket $cmd): TicketId; public function close(#[Identifier] string $id): void; }. Ecotone delivers the implementation. Your controllers, console commands, and subscribers all depend on TicketApi — and onboarding a new developer becomes a one-file question: "what can the Ticket module do?".

    This works for sending commands, executing queries, and operating on databases — keeping your application code focused on intent rather than wiring.

    How to use

    Domain Driven Design Command Query Responsibility Segregation PHP

    How to use

    If you're looking for a way to start and get familiar with Ecotone. Then Ecotone provides different ways to do so:

    • - Tutorial will introduce you to Ecotone's fundamentals and will help you build understanding of the Messaging concepts.

    Laravel Demos

    Laravel demo applications with DDD, CQRS, and Event Sourcing

    Demo Message Bus

    Demo Publishing Events

    Symfony Demos

    Symfony demo applications with DDD, CQRS, and Event Sourcing

    Message Bus Demo

    Publishing Events Demo

    Tutorial

    Ecotone PHP Framework

    Get started with Ecotone

    The best way to get started with Ecotone is to actually build something realistic. Therefore we will build a small back-end for Shopping System during this tutorial. The techniques we will learn in the tutorial are fundamental to building any application using Ecotone.

    Aggregate Query Handlers

    DDD PHP

    Read sections first to get more details about Aggregates.

    Aggregate Query Action

    Aggregate actions are defined using public method (non-static). Ecotone will ensure loading in order to execute the query method.

    And then we call it from Query Bus:

    Inbuilt Repositories

    Built-in Aggregate repositories for Doctrine ORM, Eloquent, and DBAL

    Ecotone comes with inbuilt repositories, so we don't need to configure Repositories ourselves. It often happen that those are similar between projects, therefore it may be that there is no need to roll out your own.

    Inbuilt Repositories

    Ecotone provides inbuilt repositories to get you started quicker. This way you can enable given repository and start implementing higher level code without worrying about infrastructure part.

    Saga Introduction

    Process Manager Saga PHP

    You're handling order processing: charge the card, reserve stock, schedule shipping, send a confirmation. The steps span hours or days, run across services, and any one of them can fail. State columns like is_payment_processed, retry_count, step_completed_at start appearing on the Order table — and the workflow ends up tangled across listeners, jobs, and cron sweepers.

    A Saga is a class that owns a long-running business process. It listens for events, decides what to do next, dispatches commands, and persists its own state between steps. Where an Aggregate protects the invariants of a single thing, a Saga coordinates the steps that move many things forward.

    Additional Scenarios

    Advanced Interceptor scenarios and configurations in Ecotone

    Access attribute from interceptor

    We may access attribute from the intercepted endpoint in order to perform specific action

    then we would have an Message Endpoint using this Attribute:

    and it can be used in the intereceptors by type hinting given parameter:

    Intercepting Asynchronous Endpoints

    Intercepting asynchronous message endpoints in Ecotone PHP

    You want a transaction wrapper, but only for handlers running asynchronously — synchronous Command handlers already get one from your Command Bus. Or you want to log async-only metrics, swap connections per-tenant, or apply rate-limiting to background work without touching synchronous flows. Pointing the interceptor at the AsynchronousRunningEndpoint class targets only the async path.

    Read for an introduction to Interceptors.

    Intercepting Asynchronous Endpoints

    Event Sourcing

    Event Sourcing PHP

    The Problem

    You store the current state of your entities, but not how they got there. When a customer disputes a charge, you can't answer "what exactly happened?" Rebuilding read models after a schema change means writing migration scripts by hand. Auditors ask for a complete trail of changes and you piece it together from application logs.

    Installation

    Installing Event Sourcing support in Ecotone PHP

    Ecotone comes with full automation for setting up Event Sourcing for us. This we can we really easily roll out new features with Event Sourcing with just minimal or none setup at all.

    Install Event Sourcing Support

    Before we will start, let's first install Event Sourcing module, which will provide us with all required components:

    We need to configure in order to make use of it.

    Ecotone PDO Event Sourcing does provide support for three databases:

    Event Sourcing Aggregates

    Event Sourcing Aggregates in Ecotone PHP

    The Problem

    You're a year into your Order system. Compliance asks: "show me everything that happened to order #4192, with timestamps, in order." You can't — the orders table only stores the current row. The audit log you bolted on is missing two columns and the listener that wrote to it crashed silently in 2023.

    Event Stream Persistence

    PHP Event Sourcing Persistence Strategy

    Once you decide to store events, you have to decide how — what database table holds them, how aggregate identity and version are constrained at the storage layer, what happens when you rename a class, and how PII fields are encrypted. This section covers those choices.

    The pages below are mostly independent — read the one that matches the problem in front of you:

    How Ecotone lays out events on disk: simple append-only streams, partitioned streams (one stream per aggregate with version uniqueness), or a single global stream. Pick the one that matches your concurrency and ordering needs.

    When you already store events somewhere else (EventSauce, Patchlevel, your own table) and want Ecotone to read/write through that storage instead of its built-in event store.

    You renamed App\Order\Order to App\Sales\Order — now production events still reference the old class.

    Making Stream immune to changes

    Making event streams immune to class and namespace changes

    Changes in the Application will happen. After some time we may want to refactor namespaces, change the name of Aggregate or an Event. However those kind of changes may break our system, if we already have production data which references to any of those. Therefore to make our Application to immune to future changes we need a way to decouple the code from the data in the storage, and this is what Ecotone provides.

    Custom Stream Name

    Our Event Stream name by default is based on the Aggregate Class name. Therefore to make it immune to changes we may provide custom Stream Name. To do it we will apply Stream attribute to the aggregate:

    Doctrine ORM Support

    This provides integration with Doctrine ORM. To enable it read more in Symfony Module Section.

    Laravel Eloquent Support

    This provides integration with Eloquent ORM. Eloquent support is available out of the box after installing Laravel module.

    Document Store Repository

    This provides integration Document Store using relational databases. It will serialize your aggregate to json and deserialize on load using Converters. To enable it read in Dbal Module Section.

    Event Sourcing Repository

    Ecotone provides inbuilt Event Sourcing Repository, which will set up Event Store and Event Streams. To enable it read Event Sourcing Section.

    Introduction
    Database Business Interface

    Demo Laravel and Symfony Application - You can test Ecotone in real-life example, by using our demo application. The demo application shows how to use Ecotone with Laravel and Symfony frameworks.

  • Quickstart Examples - Provides great way to check specific Ecotone features. Whether you use Laravel or Symfony or Lite (no external framework), all examples will be able to work in your Application.

  • Ask question to AI - Ecotone provides AI support, to help you find the answers quicker. You may ask any Ecotone related questions, and it will provide more details on the topic and links where more information can be found.

  • Have a Workshop or Consultancy - To quickly get whole Team or Organisation up and running with Ecotone, we provide workshops. Workshops will not only teach you how to use Ecotone, but also the concepts and reasoning behind it.

  • Join Community Channel - Ecotone has a community channel, where you can ask questions, discuss with other users and get help. It is also a great place to share your experiences, and to meet other developers using Ecotone.

  • Subscribe to Mailing list - Join mailing list to stay up to date with Ecotone changes and latest articles and features.

  • Step-by-step Tutorial

    Some demos and quick-start examples are done using specific framework integration. However Ecotone does not bind given set of features to specific solution. Whether you use Laravel, Symfony or Lite (no external framework), all features will work in the same way. Therefore feel encouraged to test out examples, even if they are not in framework of your choice.

    How Ecotone Solves It

    An Event Sourced Aggregate stores its history as a sequence of events (OrderWasPlaced, LineItemAdded, PaymentReceived, OrderShipped) instead of just its current state. The current state is a function of the events. The audit log isn't a separate concern — it is the storage. When you need a new read model (a list page, a search index, a reporting view), Projections feed off the same event stream without rerunning your handlers.

    Reach for an event-sourced aggregate when the history matters as much as the current state — finance, healthcare, billing, regulated audit trails — or when you'll want to derive multiple read models from the same domain over time.

    The pages below walk through declaring event-sourced aggregates, applying events to rebuild state, and the different ways to record events from your handlers.

    Working with Aggregates
    Applying Events
    Different ways to Record Events
    #[NamedEvent]
    ,
    #[Stream]
    , and
    #[AggregateType]
    decouple stored event names from PHP class names so refactors don't break stored data.

    Your aggregate has 50,000 events and rehydration takes seconds. Snapshots store a periodic checkpoint so the aggregate loads from snapshot + recent events.

    Your event store contains user emails. The user requests deletion under GDPR and your store is append-only. Crypto-shredding via per-subject keys satisfies the requirement without rewriting events.

    Persistence Strategies
    Event Sourcing Repository
    Making Stream immune to changes
    Snapshoting
    Event Serialization and PII Data (GDPR)
    #[Aggregate]
    class Ticket
    {
        #[Identifier]
        private Uuid $ticketId;
        private string $assignedTo;
           
        #[QueryHandler("ticket.get_assigned_person")]
        public function getAssignedTo(): string
        {
           return $this->assignedTo;
        }
    }
    $this->commandBus->sendWithRouting(
        "ticket.get_assigned_person",
        // We provide instance of Ticket aggregate using metadata 
        metadata: ["aggregate.id" => $ticketId]
    )

    You may of course use of Query class or metadata in case of need, which will be passed to your aggregate's method.

    Aggregate Introduction
    Saga vs Aggregate

    Both are message-driven persisted classes. The distinction is what they own:

    • An Aggregate owns business state and invariants for one entity (an Order, an Account, a Subscription) — its handlers protect rules about that entity.

    • A Saga owns a business process — its handlers track progress across many entities or services, often over time, and react to events as they arrive.

    If you need to wait for something, branch on the result, time out after 24 hours, or coordinate across multiple aggregates, that's a Saga.

    Where to go next

    Sagas are part of Ecotone's broader Business Workflow support, alongside output-channel chaining and Orchestrators. The full implementation guide — including delayed timeouts, branching, failure handling, and a worked Order/Payment/Shipping example — lives in:

    For deciding between a Saga and the simpler workflow primitives:

    Sagas: Workflows That Remember
    Business Workflows
    #[\Attribute]
    class Cache 
    {
        public string $cacheKey;
        public int $timeToLive;
        
        public function __construct(string $cacheKey, int $timeToLive)
        {
            $this->cacheKey = $cacheKey;
            $this->timeToLive = $timeToLive;
        }
    }
    class ProductsService
    {
       #[QueryHandler]
       #[Cache("hotestProducts", 120)]
       public function getHotestProducts(GetOrderDetailsQuery $query) : array
       {
          return ["orderId" => $query->getOrderId()]
       }
    }  
    You target asynchronous endpoints by using AsynchronousRunningEndpoint as the pointcut.

    Inject Message's payload

    As part of around intercepting, if we need Message Payload to make the decision we can simply inject that into our interceptor:

    Inject Message Headers

    We can also inject Message Headers into our interceptor. We could for example inject Message Consumer name in order to decide whatever to start the transaction or not:

    previous section
    #[Around(pointcut: AsynchronousRunningEndpoint::class)]
    public function transactional(
        MethodInvocation $methodInvocation,
        #[Payload] string $command
    )
    • PostgreSQL

    • MySQL

    • MariaDB

    Install Inbuilt Serialization Support

    Ecotone provides inbuilt functionality to serialize your Events, which can be customized in case of need. This makes Ecotone take care of Event Serialization/Deserialization, and allows us to focus on the business side of the code.

    We can take over this process and set up our own Serialization, however Ecotone JMS Converter can fully do it for us, so we can simply focus on the business side of the code. To make it happen all we need to do, is to install JMS Package and we are ready to go:

    DBAL Support
    Then tell the projection to make use of it:

    Custom Aggregate Type

    By default events in the stream will hold Aggregate Class name as AggregateType. You may customize this by applying AggregateType attribute to your Aggregate.

    Storing Events By Names

    To avoid storing class names of Events in the Event Store we may mark them with name:

    This way Ecotone will do the mapping before storing an Event and when retrieving the Event in order to deserialize it to correct class.

    Testing

    It's worth to remember that if we want test storing Events using provided Event Named, we need to add them under recognized classes, so Ecotone knows that should scan those classes for Attributes:

    You may wonder what is the difference between Stream name and Aggregate Type. By default the are the same, however we could use the same Stream name between different Aggregates, to store them all together within same Table. This may useful during migration to next version of the Aggregate, where we would want to hold both versions in same Stream.

    #[Saga]
    class OrderFulfillment
    {
        #[Identifier]
        private string $orderId;
        private OrderState $state;
    
        #[EventHandler]
        public static function start(OrderWasPlaced $event): self { /* ... */ }
    
        #[EventHandler]
        public function onPaymentReceived(PaymentReceived $event, CommandBus $bus): void
        {
            $bus->send(new ReserveStock($this->orderId));
        }
    }
    class NotificationFilter
    {
        #[After] 
        public function filter($result, Cache $cache) : ?array
        {
            $this->cachingSystem($cache->cacheKey, $result, $cache->timeToLive);
        }
    }
    class TransactionInterceptor
    {
        #[Around(pointcut: AsynchronousRunningEndpoint::class)]
        public function transactional(MethodInvocation $methodInvocation)
        {
            $this->connection->beginTransaction();
            try {
                $result = $methodInvocation->proceed();
    
                $this->connection->commit();
            }catch (\Throwable $exception) {
                $this->connection->rollBack();
    
                throw $exception;
            }
    
            return $result;
        }
    }
    #[Around(pointcut: AsynchronousRunningEndpoint::class)]
    public function transactional(
        MethodInvocation $methodInvocation,
        #[Header('polledChannelName')] string $consumerName
    )
    composer require ecotone/pdo-event-sourcing
    composer require ecotone/jms-converter
    #[Stream("basket_stream")]
    class Basket
    #[Projection(self::PROJECTION_NAME, "basket_stream")]
    class BasketList
    #[AggregateType("basket")]
    class Basket
    #[NamedEvent("basket.was_created")]
    class BasketWasCreated
    {
        public const EVENT_NAME = "basket.was_created";
    
        private string $id;
    
        public function __construct(string $id)
        {
            $this->id = $id;
        }
    
        public function getId(): string
        {
            return $this->id;
        }
    }
    $ecotoneLite = EcotoneLite::bootstrapFlowTesting(
        [Basket::class, BaskeWasCreated::class],
    );
    Demo Asynchronous Events

    Demo Aggregates and Eloquent

    Demo Event Sourcing

    Demo Distributed Application

    Read Blog Post

    • Read about Messaging and DDD with Laravel

    • Read about CQRS and Aggregates with Laravel

    Publish Asynchronous Events Demo

    Aggregates and Doctrine ORM Demo

    Event Sourcing Demo

    Distributed Application Demo

    Read Blog Post

    • Read more about Symfony and Ecotone

    Lessons

    The tutorial is divided into several lessons:

    • Lesson 1, we will learn the fundamentals of Ecotone: Endpoints, Messages, Channels, and Command Query Responsibility Segregation (CQRS)

    • Lesson 2, we will learn Tactical Domain Driven Design (DDD): Aggregates, Repositories and also Event Handlers

    • Lesson 3, we will learn how to use Converters, therefore how to handle serialization and deserialization

    • , we will learn about Metadata and Method Invocation - How we can execute Message Handlers in a way not available in any other PHP Framework

    • , we will learn about Interceptors, Ecotone's powerful Middlewares

    • , we we will learn about Asynchronous Endpoints, so how to process our Messages asynchronously.

    Found something to improve in the docs? Create Pull Request in Documentation repository.

    You don’t have to complete all of the lessons at once to get the value out of this tutorial. You will start benefit from the tutorial even if it’s one or two lessons.

    How Ecotone Solves It

    Ecotone provides Event Sourcing as a first-class feature. Instead of storing current state, you store the sequence of events that led to it. Rebuild any view of the data by replaying events. Get a complete, immutable audit trail automatically. Works with Postgres, MySQL, and MariaDB for event storage, with projections that can write to any storage you choose.


    Read more in the following chapters.

    Materials

    Demo implementation

    • Implementing Event Sourcing Aggregates

    • Emitting Events from Projections

    • Working directly with Event Sourcing Aggregates

    Links

    • Starting with Event Sourcing in PHP [Article]

    • Implementing Event Sourcing Application in 15 minutes [Article]

    Works with: Laravel, Symfony, and Standalone PHP

    Building durable workflows on top of event sourcing? Event-sourced sagas (#[EventSourcingSaga]) record every state transition of a long-running process — order fulfillment, payouts, KYC, multi-step onboarding — as queryable events in your own database. Crashes, deploys, and restarts: the saga rehydrates by replaying its events and continues from exactly where it left off. See for the three workflow shapes Ecotone supports.

    Doctrine ORM Integration
    Laravel Eloquent Integration
    Implementing Event Sourcing Application in 15 minutes
    Read more about Event Sourcing in PHP and Ecotone
    Read more about Microservices in PHP and Ecotone
    Building Reactive Message-Driven Systems in PHP
    Ensuring data consistency with outbox pattern

    Scattered Application Logic

    How to organize business logic with CQRS in Laravel and Symfony using Ecotone

    The Problem You Recognize

    Your application started clean, but as features grew, the boundaries blurred. Controllers handle business logic. Services read and write in the same method. Event listeners trigger side effects that nobody can trace.

    In Laravel, you might have a 300-line Controller that validates input, queries the database, applies business rules, dispatches jobs, and returns a response — all in one method.

    In Symfony, you might have a service class with 10 injected dependencies, where changing how orders are placed breaks the order listing page because both share the same service.

    The symptoms are familiar:

    • New developers take weeks to understand what happens when a user places an order

    • Testing a single business rule requires setting up the entire framework

    • A change in one area causes failures in unrelated features

    What the Industry Calls It

    CQRS — Command Query Responsibility Segregation. Separate the code that changes state (commands) from the code that reads state (queries). Add event handlers for side effects. Each handler has one job.

    How Ecotone Solves It

    With Ecotone, you organize your code around Command Handlers, Query Handlers, and Event Handlers — each responsible for exactly one thing. Ecotone wires them together automatically through PHP attributes:

    No base classes. No interfaces to implement. Your existing Laravel or Symfony services stay exactly where they are — you add attributes to give them clear responsibilities.

    Next Steps

    • — Learn how to define and dispatch commands

    • — Separate your read models

    • — React to domain events

    Audit Trail & State Rebuild

    How to implement Event Sourcing for audit trails and state rebuilds in PHP

    The Problem You Recognize

    A customer disputes a charge. Your support team asks "what exactly happened to this order?" The answer requires reading application logs, database timestamps, and hoping someone didn't overwrite the data.

    Your read models need a schema change. You write a migration script, but there's no way to verify the migrated data is correct — the original events that created it are gone. You store the current state, but not how you got there.

    The symptoms:

    • No history — you know what the current price is, but not what it was yesterday

    • Risky migrations — changing the read model means writing one-off scripts and praying

    • Compliance gaps — auditors ask for a complete trail of changes and you can't provide one

    What the Industry Calls It

    Event Sourcing — instead of storing the current state, store the sequence of events that led to it. Rebuild any view of the data by replaying events. Get a complete, immutable audit trail for free.

    How Ecotone Solves It

    Ecotone provides Event Sourcing as a first-class feature with built-in projections. Your aggregate records events instead of mutating state:

    Build read models (projections) that can be rebuilt at any time from the event history:

    Works with Postgres, MySQL, and MariaDB for event storage. Projections can write to any storage you choose.

    Next Steps

    • — How Event Sourced Aggregates work

    • — Build and rebuild read models

    • — Evolve your events safely

    Microservice Communication

    How to build reliable microservice communication in PHP with Ecotone Distributed Bus

    The Problem You Recognize

    Your monolith is splitting into services. Or you already have multiple services and they need to talk to each other.

    The current approach: HTTP calls between services. When Service B is down, Service A fails too. You've built custom retry logic, custom serialization, and custom routing for each service pair. There's no guaranteed delivery — if a request fails, the data is lost unless you built a custom retry mechanism.

    The symptoms:

    • Cascading failures — one service going down takes others with it

    • Custom glue code per service pair — serialization, routing, error handling

    • No event sharing — services can't subscribe to each other's events without point-to-point integrations

    • Broker lock-in — switching from RabbitMQ to SQS means rewriting integration code

    What the Industry Calls It

    Distributed Messaging — services communicate through a message broker with guaranteed delivery, event sharing, and transport abstraction.

    How Ecotone Solves It

    Ecotone's Distributed Bus lets services send commands and publish events to each other through message brokers. Your application code stays the same — Ecotone handles routing, serialization, and delivery:

    Supports RabbitMQ, Amazon SQS, Redis, and Kafka — swap transports without changing application code.

    Next Steps

    • — Cross-service messaging

    • — Consuming from external sources

    • — Publishing to external targets

    Event Handling PHP

    Event Handlers PHP

    Demo

    Read Blog Post

    Read more about Event Handling in PHP and Ecotone

    Code Example

    Let's create Event Order was placed.

    And Event Handler that will be listening to the OrderWasPlaced.

    Running The Example

    Before we start tutorial

    Prerequisites and setup before starting the Ecotone tutorial

    Setup for tutorial

    Depending on the preferences, we may choose tutorial version for

    • Symfony

    1. Use to download starting point in order to start tutorial

    2. Run command line application to verify if everything is ready.

    Repositories Introduction

    Repository PHP

    Read Aggregate Introduction first for more details about Aggregates.

    The Problem

    Every Command Handler does the same three lines: findById, do something, save. After ten handlers, that's thirty lines of identical glue. And the moment your aggregate becomes a Doctrine entity (or an Eloquent model), persistence concerns start leaking into the domain — fields exist to please the ORM, tests need a real database, and migrating to a different storage means rewriting every aggregate.

    How Ecotone Solves It

    A Repository is the seam between your domain (which only knows aggregates) and your storage (Doctrine, Eloquent, Document Store, Event Store). Ecotone provides built-in repositories for each — pick one, point Ecotone at it, and your aggregates stay free of @ORM\Entity and extends Model. The fetch/save boilerplate disappears entirely; Ecotone wraps every command in load → call → save automatically.

    Typical Aggregate Flow

    Repositories retrieve and save the aggregate to persistent storage. The typical flow for calling an aggregate method looks like:

    By setting up Repository we provide Ecotone with functionality to fetch and store the Aggregate , so we don't need to write the above orchestration code anymore.

    Ecotone's Aggregate Flow

    If our class is defined as Aggregate, Ecotone will use Repository in order fetch and store it, whenever the Command is sent via Command Bus.

    Now when we will send the Command, Ecotone will use ticketId from the Command to fetch related Ticket Aggregate, and will called assignWorker passing the Command. After this is completed it will use the repository to store changed Aggregate instance.

    Therefore from high level nothing changes:

    This way we don't need to write orchestration level code ourselves.

    Converting Results

    Converting query results in Database Business Interface

    Converting Results

    In rich business domains we will want to work with higher level objects than associate arrays. Suppose we have PersonNameDTO and defined Ecotone's Converter for it.

    class PersonNameDTOConverter
    {
        #[Converter]
        public function from(PersonNameDTO $personNameDTO): array
        {
            return [
                "person_id" => $personNameDTO->getPersonId(),
                "name" => $personNameDTO->getName()
            ];
        }
    
        #[Converter]
        public function to(array $personNameDTO): PersonNameDTO
        {
            return new PersonNameDTO($personNameDTO['person_id'], $personNameDTO['name']);
        }
    }

    Converting to Collection of Objects

    Ecotone will read the Docblock and based on that will deserialize Result Set from database to list of PersonNameDTO.

    Converting to single Object

    Using combination of First Row Fetch Mode, we can get first row and then use it for conversion to PersonNameDTO.

    Converting Iterator

    For big result set we may want to avoid fetching everything at once, as it may consume a lot of memory. In those situations we may use Iterator Fetch Mode, to fetch one by one. If we want to convert each result to given Class, we may define docblock describing the result:

    Each returned row will be automatically convertered to PersonNameDTO.

    Converting to specific Media Type Format

    We may return the result in specific format directly. This is useful when Business Method is used on the edges of our application and we want to return the result directly.

    In this example, result will be returned in application/json.\

    Extending Message Buses (Gateways)

    Extending Command, Event, and Query Buses with custom Gateways

    You want webhook commands to retry 3 times and dedup on paymentId, but internal admin commands to fail fast with no retry. Both go through your CommandBus. Extending the bus interface lets you declare a WebhookCommandBus extends CommandBus, attach #[InstantRetry] and #[Deduplicated] directly to it, and inject the typed bus where you want those policies — same handlers, different policies, no runtime branching.

    For better understanding, please read Interceptors section before going through this chapter.

    Intercepting Gateways

    Suppose we want to add custom logging, whenever any Command is executed. We know that CommandBus is a interface for sending Commands, therefore we need to hook into that Gateway.

    Intercepting Gateways, does not differ from intercepting Message Handlers.

    Building customized Gateways

    We may also want to have different types of Message Buses for given Message Type. For example we could have EventBus with audit which we would use in specific cases. Therefore we want to keep the original EventBus untouched, as for other scenarios we would simply keep using it.

    To do this, we will introduce our new EventBus:

    That's basically enough to register our new interface. This new Gateway will be automatically registered in our DI container, so we will be able to inject it and use.

    Now as this is separate interface, we can point interceptor specifically on this

    Pointcut by attributes

    We could of course intercept by attributes, if we would like to make audit functionality reusable

    and then we pointcut based on the attribute

    Asynchronous Gateways

    Gateways can also be extended with asynchronous functionality on which you can read more in .

    Event Sourcing Introduction

    Using Event Sourcing in PHP

    Works with: Laravel, Symfony, and Standalone PHP

    The Problem

    You store the current state but not how you got there. When a customer disputes a charge, you can't answer "what exactly happened?" Rebuilding read models after a schema change means writing migration scripts by hand.

    How Ecotone Solves It

    Ecotone's Event Sourced Aggregates store events instead of current state. Every state change is an immutable event in a stream. Projections rebuild read models from event history — change the schema, replay the events, get a correct read model.


    Before diving into this section be sure to understand how Aggregates works in Ecotone based on .

    Difference between Aggregate Types

    Ecotone provides higher level abstraction to work with Event Sourcing, which is based on Event Sourced Aggregates. Event Sourced Aggregate just like normal Aggregates protect our business rules, the difference is in how they are stored.

    State-Stored Aggregates

    Normal Aggregates are stored based on their current state:

    Yet if we change the state, then our previous history is lost:

    Having only the current state may be fine in a lot of cases and in those situation it's perfectly fine to make use of . This is most easy way of dealing with changes, we change and we forget the history, as we are interested only in current state.

    Event Sourcing Aggregate

    When we are interested in history of changes, then Event Sourced Aggregate will help us. Event Sourced Aggregates are stored in forms of Events. This way we preserve all the history of given Aggregate:

    When we change the state the previous Event is preserved, yet we add another one to the audit trail (Event Stream).

    This way all changes are preserved and we are able to know what was the historic changes of the Product.

    Event Stream

    The audit trail of all the Events that happened for given Aggregate is called Event Stream. Event Stream contains of all historic Events for all instance of specific Aggregate type, for example all Events for Product Aggregate

    Let's now dive a bit more into Event Streams, and what they actually are.

    Applying Events

    Applying events to rebuild Event Sourcing Aggregate state in PHP

    As mentioned earlier, Events are stored in form of a Event Stream. Event Stream is audit of Events, which happened in the past. However to protect our business invariants, we may want to work with current state of the Aggregate to know, if given action is possible or not (business invariants).

    Business Invariants

    Business Invariants in short are our simple "if" statements inside the Command Handler in the Aggregate. Those protect our Aggregate from moving into incorrect state. With State-Stored Aggregates, we always have current state of the Aggregate, therefore we can check the invariants right away. With Event-Sourcing Aggregates, we store them in form of an Events, therefore we need to rebuild our Aggregate, in order to protect the invariants.

    Suppose we have Ticket Event Sourcing Aggregate.

    For this Ticket we do allow for assigning an Person to handle the Ticket. Let's suppose however, that Business asked us to allow only one Person to be assigned to the Ticket at time. With current code we could assign unlimited people to the Ticket, therefore we need to protect this invariant.

    To check if whatever Ticket was already assigned to a Person, our Aggregate need to have state applied which will tell him whatever the Ticket was already assigned. To do this we use EventSourcingHandler attribute passing as first argument given Event class. This method will be called on reconstruction of this Aggregate. So when this Aggregate will be loaded, if given Event was recorded in the Event Stream, method will be called:

    Then this state, can be used in the Command Handler to decide whatever we can trigger an action or not:

    Unreliable Async Processing

    How to build reliable async processing in Laravel and Symfony with Ecotone

    The Problem You Recognize

    You added async processing to handle background work — sending emails, processing payments, syncing data. But now you have new problems:

    • Failed jobs disappear silently or retry forever with no visibility

    Complex Business Processes

    How to manage complex multi-step business workflows in PHP with Ecotone

    The Problem You Recognize

    Your order fulfillment process spans 6 steps across 4 services. The subscription lifecycle involves payment processing, provisioning, notifications, and grace periods. User onboarding triggers a welcome email, account setup, and a follow-up sequence.

    The logic for these processes is spread across:

    Configure Repository

    Configuring custom Aggregate repositories in Ecotone PHP

    To use Ecotone's Aggregate functionality, we need a registered repository. Ecotone comes with built-in support for popular persistence options like Doctrine ORM, Eloquent, and document stores, so there's a good chance we can use what we already have without extra work. If our storage solution isn't supported yet, or if we have specific requirements, we can easily register our own custom repository by following the steps in this section. This flexibility means we're not locked into any particular database or ORM—we can use whatever fits our project best.

    Repository for State-Stored Aggregate

    State-Stored Aggregate are normal Aggregates, which are stored using Standard Repositories. Therefore to configure Repository for your Aggregate, create a class that extends

    Different ways to Record Events

    Different ways to record events in Event Sourcing Aggregates

    Two ways of setting up Event Sourced Aggregates

    There are two ways we can configure our Aggregate to record Events.

    Snapshoting

    PHP Event Sourcing Snapshoting

    In general having streams in need for snapshots may indicate that our model needs revisiting. We may cut the stream on some specific event and begin new one, like at the end of month from all the transactions we generate invoice and we start new stream for next month. However if cutting the stream off is not an option for any reason, we can use snapshots to avoid loading all events history for given Aggregate. Every given set of events snapshot will be taken, stored and retrieved on next calls, to fetch only events that happened after that.

    Setting up

    EventSourcingConfiguration provides the following interface to set up snapshots.

    Event versioning

    Event versioning and upcasting for Event Sourcing in PHP

    In its lifetime events may change. In order to track those changes Ecotone provides possibility of versioning events.

    Value given with Revision attribute will be stored by Ecotone in events metadata. Attribute is used only when event is saved in event store. In order to read it, you can access events metadata, e.g. in event handler.

    [Enterprise] Accessing Metadata in Event Sourcing Handler

    Recovering, Tracing and Monitoring

    Recovering, tracing, and monitoring message-driven applications

    A consumer crashes mid-handler. The Stripe webhook arrives twice. A notify-customer job fails forever and you can't tell why. Two workers race on the same aggregate and one's update gets silently overwritten. This section is the toolbox for everything that goes wrong after a message is dispatched.

    Ecotone provides solutions across three concerns:

    • Self-healing (, , )

    Lesson 4
    Lesson 5
    Lesson 6
    Durable Execution in PHP

    Event-sourced sagas are durable workflows. When a long-running process — order fulfillment, payouts, multi-step onboarding — is its history, model it as an #[EventSourcingSaga]: every state transition is an event in your own database, the saga rebuilds itself by replaying those events, and you can project the same events into any view, audit log, or dashboard your application needs. See Durable Execution in PHP for the full story.

    When we actually need to know what was the history of changes, then State-Stored Aggregates are not right path for this. If we will try to adjust them so they are aware of history we will most likely complicate our business code. This is not necessary as there is better solution - Event Sourced Aggregates.

    previous sections
    State-Stored Aggregates
    State-Stored Aggregate State
    Price was changed, we don't know what was the previous price anymore
    Event-Sourced Aggregate
    Price was changed, yet we still have the previous Event in audit trail (Event Stream)
    Product Event Stream

    There are two options in which we run the tutorial:

    • Local Environment

    • Docker (preferred)

    If you can see "Hello World", then we are ready to go. Time for Lesson 1!

    Laravel
    Lite (No extra framework)
    git
    Lesson 1: Messaging Concepts
    git clone [email protected]:ecotoneframework/symfony-tutorial.git
    # Go to symfony-tutorial catalog
    git clone [email protected]:ecotoneframework/laravel-tutorial.git
    # Go to laravel-tutorial catalog
    
    # Normally you will use "php artisan" for running console commands
    # To reduce number of difference during the tutorial
    # "artisan" is changed to "bin/console"
    git clone [email protected]:ecotoneframework/lite-tutorial.git
    # Go to lite-tutorial catalog
    /** Ecotone Quickstart ships with docker-compose with preinstalled PHP 8.0 */
    1. Run "docker-compose up -d"
    2. Enter container "docker exec -it ecotone-quickstart /bin/bash"
    3. Run starting command "composer install"
    4. Run starting command "bin/console ecotone:quickstart"
    5. You should see:
    "Running example...
    Hello World
    Good job, scenario ran with success!"
    /** You need to have atleast PHP 8.0 and Composer installed */
    1. Run "composer install" 
    2. Run starting command "bin/console ecotone:quickstart"
    3. You should see:
    "Running example...
    Hello World
    Good job, scenario ran with success!"
    Unexpected error with integration github-files: Integration is not installed on this space
    — Encapsulate business rules in a single place
    CQRS Introduction — Commands
    Query Handling
    Event Handling
    Aggregate Introduction
    — Storage strategies and snapshots

    As You Scale: Ecotone Enterprise adds Partitioned Projections for independent per-aggregate processing, Async Backfill & Rebuild with parallel workers, and Blue-Green Deployments for zero-downtime projection updates.

    Event Sourcing Introduction
    Projections
    Event Versioning
    Event Stream Persistence

    As You Scale: Ecotone Enterprise adds Distributed Bus with Service Map — a topology-aware distributed bus that supports multiple brokers in a single topology, automatic routing, and cross-framework integration.

    Distributed Bus
    Message Consumer
    Message Publisher

    It's enough to extend given Gateway with custom interface to register new abstraction in Gateway in Dependency Container. In above example AuditableEventBus will be automatically available in Dependency Container to use, as Ecotone will deliver implementation.

    Asynchronous section
    #[EventSourcingAggregate]
    class Ticket
    {
        use WithAggregateVersioning;
    
        #[Identifier]
        private string $ticketId;
    
        (...)
    
        #[CommandHandler]
        public function assign(AssignPerson $command) : array
        {
            return [new PersonWasAssigned($this->ticketId, $command->personId)];
        }
    }

    As you can see, it make sense to only assign to the state attributes that protect our invariants. This way the Aggregate stays readable and clean of unused information.

    StandardRepository
    interface:
    1. canHandle method informs, which Aggregate Classes can be handled with this Repository. Return true, if saving specific aggregate is possible, false otherwise.

    2. findBy method returns if found, existing Aggregate instance, otherwise null.

    3. save method is responsible for storing given Aggregate instance.

    • $identifiers are array of #[Identifier] defined within aggregate.

    • $aggregate is instance of aggregate

    • $metadata is array of extra information, that can be passed with Command

    • $expectedVersion if version locking by #[Version] is enabled it will carry currently expected

    Set up your own Implementation

    When your implementation is ready simply mark it with #[Repository] attribute:

    Example implementation using Doctrine ORM

    This is example implementation of Standard Repository using Doctrine ORM.

    Repository:

    Using Multiple Repositories

    By default Ecotone when we have only one Standard and Event Sourcing Repository registered, Ecotone will use them for our Aggregate by default. This comes from simplification, as if there is only one Repository of given type, then there is nothing else to be choose from. However, if we register multiple Repositories, then we need to take over the process and tell which Repository will be used for which Aggregate.

    • In case of Custom Repositories we do it using canHandle method.

    • In case of inbuilt Repositories, we should follow configuration section for given type

    Repository for Event Sourced Aggregate

    Custom repository for Event Sourced Aggregates is described in more details under Event Sourcing Repository section.

    Depending on the version we may actually want to restore our Aggregate a bit differently. This is especially useful when we've changed the way Events are structured and introduced new version of the Event. For this we can use revision header to access the version on which given Event was stored.

    Revision applies to messages in general (also commands and queries). However, for now it is used only when events gets saved.

    You don't have to define Revision for your current events. Ecotone will set it's value to 1 by default. Also, if not defined in the class, already saved events will be read with Revision 1.

    This feature is available as part of Ecotone Enterprise.

    We may inject any type of Header that was stored together with the Event. This means inbuilt not only headers like timestamp, id, correlationId are avaiable out of the box, but also custom headers provided by the application (e.g. userId).

    class OrderService
    {
        #[CommandHandler]
        public function placeOrder(PlaceOrder $command): void
        {
            // Only handles placing the order — nothing else
        }
    
        #[QueryHandler("order.get")]
        public function getOrder(GetOrder $query): OrderView
        {
            // Only handles reading — no side effects
        }
    }
    
    class NotificationService
    {
        #[EventHandler]
        public function whenOrderPlaced(OrderWasPlaced $event): void
        {
            // Reacts to the event — fully decoupled from order logic
        }
    }
    #[EventSourcingAggregate]
    class Order
    {
        #[Identifier]
        private string $orderId;
    
        #[CommandHandler]
        public static function place(PlaceOrder $command): array
        {
            return [new OrderWasPlaced($command->orderId, $command->items)];
        }
    
        #[EventSourcingHandler]
        public function onOrderPlaced(OrderWasPlaced $event): void
        {
            $this->orderId = $event->orderId;
        }
    }
    #[ProjectionV2('order_list')]
    #[FromAggregateStream(Order::class)]
    class OrderListProjection
    {
        #[EventHandler]
        public function onOrderPlaced(OrderWasPlaced $event): void
        {
            // Build your read model — rebuildable from history
        }
    }
    // Service A: Send a command to Service B
    $distributedBus->sendCommand(
        targetServiceName: "order-service",
        command: new PlaceOrder($orderId, $items),
    );
    // Service B: Handle commands from other services — same as local handlers
    #[Distributed]
    #[CommandHandler]
    public function placeOrder(PlaceOrder $command): void
    {
        // This handler receives commands from any service
    }
    class AssignWorkerHandler
    {
        private TicketRepository $ticketRepository;
    
        #[CommandHandler]
        public function handle(AssignWorkerCommand $command) : void
        {
           // fetch the aggregate from repository
           $ticket = $this->ticketRepository->findBy($command->getTicketId());
           // call action method
           $ticket->assignWorker($command);
           // store the aggregate in repository
           $this->ticketRepository->save($ticket);    
        }
    }
    $this->commandBus->send(
       new AssignWorkerCommand(
          $ticketId, $workerId,            
       )
    );
    #[Aggregate]
    class Ticket
    {
        #[Identifier]
        private string $ticketId;
    
        #[CommandHandler]
        public function assignWorker(AssignWorkerCommand $command)
        {
           // do something with assignation
        }
    }
    $this->commandBus->send(
       new AssignWorkerCommand(
          $ticketId, $workerId,            
       )
    );
    /**
    * @return PersonNameDTO[]
    */
    #[DbalQuery('SELECT person_id, name FROM persons LIMIT :limit OFFSET :offset')]
    public function getNameListDTO(int $limit, int $offset): array;
    #[DbalQuery(
        'SELECT person_id, name FROM persons WHERE person_id = :personId',
        fetchMode: FetchMode::FIRST_ROW
    )]
    public function getNameDTO(int $personId): PersonNameDTO;
    /**
     * @return iterable<PersonNameDTO>
     */
    #[DbalQuery(
        'SELECT person_id, name FROM persons ORDER BY person_id ASC',
        fetchMode: FetchMode::ITERATE
    )]
    public function getPersonIdsIterator(): iterable;
    #[DbalQuery(
        'SELECT person_id, name FROM persons WHERE person_id = :personId',
        fetchMode: FetchMode::FIRST_ROW,
        replyContentType: 'application/json'
    )]
    public function getNameDTOInJson(int $personId): string;
    class LoggerInterceptor
    {
        #[Before(pointcut: CommandBus::class)]
        public function log(object $command, array $metadata) : void
        {
            // log Command message
        }
    }
    interface AuditableEventBus extends EventBus {}
    #[CommandHandler]
    public function placeOrder(PlaceOrder $command, AuditableEventBus $eventBus)
    {
        // place order
        
        $eventBus->publish(new OrderWasPlaced());
    }
    class Audit
    {
        #[Before(pointcut: AuditableEventBus::class)]
        public function log(object $event, array $metadata) : void
        {
            // store audit
        }
    }
    #[Auditable]
    interface AuditableEventBus extends EventBus {}
    class Audit
    {
        #[Before(pointcut: Auditable::class)]
        public function log(object $event, array $metadata) : void
        {
            // store audit
        }
    }
    #[Asynchronous("async")]
    interface OutboxCommandBus extends CommandBus
    {
    
    }
    #[EventSourcingAggregate]
    class Ticket
    {
        use WithAggregateVersioning;
    
        #[Identifier]
        private string $ticketId;
        private bool $isAssigned;
    
        #[CommandHandler]
        public function assign(AssignPerson $command) : array
        {
            if ($this-isAssigned) {
               throw new \InvalidArgumentException("Ticket already assigned");
            }
        
            return [new PersonWasAssigned($this->ticketId, $command->personId)];
        }
    
        #[EventSourcingHandler]
        public function applyPersonWasAssigned(PersonWasAssigned $event) : void
        {
            $this->isAssigned = true;
        }
    }
    if ($this-isAssigned) {
      throw new \InvalidArgumentException("Ticket already assigned");
    }
    interface StandardRepository
    {
        
        1 public function canHandle(string $aggregateClassName): bool; 
        
        2 public function findBy(string $aggregateClassName, array $identifiers) : ?object;
        
        3 public function save(array $identifiers, object $aggregate, array $metadata, ?int $expectedVersion): void;
    }
    #[Repository]
    class DoctrineRepository implements StandardRepository
    {
        // implemented methods
    }
    final class EcotoneTicketRepository implements StandardRepository
    {
        public function __construct(private readonly EntityManagerInterface $entityManager)
        {
        }
    
        public function canHandle(string $aggregateClassName): bool
        {
            return $aggregateClassName === Ticket::class;
        }
    
        public function findBy(string $aggregateClassName, array $identifiers): ?object
        {
            return $this->entityManager->getRepository(Ticket::class)
                        // Array of identifiers for given Aggregate
                        ->find($identifiers['ticketId']);
        }
    
        public function save(array $identifiers, object $aggregate, array $metadata, ?int $versionBeforeHandling): void
        {
            $this->entityManager->persist($aggregate);
        }
    }
    use Ecotone\Modelling\Attribute\Revision;
    
    #[Revision(2)]
    class MyEvent
    {
        public string $id;
    }
    use Ecotone\Messaging\MessageHeaders;
    
    class MyEventHandler
    {
        #[EventHandler]
        public function handle(MyEvent $event, array $metadata) : void
        {
            if ($metadata[MessageHeaders::REVISION] !== 2) {
                return; // this is not the revision I'm looking for
            }
            
            // the force is strong with this one
        }
    }
    #[EventSourcingAggregate]
    class Product
    {
        #[Identifier]
        private string $id;
        private string $type;
    
        (...)
    
            
        #[EventSourcingHandler]
        public function applyProductWasCreated(
            ProductWasCreated $event,
            // Accessing Metadata
            #[Header("revision")] int $revision,
        ) : void
        {
            $this->id = $event->id;
            if ($revision < 4) {
                $this->type = 'standard';
            }
    
            $this->type = $event->type;
        }
    }

    You can't replay a failed message after fixing the bug — the data is gone

  • A duplicate webhook triggers the same handler twice, leading to double charges or duplicate emails

  • Going async required touching every handler — adding queue configuration, serialization, and retry logic to each one

  • Retrying a failed event triggers all handlers again — if one of three event handlers fails, the retry re-executes the two that already succeeded, causing side effects like duplicate emails or double charges

  • In Laravel, you've scattered dispatch() calls and ShouldQueue implementations across your codebase. In Symfony, you've configured Messenger transports and retry strategies in YAML, but each handler still needs custom error handling.

    What the Industry Calls It

    Resilient Messaging — a combination of patterns: failure isolation (per-handler message delivery), automatic retries, error channels, dead letter queues, the outbox pattern for guaranteed delivery, and idempotency for deduplication.

    How Ecotone Solves It

    With Ecotone, making a handler async is a single attribute. Resilience is built into the messaging layer — not bolted on per handler:

    Failure isolation — when multiple handlers subscribe to the same event, Ecotone delivers a separate copy of the message to each handler. If one fails, only that handler is retried — the others are not affected:

    Retries, error channels, and dead letter queues are configured once at the channel level — every handler on that channel gets production resilience automatically. No per-handler boilerplate.

    Next Steps

    • Asynchronous Handling — Make handlers async with a single attribute

    • Message Handling Isolation — Each handler gets its own message copy for safe retries

    • Retries — Configure automatic retry strategies

    • — Store failed messages for replay

    • — Guarantee message delivery

    • — Prevent double-processing

    // Make any handler async with one attribute
    #[Asynchronous("notifications")]
    #[EventHandler]
    public function sendWelcomeEmail(UserRegistered $event): void
    {
        // If this fails, Ecotone retries automatically
        // If it keeps failing, it goes to the dead letter queue
        // You can replay it after fixing the bug
    }
    #[Asynchronous("notifications")]
    #[EventHandler]
    public function sendWelcomeEmail(UserRegistered $event): void
    {
        // If this fails, only this handler retries
        // The inventory handler below is NOT re-triggered
    }
    
    #[Asynchronous("inventory")]
    #[EventHandler]
    public function reserveInventory(UserRegistered $event): void
    {
        // Runs independently — isolated from email handler failures
    }

    As You Scale: Ecotone Enterprise adds for synchronous commands, for centralized error routing, and that protects every handler behind a bus automatically.

    Event listeners that trigger other event listeners
  • Cron jobs that check status flags

  • Database columns like is_processed, retry_count, step_completed_at

  • Nobody can explain the full flow without reading all the code. Adding a step means editing multiple files. Reordering steps is risky. When something fails mid-process, recovery means manually updating database flags.

    What the Industry Calls It

    Two distinct patterns solve this, and they're often confused:

    • Workflows — stateless pipe-and-filter chaining. The message flows from one handler to the next via output channels. Each step is independent; nothing is remembered across steps.

    • Sagas — stateful long-running coordination. The saga remembers where it is across events that may arrive seconds, minutes, or days apart, and decides what to do next based on prior state.

    Neither Symfony Messenger nor Laravel Queues has a first-class equivalent — both stop at "dispatch a job." Ecotone provides both patterns natively.

    How Ecotone Solves It

    Workflows — chained handlers. Connect handlers through input and output channels. Each handler does one thing and passes the message on. No coordinator, no state; just declarative flow:

    Sagas — stateful coordination. Track state across events that arrive over time. The saga remembers where it is and reacts to each event based on what came before:

    Next Steps

    • Handler Chaining — Simple linear workflows

    • Sagas — Stateful workflows that remember

    • Handling Failures — Recovery and compensation

    #[CommandHandler(
        routingKey: "order.place",
        outputChannelName: "order.verify_payment"
    )]
    public function placeOrder(PlaceOrder $command): OrderData
    {
        // Step 1: Create the order, pass to next step
    }
    
    #[Asynchronous('async')]
    #[InternalHandler(
        inputChannelName: "order.verify_payment",
        outputChannelName: "order.ship"
    )]
    public function verifyPayment(OrderData $order): OrderData
    {
        // Step 2: Verify payment, pass to shipping
    }
    #[Saga]
    class OrderFulfillment
    {
        #[Identifier]
        private string $orderId;
        private string $status;
    
        #[EventHandler]
        public static function start(OrderWasPlaced $event): self
        {
            // Begin the saga — tracks state across events
        }
    
        #[EventHandler]
        public function onPaymentReceived(PaymentReceived $event, CommandBus $bus): void
        {
            $this->status = 'paid';
            $bus->send(new ShipOrder($this->orderId));
        }
    }

    As You Scale: Ecotone Enterprise adds — declarative workflow automation where you define step sequences in one place, with each step independently testable and reusable. Dynamic step lists adapt to input data without touching step code.

    1) Pure Event Sourced Aggregate

    This way of handling events does allow for pure functions. This means that actions called on the Aggregate returns Events and are not changing internal state of Aggregate.

    1. EventSourcingAggregate and Identifier works exactly the same as State-Stored Aggregate.

    2. Event Sourced Aggregate must provide version. You may leave it to Ecotone using WithAggregateVersioning or you can implement it yourself.

    3. CommandHandlerfor event sourcing returns events generated by specific method. This will be passed to the to be stored.

    4. EventSourcingHandler is method responsible for reconstructing Aggregate from previously created events. At least one event need to be handled in order to provide Identifier.

    2) Internal Recorder Aggregate

    This way of handling events allow for similarity with State Stored Aggregates. This convention requires changing internal state of Aggregate to record Events. Therefore Pure ES Aggregate is recommended as it's not require for any internal state changes in most of the scenarios. However ES Aggregate with Internal Recorder may be useful for projects migrating with other solutions, or when our team is heavily used to working this way.

    1. In order to make use of alternative way of handling events, we need to provide trait WithEvents.

    2. Command Handlers instead of returning events are acting the same as State Stored Aggregates. All events which will be published using recordThatwill be passed to the Repository to be stored.

    #[EventSourcingAggregate] // 1
    class Ticket
    {
        use WithAggregateVersioning; // 2
    
        #[Identifier] // 1
        private string $ticketId;
        private string $ticketType;
    
        #[CommandHandler] // 2
        public static function register(RegisterTicket $command) : array
        {
            return [new TicketWasRegistered($command->getTicketId(), $command->getTicketType())];
        }
    
        #[CommandHandler] // 2
        public function close(CloseTicket $command) : array
        {
            return [new TicketWasClosed($this->ticketId)];
        }
    
        #[EventSourcingHandler] // 4
        public function applyTicketWasRegistered(TicketWasRegistered $event) : void
        {
            $this->ticketId       = $event->getTicketId();
            $this->ticketType     = $event->getTicketType();
        }
    }
    #[EventSourcingAggregate] 
    class Basket
    {
        use WithEvents; // 1
        use WithVersioning;
    
        #[Identifier]
        private string $id;
    
        #[CommandHandler] // 2
        public static function create(CreateBasket $command) : static
        {
            $basket = new static();
            $basket->recordThat(new BasketWasCreated($command->getId()));
    
            return $basket;
        }
    
        #[CommandHandler] // 2
        public function addProduct(AddProduct $command) : void
        {
            $this->recordThat(new ProductWasAddedToBasket($this->id, $command->getProductName()));
        }
    
        #[EventSourcingHandler]
        public function applyBasketWasCreated(BasketWasCreated $basketWasCreated)
        {
            $this->id = $basketWasCreated->getId();
        }
    }
  • $aggregateClassToSnapshot - class name of an aggregate you want Ecotone to save snapshots of

  • $thresholdTrigger - amount of events for interval of taking a snapshot

  • $documentStore - reference to document store which will be used for saving/retrieving snapshots

  • To set up snapshots we will define ServiceContext configuration.

    Snapshot threshold

    Threshold states at which interval snapshots should be done. Therefore with below configuration:

    snapshots will be done every 500 events. Then when snapshot will be loaded, it will start loading the events from event number 501 for given Aggregate instance.

    class EventSourcingConfiguration
    {
        public const DEFAULT_SNAPSHOT_TRIGGER_THRESHOLD = 100;
    
        public function withSnapshotsFor(
            string $aggregateClassToSnapshot, // 1.
            int $thresholdTrigger = self::DEFAULT_SNAPSHOT_TRIGGER_THRESHOLD, // 2.
            string $documentStore = DocumentStore::class // 3.
        ): static
    }
    #[ServiceContext]
    public function aggregateSnapshots()
    {
        return EventSourcingConfiguration::createWithDefaults()
                ->withSnapshotsFor(Ticket::class, 1000)
                ->withSnapshotsFor(Basket::class, 500, MyDocumentStore::class)
        ;
    }
    ->withSnapshotsFor(Ticket::class, 500)

    Ecotone make use of to store snapshots, by default it's enabled with event-sourcing package. If you want to clean the snapshots, you can do it manually. Snapshots are stored in aggregate_snapshots collection.

    Data Consistency (Resilient Message Sending, Outbox pattern, Message Deduplication)
  • Recovery & Visibility (Dead Letter, Tracing, Monitoring)

  • Materials

    Demo implementation

    • Error Handling with delayed retries and storing in DLQ

    Links

    • Async Failure Recovery: Queue vs Streaming Channel Strategies {Article]

    • Read in depth material about resiliency in Messaging Systems using Ecotone [Article]

    • Resilient Messaging with Laravel [Article]

    • [Article]

    • [Article]

    Instant and Delayed Retries
    Concurrency / Locking
    Isolation of failures

    To find out more about different use-cases, read related section about .

    class OrderWasPlaced
    {
        private string $orderId;
        private string $productName;
    
        public function __construct(string $orderId, string $productName)
        {
            $this->orderId = $orderId;
            $this->productName = $productName;
        }
    
        public function getOrderId(): string
        {
            return $this->orderId;
        }
    
        public function getProductName(): string
        {
            return $this->productName;
        }
    }
    class NotificationService
    {
        #[EventHandler]
        public function notifyAboutNewOrder(OrderWasPlaced $event) : void
        {
            echo $event->getProductName() . "\n";
        }
    }
    $eventBus->publish(new OrderWasPlaced(1, "Milk"));

    Installation

    Installing Ecotone for Symfony, Laravel or Stand Alone

    Ecotone is the PHP architecture layer that grows with your system without rewrites. One Composer package adds CQRS, event sourcing, sagas, projections, outbox, EIP routing, and distributed messaging to Laravel or Symfony via declarative PHP attributes — no rewrite, no bespoke glue.

    Pick your stack below. Symfony and Laravel get dedicated integration packages with auto-configuration; any other framework (or no framework) runs on Ecotone Lite through a PSR-11 container.

    Prerequisites

    Before installing Ecotone, ensure you have:

    • PHP 8.1 or higher

    • Composer installed

    • A properly configured PHP project with PSR-4 autoloading

    Install for Symfony

    Step 1: Install the Ecotone Symfony Bundle using Composer

    Step 2: Verify Bundle Registration

    If you're using Symfony Flex (recommended), the bundle will auto-configure. If auto-configuration didn't work, manually register the bundle in config/bundles.php:

    Step 3: Verify Installation

    Run this command to check if Ecotone is properly installed:


    Install for Laravel

    Step 1: Install the Ecotone Laravel Package

    Step 2: Verify Provider Registration

    The service provider should be automatically registered via Laravel's package discovery. If auto-registration didn't work, manually add the provider to config/app.php:

    Step 3: Verify Installation

    Run this command to check if Ecotone is properly installed:


    Install Ecotone Lite (No framework)

    If you're using no framework or framework different than Symfony or Laravel, then you may use Ecotone Lite to bootstrap Ecotone.

    With Custom Dependency Container

    If you already have Dependency Container configured, then:

    Load namespaces

    By default Ecotone will look for Attributes only in Classes provided under "classesToResolve". If we want to look for Attributes in given set of Namespaces, we can pass it to the configuration.

    With no Dependency Container

    You may actually run Ecotone without any Dependency Container. That may be useful for small applications, testing or when we want to run some small Ecotone's script.


    Ecotone Lite Application

    You may use out of the box Ecotone Lite Application, which provide you with Dependency Container.

    Common Installation Issues

    "Class not found" errors

    Problem: Ecotone can't find your classes with attributes. Solution: Make sure your classes are in the correct namespace and directory structure matches your PSR-4 autoloading configuration.

    Bundle/Provider not registered

    Problem: Ecotone commands are not available. Solution:

    • For Symfony: Check that the bundle is listed in config/bundles.php

    • For Laravel: Check that the provider is in config/app.php or that package discovery is enabled

    Permission errors

    Problem: Cache directory is not writable. Solution: Ensure your web server has write permissions to the cache directory (usually var/cache for Symfony or storage/framework/cache for Laravel).

    CQRS PHP

    Command Query Responsibility Segregation PHP

    Separate the code that changes state from the code that reads it — clear command and query handlers with zero boilerplate.

    Demo

    Read Blog Post

    Code Example

    Registering Command Handlers

    Let's create PlaceOrder Command that will place an order in our system.

    And Command Handler that will handle this Command

    Registering Query Handlers

    Let's define GetOrder Query that will find our placed order.

    And Query Handlerthat will handle this query

    Running The Example

    Asynchronous PHP

    Running the code asynchronously

    Make any handler async with a single attribute — retries, error handling, and dead letter included automatically.

    Demo

    Read Blog Post

    Code Example

    Let's create Event Order was placed.

    And Event Handler that will be listening to the OrderWasPlaced.

    Let's Ecotone that we want to run this Event Handler Asynchronously using

    Running The Example

    Aggregate Event Handlers

    DDD PHP

    Read Aggregate Introduction sections first to get more details about Aggregates.

    Publishing Events from Aggregate

    To tell Ecotone to retrieve Events from your Aggregate add trait WithEvents which contains two methods: recordThat and getRecordedEvents.

    After importing trait, Events will be automatically retrieved and published after handling Command in your Aggregate.

    Subscribing to Event from your Aggregate

    Sometimes you may have situation, where Event from one Aggregate will actually change another Aggregate. In those situations you may actually subscribe to the Event directly from Aggregate, to avoid creating higher level boilerplate code.

    In those situations however you need to ensure event contains of reference id, so Ecotone knows which Aggregate to load from the database.

    Sending Named Events

    You may subscribe to Events by names, instead of the class itself. This is useful in cases where we want to decoupled the modules more, or we are not interested with the Event Payload at all. For Events published from your Aggregate, it's enough to provide NamedEvent attribute with the name of your event.

    And then you can subscribe to the Event using name

    Advanced Aggregate creation

    DDD PHP

    Create an Aggregate by another Aggregate

    There may be a scenario where the creation of an Aggregate is conditioned by the current state of another Aggregate.

    Ecotone provides a possibility for that and lets you focus more on domain modeling rather than technical nuances you may face trying to implement an actual use case.

    This case is supported by both Event Sourcing and State-based Aggregates.

    Create a State-based Aggregate

    It is possible to send a command to an Aggregate and expect a State-based Aggregate to be returned.

    Create an Event Sourcing Aggregate

    It is also possible to send a command to an Aggregate and expect the Event Sourcing Aggregate to be returned.

    Events handling

    Both of the Aggregates (called and result) can still record their Events using an Internal Recorder. Recorded Events will be published after the operation is persisted in the database.

    Persisting a state change

    In the case of an Event Sourcing Aggregate recording an event indicates a state change of that Aggregate.

    Also, when calling a State-based Aggregate its state may be changed before returning the newly created Aggregate. E.g. you want to save a reference to the newly created Aggregate.

    Ecotone will try to persist both called and returned Aggregates.

    Fetching/Storing Aggregates

    Fetching and storing Aggregates with repositories in Ecotone PHP

    Default flow

    In default flow there is no need to fetch or store Aggregates, because this is done for us. We simply need to trigger an Command via CommandBus. However in some cases, you may want to retake orchestration flow and do it directly. For that cases Business Repository Interface or Instant Fetch Aggregate can help you.

    Business Repository Interface

    Special type of is Repository. This Interface allows us to simply load and store our Aggregates directly. In situations when we call Command directly in our Aggregates we won't be in need to use it. However for some specific cases, where we need to load Aggregate and store it outside of Aggregate's Command Handler, this business interface becomes useful.

    Ecotone will read type hint to understand which Aggregate you would like to fetch or save.

    Pure Event Sourced Repository

    When using Pure Event Sourced Aggregate, instance of Aggregate does not hold recorded Events. Therefore passing aggregate instance would not contain any information about recorded events. For Pure Event Sourced Aggregates, we can use direct event passing to the repository:

    Instant Fetch Aggregate

    Fetch aggregates directly in your handlers without repository injection boilerplate. Aggregates arrive automatically via the #[Fetch] attribute, keeping handler code focused on business logic.

    You'll know you need this when:

    • Every aggregate command handler follows the same pattern: inject repository, fetch aggregate, call method, save

    • Repository injection boilerplate obscures the actual business logic in your handlers

    • You want your domain code to express "what happens" without "how to load it"

    To do instant fetch of Aggregate we will be using Fetch Attribute. Suppose we want PlaceOrder Command Handler, and we want to fetch User Aggregate:

    Fetch using to evaluate the expression given inside the Attribute. For example having above "payload.userId" and following Command:

    Ecotone will use userId from the Command to fetch User Aggregate instance. &#xNAN;"payload" is special variable within expression that points to our Command, therefore whatever is available within the Command is available for us to do the fetching. This provides quick way of accessing related Aggregates without the need to inject Repositories.

    Allowing non existing Aggregates

    By default Ecotone will throw Exception if Aggregate is not found, we can change the behaviour simply by allowing nulls in our method declaration:

    Accessing Message Headers

    We can also use Message Headers to fetch our related Aggregate instance:

    Using External Services

    In some cases we may not have enough information to provide correct Identifier, for example that may require some mapping in order to get the Identifier. For this cases we can use "reference" function to access any Service from Depedency Container in order to do the mapping.

    Introduction

    Introduction to Business Interfaces in Ecotone PHP

    Be sure to read CQRS Introduction before diving in this chapter.

    Execute your Business Actions via Interface

    Business Interface aims to reduce boilerplate code and make your domain actions explicit. In Application we describe an Interface, which executes Business methods. Ecotone will deliver implementation for this interface, which will bind the interface with specific actions. This way we can get rid of delegation level code and focus on the things we want to achieve. For example, if we don't want to trigger action via Command/Query Bus, we can do it directly using our business interface and skip all the Middlewares that would normally trigger during Bus execution. There are different types of Business Interfaces and in this chapter we will discuss the basics of build our own Business Interface, in next sections we will dive into specific types of business interfaces: Repositories and Database Layers.

    Command Interface

    Let's take as an example creating new Ticket

    We may define interface, that will call this Command Handler whenever will be executed.

    This way we don't need to use Command Bus and we can bypass all Bus related interceptors.

    The attribute #[BusinessMethod] tells Ecotone that given Interface is meant to be used as entrypoint to Messaging and which Message Handler it should send the Message to. Ecotone will provide implementation of this interface directly in our Dependency Container.

    Aggregate Command Interface

    We may also execute given Aggregate directly using Business Interface.

    Then we define interface:

    Query Interface

    Defining Query Interface works exactly the same as Command Interface and we may also use it with Aggregates.

    Then we may call this Query Handler using Interface

    Result Conversion

    If we have registered then we let Ecotone do conversion to Message Handler specific format:

    Then we may call this Query Handler using Interface

    Ecotone will use defined Converter to convert array to TicketDTO.

    Payload Conversion

    If we have registered then we let Ecotone do conversion to Message Handler specific format:

    Then we may call this Query Handler using Interface

    Ecotone will use defined Converter to convert array to CreateTicket command class.

    Working with Aggregates

    Working with Event Sourcing Aggregates in Ecotone PHP

    Working with Event Sourcing Aggregates

    Just as with Standard Aggregate, ES Aggregates are called by Command Handlers, however what they return are Events and they do not change their internal state.

    #[EventSourcingAggregate]
    class Product
    {
        use WithAggregateVersioning;
    
        #[Identifier]
        private string $id;
    
        #[CommandHandler]
        public static function create(CreateProduct $command) : array
        {
            return [new ProductWasCreated($command->id, $command->name, $command->price)];
        }
    }

    When this Aggregate will be called via Command Bus with CreateProduct Command, it will then return new ProductWasCreated Event.

    Command Handlers may return single events, multiple events or no events at all, if nothing is meant to be changed.

    Event Stream

    Aggregates under the hood make use of Partition persistence strategy (Refer to ). This means that we need to know:

    • Aggregate Version

    • Aggregate Id

    • Aggregate Type

    Aggregate Version

    To find out about current version of Aggregate Ecotone will look for property marked with Version Attribute.

    We don't to add this property directly, we can use trait instead:

    Anyways, this is all we need to do, as Ecotone will take care of reading and writing to this property. This way we can focus on the business logic of the Aggregate, and Framework will take care of tracking the version.

    Aggregate Id (Partition Key)

    We need to tell to Ecotone what is the Identifier of our Event Sourcing Aggregate. This is done by having property marked with Identifier in the Aggregate:

    As Command Handlers are pure and do not change the state of our Event Sourcing Aggregate, this means we need a different way to mutate the state in order to assign the identifier. For changing the state we use EventSourcingHandler attribute, which tell Ecotone that if given Event happens, then trigger this method afterwards:

    We will explore how applying Events works more in .

    Aggregate Type

    Aggregate Type will be the same as Aggregate Class. We can decouple the class from the Aggregate Type, more about this can be found in "" section.

    Recording Events in the Event Stream

    So when this Command Handler happens:

    What actually will happen under the hood is that this Event will be applied to the Event Stream:

    As storing in Event Store is abstracted away, the code stays clean and contains only of the business part. We can the Stream Name, Aggregate Type and even Event Names when needed.

    Event Sourcing Repository

    Configuring Event Sourcing repositories in Ecotone PHP

    Ecotone comes with inbuilt Event Sourcing repository after Event Sourcing package is installed. However you want to roll out your own storage for Events, or maybe you already use some event-sourcing framework and would like to integrate with it. For this you can take over the control by introducing your own Event Sourcing Repository.

    Using Custom Event Sourcing Repository will not allow you to make use of . Therefore consider configuring your own Event Sourcing Repository only if you want to build your own projecting system.

    Custom Event Sourcing Repository

    We do start by implementing EventSourcingRepository interface:

    interface EventSourcedRepository
    {
        1. public function canHandle(string $aggregateClassName): bool;
        
        2. public function findBy(string $aggregateClassName, array $identifiers, int $fromAggregateVersion = 1) :  EventStream;
    
        3. public function save(array $identifiers, string $aggregateClassName, array $events, array $metadata, int $versionBeforeHandling): void;
    }
    1. canHandle - Tells whatever given Aggregate is handled by this Repository

    2. findBy - Method returns previously created events for given aggregate. Which Ecotone will use to reconstruct the Aggregate.

    3. save - Stores events recorded by Event Sourced Aggregate

    and then we need to mark class which implements this interface as Repository

    Storing Events

    Ecotone provides enough information to decide how to store provided events.

    Identifiers will hold array of identifiers related to given aggregate (e.g. ["orderId" ⇒ 123]). Events will be list of Ecotone's Event classes, which contains of payload and metadata, where payload is your Event class instance and metadata is specific to this event. Metadata as parameter is generic metadata available at the moment of Aggregate execution. Version before handling on other hand is the version of the Aggregate before any action was triggered on it. This can be used to protect from concurrency issues.

    The structure of Events is as follows:

    Core metadata

    It's worth to mention about Ecotone's Events and especially about metadata part of the Event. Each metadata for given Event contains of three core Event attributes:

    "_aggregate_id" - This provides aggregate identifier of related Aggregate

    "_aggregate_version" - This provides version of the related Event (e.g. 1/2/3/4)

    "_aggregate_type" - This provides type of the Aggregate being stored, which can be customized

    Aggregate Type

    If our repository stores multiple Aggregates is useful to have the information about the type of Aggregate we are storing. However keeping the class name is not best idea, as simply refactor would break our Event Stream. Therefore Ecotone provides a way to mark our Aggregate type using Attribute

    This now will be passed together with Events under _aggregate_type metadata.

    Named Events

    In Ecotone we can name the events to avoid storing class names in the Event Stream, to do so we use NamedEvent.

    then when events will be passed to save method, they will automatically provide this name under eventName property.

    Snapshoting

    With custom repository we still can use inbuilt . To use it for customized repository we will use BaseEventSourcingConfiguration.

    Ecotone then after fetching snapshot, will load events only from this given moment using `fromAggregateVersion`.

    Testing

    If you want to test out your flow and storing with your custom Event Sourced Repository, you should disable default in memory repository

    Persistence Strategies

    Event stream persistence strategies in Ecotone PHP

    Persistence Strategy

    Describes how streams with events will be stored. Each Event Stream is separate Database Table, yet how those tables are created and what are constraints do they protect depends on the persistence strategy.

    Simple Stream Strategy

    This is the basics Stream Strategy which involves no constraints. This means that we can append any Events to it without providing any additional metadata.

    Now as this is free append involves no could re-run this code apply exactly the same Event. This can sounds silly, but it 's make it useful for particular cases. It make it super easy to append new Events. We basically could just add this action in our code and keep applying Events to the Event Stream, we don't need to know context of what happened before.

    This is useful for scenarios where we just want to store information without putting any business logic around this. It could be used to continues stream of information like:

    • Temperature changes

    • Counting car passing by in traffic jam

    • Recording clicks and user views.

    Partition Stream Strategy

    This the default persistence strategy. It does creates partitioning within Event Stream to ensure that we always maintain correct history within partition. This way we can be sure that each Event contains details on like Aggregate id it does relate to, on which version it was applied, to what Aggregate it references to.

    The tricky part here is that we need to know Context in order to apply the Event, as besides the Aggregate Id, we need to provide Version. To know the version we need to be aware of last previous applied Event.

    This Stream Strategy is great whenever business logic is involved that need to be protected. This solves for example the problem of concurrent access on the database level, as we for example can't store Event for same Aggregate Id and Version twice in the Event Stream. We would use it in most of business scenarios where knowing previous state in order to make the decision is needed, like:

    • Check if we can change Ticket based on status

    • Performing invocing from previous transactions

    • Decide if Order can be shipped

    Stream Per Aggregate Strategy

    This is similar to Partition strategy, however each Partition is actually stored in separate Table, instead of Single One.

    This can be used when amount of partitions is really low and volume of events within partition is huge.

    Custom Strategy

    You may provide your own Customer Persistence Strategy as long as it implements PersistenceStrategy.

    Setting global Persistence Strategy

    To set given persistence strategy as default, we can use ServiceContext:

    Multiple Persistence Strategies

    Once set, the persistence strategy will apply to all streams in your application. However, you may face a situation when you need to have a different strategy for one or more of your streams.

    The above will make the Simple Stream Strategy as default however, for some_stream Event Store will use the Aggregate Stream Strategy.

    Lifecycle Management

    PHP Event Sourcing Projection Lifecycle and CLI

    The Problem

    Your projection's table schema changed, or you found a bug in a handler and the read model has wrong data. How do you set up, tear down, and rebuild projections without writing manual SQL scripts?

    Ecotone provides lifecycle hooks — methods on your projection class that are called at specific moments — and CLI commands to trigger them.

    Lifecycle Hooks

    Initialization

    Called when the projection is first set up. Use it to create tables, indexes, or any storage structure:

    Delete

    Called when the projection is permanently removed. Clean up all storage:

    Reset

    Called when the projection needs to be rebuilt from scratch. Clear the data but keep the structure:

    After a reset, the projection's position is set back to the beginning. The next trigger will replay all events from the start.

    Flush

    Called after each batch of events is processed. Useful for flushing buffers or intermediate state:

    See for how batching and flushing work together.

    CLI Commands

    Initialize a Projection

    Delete a Projection

    Calls #[ProjectionDelete] and removes all tracking state:

    Backfill a Projection

    Populates a fresh projection with historical events. See for details:

    Rebuild a Projection (Enterprise)

    Resets the projection and replays all events. See for details:

    Automatic vs Manual Initialization

    By default, projections auto-initialize the first time an event triggers them. This means you don't need to run any CLI command — the #[ProjectionInitialization] method is called automatically.

    If you need manual control (for example, during ), you can disable auto-initialization:

    Reset and Trigger

    To rebuild a projection manually, you can reset it (clears data and position) and then trigger it (starts processing from the beginning):

    1. Reset — calls #[ProjectionReset], clears position to the beginning

    2. Trigger — starts processing events from position 0, rebuilding the entire Read Model

    This is useful when you've fixed a bug in a handler and need to reprocess all events to correct the data.

    Message Bus and CQRS

    PHP Message Bus, CQRS, Command Event Query Handlers

    Works with: Laravel, Symfony, and Standalone PHP

    The Problem

    Your service classes mix reading and writing. A single change to how orders are placed breaks the order listing page. Business rules are scattered across controllers, listeners, and services — there's no clear boundary between "what changes state" and "what reads state."

    How Ecotone Solves It

    Ecotone introduces Command Handlers for state changes, Query Handlers for reads, and Event Handlers for reactions. Each has a single responsibility, wired automatically through PHP attributes. No base classes, no framework coupling — just clear separation of concerns on top of your existing Laravel or Symfony application.


    In this chapter we will cover process of handling and dispatching Messages with Ecotone. We will discuss topics like Commands, Events and Queries, Message Handlers, Message Buses, Aggregates and Sagas. You may be interested in theory - chapter first.

    Materials

    Demo implementation

    Links

    • [Article]

    • [Article]

    • [Article]

    PHP for Enterprise Architecture

    Enterprise architecture patterns in PHP - comparing Ecotone to Spring, Axon, NServiceBus

    The Problem You Recognize

    You're a technical lead or architect evaluating whether PHP can handle enterprise-grade architecture. Your team knows PHP well, but the business is growing — you need CQRS, Event Sourcing, distributed messaging, multi-tenancy, and production resilience.

    The alternative is migrating to Java (Spring + Axon) or .NET (NServiceBus, MassTransit). That means retraining your team, rewriting your application, and losing PHP's development speed.

    Query Handling

    Query CQRS PHP

    Be sure to read before diving in this chapter.

    Handling Queries

    External Query Handlers are Services available in your dependency container, which are defined to handle Queries.

    Aggregate Introduction

    DDD Aggregates PHP

    The Problem

    You have an Order model. The rule "an order can only be paid once" lives in the OrderController::pay() action. There's another check in the Stripe webhook listener. There's a third in the admin override controller. A new developer adds a fourth code path — a console command that updates the row directly — and skips the check. Production ships double-paid orders.

    The root cause: anyone in the codebase can write $order->status = 'paid'

    Database Business Interface

    Database Business Interface for type-safe database access in PHP

    Ecotone allows to work with Database using DbalBusinessMethod. The goal is to create abstraction which significantly reduce the amount of boilerplate code required to implement data access layers. Thanks to Dbal based Business Methods we are able to avoid writing integration and transformation level code and focus on the Business part of the system. To make use of Dbal based Business Method, .

    Write Business Methods

    Let's consider scenario where we want to store new record in Persons table. To make it happen just like with Business Method we will create an Interface, yet this time we will mark it with

    Identifier Mapping

    Mapping identifiers for Aggregate and Saga routing in Ecotone

    When loading Aggregates or Sagas we need to know what Identifier should be used for that. This depending on the business feature we work may require different approaches. In this section we will dive into different solutions which we can use.

    Auto-Mapping from the Command/Event

    Ecotone resolves the mapping automatically, when Identifier in the Aggregate/Saga is named the same as the property in the Command/Event.

    then, if Message has productId, it will be used for Mapping:

    Business Workflows

    Three shapes of durable workflows in PHP — Stateless Workflows, Sagas, and Orchestrators. Pick the one that matches your process, on the database and broker you already run.

    Three Shapes of Durable Workflows

    Every multi-step business process has one of three shapes. Pick the one that matches yours — all three are durable, all three survive crashes and deploys, all three run on the database and broker you already operate.

    Shape
    Pick when

    Handling Failures

    Handling Failures and Exceptions in Sagas and Process Managers

    We may happen to face Errors on different stage of our Workflows. This may happen due to coding error and due to Network and Integration problems as well. Whatever the problem is, we want to be able to recover from that, or to be able to execute compensation action that will handle this failure.

    Ecotone provides different ways to deal with the problems and depending on the context, we may want to choose different solution.

    Uncovered Business Scenarios

    It happens that uncovered business scenario are treated as failures. This may happen for example when Customer have made two payments for the same Order and our system basically throws an Exception.

    Message Headers

    Working with Message Headers and metadata in Ecotone PHP

    Your audit interceptor needs currentUserId. The handler doesn't take it as an argument — and you don't want to thread it through every Command DTO just to satisfy an audit concern. Headers are the side-channel: pass currentUserId as a header at dispatch time and read it from any interceptor or handler downstream. They flow with the message through synchronous and asynchronous channels alike, automatically mapped through your Message Broker on the wire.

    Passing headers to Bus:

    Upgrading from V1 to V2

    Upgrading from Projection V1 to ProjectionV2

    The Problem

    You have existing projections using the old #[Projection] API and want to migrate to #[ProjectionV2]. Do you need to migrate data? Will there be downtime?

    Event Streams and Handlers

    PHP Event Sourcing Projection Streams and Event Handlers

    The Problem

    Your projection needs data from multiple aggregates — orders AND payments — or you want to handle only specific events instead of everything in the stream. How do you control what events reach your projection and how they are routed?

    Repository
    Going into CQRS with PHP [Article]
  • Event Handling in PHP [Article]

  • DDD and CQRS
    Dispatching and handling Commands
    Dispatching and handling Events
    Business Interface
    Build Symfony and Doctrine ORM Applications with ease
    Build Laravel Application using DDD and CQRS
    DDD and Message based communication with Laravel
    Error Channel and Dead Letter
    Outbox Pattern
    Idempotency (Deduplication)
    Command Bus Instant Retries
    Command Bus Error Channel
    Gateway-Level Deduplication
    Orchestrators
    Document Store
    Making your application stable with Outbox Pattern
    Handling asynchronous errors
    Handling Failures in Workflows

    composer require ecotone/symfony-bundle

    By default Ecotone will look for Attributes in default Symfony catalog "src". If you do follow different structure, you can use "namespaces" configuration to tell Ecotone, where to look for.

    composer require ecotone/laravel

    By default Ecotone will look for Attributes in default Laravel catalog "app". If you do follow different structure, you can use "namespaces" configuration to tell Ecotone, where to look for.

    composer require ecotone/ecotone

    In order to start, you need to have a composer.json with PSR-4 or PSR-0 autoload setup.

    composer require ecotone/lite-application

    With default configuration, Ecotone will look for classes inside "src" catalog.

    As Ecotone never forces to use framework specific classes in your business code, you may replace it with your own implementation.

    Using recordThat will delay sending an event till the moment your Aggregate is saved in the Repository. This way you ensure that no Event Handlers will be called before the state is actually stored.

    For more sophisticated scenarios, where there is no direct identifier in corresponding event, you may use of identifier mapping. You can read about it more in Saga related section.

    When splitting your aggregates into the smallest, independent parts of the domain you have to be aware of transaction boundaries which Aggregate has to protect. In the case where the creation of an Aggregate is the transaction boundary of another Aggregate, it may require a state change of the one that protects that boundary.

    This is a very specific scenario where two aggregates will persist at the same time within the same transaction which is covered by Ecotone.

    To make use of this Business Interface, we need our Aggregate Repository being registered.

    Implementation will be delivered by Ecotone. All you need to do is to define the interface and it will available in your Dependency Container

    Instant Fetch Aggregate is available as part of Ecotone Enterprise.

    Business Interface
    expression language

    From lower level API Business Method is actually a Message Gateway.

    We may of course pass Command class if we need to pass more data to our Aggregate's Command Handler.

    Such conversion are useful in order to work with objects and to avoid writing transformation code in our business code. We can build generic queries, and transform them to different classes using different business methods.

    This type of conversion is especially useful, when we receive data from external source and we simply want to push it to given Message Handler. We avoid doing transformations ourselves, as we simply push what we receive as array.

    Converter
    Converter
    Working with Event Streams
    next section
    Making Stream immune to changes
    customize
    Snapshoting mechanism
    inbuilt projection system

    When this persistence strategy is used with Ecotone's Aggregate, Ecotone resolve metadata part on his own, therefore working with this Stream becomes easy. However when working directly with Event Store getting the context may involve extra work.

    This is the default persistence strategy used whenever we don't specify otherwise.

    Take under consideration that each aggregate instance will have separate table. When this strategy is used with a lot of Aggregate instance, the volume of tables in the database may become hard to manage.

    Be aware that we won't be able to set Custom Strategy that way.

    By default, projections auto-initialize on the first event trigger. You don't need to run initialization manually unless you use #[ProjectionDeployment(manualKickOff: true)].

    The rebuild command is available as part of Ecotone Enterprise.

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

    Execution Modes
    Backfill and Rebuild
    Backfill and Rebuild
    blue-green deployments
    Queries are Plain Old PHP Objects:

    To send an Query we will be using send method on QueryBus. Query will be delivered to corresponding Query Handler.

    Sending with Routing

    Just like with Commands, we may use routing in order to execute queries:

    To send an Query we will be using sendWithRouting method on QueryBus. Query will be delivered to corresponding Query Handler.

    Converting result from Query Handler

    If you have registered Converter for specific Media Type, then you can tell Ecotone to convert result of your Query Bus to specific format. In order to do this, we need to make use of Metadataand replyContentType header.

    C

    CQRS Introduction
    Subscribing to Event Streams

    Every Projection needs to declare which Event Streams it reads from. This tells Ecotone where to fetch events when the Projection is triggered.

    From Aggregate Stream (Recommended)

    The most common case — subscribe to all events from a single aggregate type using #[FromAggregateStream]:

    #[FromAggregateStream(Ticket::class)] automatically resolves both the stream name and the aggregate type from the Ticket class. This enables Ecotone to use the correct database indexes for fast event loading.

    From Multiple Aggregate Streams

    When your Read Model combines data from multiple aggregates, use multiple #[FromAggregateStream] attributes:

    The Projection will process events from both streams, ordered by when they were stored.

    From a Named Stream

    In some cases you may need to specify the stream name directly — for example when the aggregate class has been deleted or when targeting a custom stream name. Use #[FromStream] for this:

    Event Handler Routing

    Ecotone routes events to the correct handler method. You have several options for controlling how this works.

    By Type Hint (Default)

    The simplest approach — Ecotone routes based on the event class in the method signature:

    Named Events

    If your events use #[NamedEvent] to decouple the stored event name from the PHP class name:

    You can still type-hint your handler with the class — Ecotone automatically resolves the #[NamedEvent] mapping:

    You can also subscribe by name explicitly, which is useful when you don't have (or don't want to import) the event class:

    Catch-All Handler

    To receive every event in the stream regardless of type:

    Using Array Payload for Performance

    When handling events by name, you can accept the raw array payload instead of a deserialized object. This skips deserialization and can significantly speed up processing — especially useful during backfill or rebuild with large event volumes:

    What's Next

    Instead of writing raw SQL in your projections, you can use Ecotone's Document Store for automatic serialization and storage — especially useful for rapid prototyping and simpler Read Models.

    Always prefer #[FromAggregateStream] when your aggregate class is available. It ensures optimal performance by providing the aggregate type metadata that enables indexed queries on the Event Store.

    When using #[FromStream], always provide the aggregateType parameter. Without it, Ecotone cannot use the aggregate type index on the Event Store, resulting in significantly slower event loading — especially on large streams.

    You don't need to match the event name manually in #[EventHandler('ticket.registered')]. As long as the event class has #[NamedEvent], type-hinting the class is enough — Ecotone handles the routing for you.

    Using array payloads avoids the cost of deserializing event objects. When rebuilding a projection with thousands of events, this can make a noticeable difference in processing time.

    Ecotone\SymfonyBundle\EcotoneSymfonyBundle::class => ['all' => true]
    php bin/console ecotone:list
    'providers' => [
        \Ecotone\Laravel\EcotoneProvider::class
    ],
    php artisan ecotone:list
    $ecotoneLite = EcotoneLite::bootstrap(
        classesToResolve: [User::class, UserRepository::class, UserService::class],
        containerOrAvailableServices: $container
    );
    $ecotoneLite = EcotoneLite::bootstrap(
        classesToResolve: [User::class, UserRepository::class, UserService::class],
        containerOrAvailableServices: $container,
        configuration: ServiceConfiguration::createWithDefaults()->withNamespaces(['App'])
    );
    $ecotoneLite = EcotoneLite::bootstrap(
        classesToResolve: [User::class, UserRepository::class, UserService::class],
        containerOrAvailableServices: [new UserRepository(), new UserService()]
    );
    $ecotoneLite = EcotoneLiteApplication::bootstrap();
    
    $commandBus = $ecotoneLite->getCommandBus();
    $queryBus = $ecotoneLite->getQueryBus();
    #[Aggregate]
    class Ticket
    {
        // Import trait with recordThat method
        use WithEvents;
    
        #[Identifier]
        private Uuid $ticketId;
        private string $description;
        private string $assignedTo;
           
        #[CommandHandler]
        public function changeTicket(ChangeTicket $command): void
        {
            $this->description = $command->description;
            $this->assignedTo = $command->assignedTo;
            
            // Record the event
            $this->recordThat(new TicketWasChanged($this->ticketId));
        }
    }
    #[Aggregate]
    class Promotion
    {
        #[Identifier]
        private Uuid $userId;
        private bool $isActive;
           
        #[EventHandler]
        public function stop(AccountWasClosed $event): void
        {
            $this->isActive = false;
        }
    }
    class readonly AccountWasClosed
    {
        public function __construct(public Uuid $userId) {}
    }
    #[NamedEvent('order.placed')]
    final readonly class OrderWasPlaced
    {
        public function __construct(
            public string $orderId,
            public string $productId
        ) {}
    }
    #[EventHandler(listenTo: "order.placed")]
    public function notify(#[Header("executoId")] $executorId): void
    {
        // notify user that the Order was placed
    }
    #[Aggregate]
    final class Calendar
    {
        /** @var array<string> */
        private array $meetings = [];
    
        public function __construct(#[Identifier] public string $calendarId) 
        {
        }
    
        #[CommandHandler]
        public function scheduleMeeting(ScheduleMeeting $command): Meeting
        {
            // checking business rules
    
            $this->meetings[] = $command->meetingId;
    
            return new Meeting($command->meetingId);
        }
    }
    
    #[Aggregate]
    final class Meeting
    {
        public function __construct(#[Identifier] public string $meetingId) 
        {
        }
    }
    #[Aggregate]
    final class Calendar
    {
        /** @var array<string> */
        private array $meetings = [];
    
        public function __construct(#[Identifier] public string $calendarId) 
        {
        }
    
        #[CommandHandler]
        public function scheduleMeeting(ScheduleMeeting $command): Meeting
        {
            // checking business rules
    
            $this->meetings[] = $command->meetingId;
    
            return Meeting::create($command->meetingId);
        }
    }
    
    #[EventSourcingAggregate(true)]
    final class Meeting
    {
        use WithEvents;
        use WithAggregateVersioning;
        
        #[Identifier]
        public string $meetingId;
    
        public static function create(string $meetingId): self
        {
            $meeting = new self();
            $meeting->recordThat(new MeetingCreated($meetingId));
            
            return $meeting;
        }
    }
    interface OrderRepository
    {
        #[Repository]
        public function getOrder(string $twitId): Order;
    
        #[Repository]
        public function findOrder(string $twitId): ?Order;
    
        #[Repository]
        public function save(Twitter $twitter): void;
    }
    interface OrderRepository
    {
        #[Repository]
        public function getOrder(string $twitId): Order;
    
        #[Repository]
        public function findOrder(string $twitId): ?Order;
    
        #[Repository]
        #[RelatedAggregate(Order::class)]
        public function save(string $aggregateId, int $currentVersion, array $events): void;
    }
    #[CommandHandler]
    public function placeOrder(
        PlaceOrder $command,
        #[Fetch("payload.userId")] User $user
    ): void {
        // do something    
    }
    class readonly PlaceOrder
    {
        public function __construct(
            public string $orderId,
            public string $userId,
            public string $productId
        ) {
        }
    #[CommandHandler]
    public function placeOrder(
        PlaceOrder $command,
        #[Fetch("payload.userId")] ?User $user // we marked it as possible null
    ): void {
        // do something    
    }
    #[CommandHandler]
    public function placeOrder(
        PlaceOrder $command,
        #[Fetch("headers['userId']")] User $user
    ): void {
        // do something    
    }
    #[CommandHandler]
    public function placeOrder(
        PlaceOrder $command,
        #[Fetch("reference('emailToIdMapper').map(payload.email)")] User $user
    ): void {
        // do something    
    }
    class TicketService
    {
        #[CommandHandler("ticket.create")] 
        public function createTicket(CreateTicketCommand $command) : void
        {
            // handle create ticket command
        }
    }
    interface TicketApi
    {
        #[BusinessMethod('ticket.create')]
        public function create(CreateTicketCommand $command): void;
    }
    #[Aggregate]
    class Ticket
    {
        #[Identifier]
        private Uuid $ticketId;
        private bool $isClosed;
           
        #[CommandHandler("ticket.close")]
        public function close(): void
        {
            $this->isClosed = true;
        }
    }
    interface TicketApi
    {
        #[BusinessMethod('ticket.close')]
        public function create(#[Identifier] Uuid $ticketId): void;
    }
    class TicketService
    {
        #[QueryHandler("ticket.get_by_id")] 
        public function getTicket(GetTicketById $query) : array
        {
            //return ticket
        }
    }
    interface TicketApi
    {
        #[BusinessMethod("ticket.get_by_id")]
        public function getTicket(GetTicketById $query): array;
    }
    class TicketService
    {
        #[QueryHandler("ticket.get_by_id")] 
        public function getTicket(GetTicketById $query) : array
        {
            //return ticket as array
        }
    }
    interface TicketApi
    {
        // return ticket as Class
        #[BusinessMethod("ticket.get_by_id")]
        public function getTicket(GetTicketById $query): TicketDTO;
    }
    class TicketService
    {
        #[CommandHandler("ticket.create")] 
        public function getTicket(CreateTicket $command) : void
        {
    
        }
    }
    interface TicketApi
    {
        #[BusinessMethod("ticket.create")]
        public function getTicket(array $query): void;
    }
    #[Version]
    private int $version = 0;
    #[EventSourcingAggregate]
    class Product
    {
        use WithAggregateVersioning;
    #[Identifier]
    private string $id;
    #[EventSourcingHandler]
    public function applyProductWasCreated(ProductWasCreated $event) : void
    {
        $this->id = $event->id;
    }
    #[CommandHandler]
    public static function create(CreateProduct $command) : array
    {
        return [new ProductWasCreated($command->id, $command->name, $command->price)];
    }
    $eventStore->appendTo(
        Product::class, // Stream name
        [
            Event::create(
                $event,
                metadata: [
                    '_aggregate_id' => 1,
                    '_aggregate_version' => 1,
                    '_aggregate_type' => Product::class,
                ]
            )
        ]
    );
    #[Repository]
    class CustomEventSourcingRepository
    public function save(
        array $identifiers, 
        string $aggregateClassName, 
        array $events, 
        array $metadata, 
        int $versionBeforeHandling
    ): void;
    class Event
    {
        private function __construct(
            private string $eventName, // either class name or name of the event
            private object $payload, // event object instance
            private array $metadata // related metadata
        )
    }
    #[EventSourcingAggregate]
    #[AggregateType("basket")]
    class Basket
    #[NamedEvent("order_was_placed")]
    class OrderWasPlaced
    #[ServiceContext]
    public function configuration()
    {
        return BaseEventSourcingConfiguration::withDefaults()
                ->withSnapshotsFor(Basket::class, thresholdTrigger: 100);
    }
    public function findBy(
        string $aggregateClassName, 
        array $identifiers, 
        int $fromAggregateVersion = 1
    ) 
    $repository = new CustomEventSourcingRepository;
    $ecotoneLite = EcotoneLite::bootstrapFlowTesting(
        [OrderAggregate::class, CustomEventSourcingRepository::class],
        [CustomEventSourcingRepository::class => $repository],
        addInMemoryEventSourcedRepository: false,
    );
    
    $ecotoneLite->sendCommand(new PlaceOrder());
    
    $this->assertNotEmpty($repository->getEvents());
    $eventStore->create($streamName, streamMetadata: [
        "_persistence" => 'simple',
    ]);
    
    $eventStore->appendTo(
        $streamName,
        [
            Event::create(
                payload: new TicketWasRegistered('123', 'Johnny', 'alert'),
                metadata: [
                    'executor' => 'johnny',
                ]
            )
        ]
    );
    $eventStore->create($streamName, streamMetadata: [
        "_persistence" => 'partition',
    ]);
    $eventStore->appendTo(
        $streamName,
        [
            Event::create(
                new TicketWasRegistered('123', 'Johnny', 'alert'),
                [
                    '_aggregate_id' => 123,
                    '_aggregate_version' => 1,
                    '_aggregate_type' => 'ticket',
                ]
            )
        ]
    );
    $eventStore->create($streamName, streamMetadata: [
        "_persistence" => 'aggregate',
    ]);
    $eventStore->appendTo(
        $streamName,
        [
            Event::create(
                new TicketWasRegistered('123', 'Johnny', 'alert'),
                [
                    '_aggregate_id' => 123,
                    '_aggregate_version' => 1,
                    '_aggregate_type' => 'ticket',
                ]
            )
        ]
    );
    #[ServiceContext]
    public function aggregateStreamStrategy()
    {
        return EventSourcingConfiguration::createWithDefaults()
            ->withCustomPersistenceStrategy(new CustomStreamStrategy(new FromProophMessageToArrayConverter()));
    }
    #[ServiceContext]
    public function persistenceStrategy()
    {
        return EventSourcingConfiguration::createWithDefaults()
            ->withSimpleStreamPersistenceStrategy();
    }
    #[ServiceContext]
    public function eventSourcingConfiguration(): EventSourcingConfiguration
    {
        return EventSourcingConfiguration::createWithDefaults()
            ->withPersistenceStrategyFor('some_stream', LazyProophEventStore::AGGREGATE_STREAM_PERSISTENCE)
        ;
    }
    #[ProjectionInitialization]
    public function init(): void
    {
        $this->connection->executeStatement(<<<SQL
            CREATE TABLE IF NOT EXISTS ticket_list (
                ticket_id VARCHAR(36) PRIMARY KEY,
                ticket_type VARCHAR(25),
                status VARCHAR(25)
            )
        SQL);
    }
    #[ProjectionDelete]
    public function delete(): void
    {
        $this->connection->executeStatement('DROP TABLE IF EXISTS ticket_list');
    }
    #[ProjectionReset]
    public function reset(): void
    {
        $this->connection->executeStatement('DELETE FROM ticket_list');
    }
    #[ProjectionFlush]
    public function flush(): void
    {
        // Called after each batch commit
        // Useful for clearing caches, flushing buffers, etc.
    }
    bin/console ecotone:projection:init ticket_list
    # Or initialize all projections at once:
    bin/console ecotone:projection:init --all
    artisan ecotone:projection:init ticket_list
    # Or initialize all:
    artisan ecotone:projection:init --all
    $messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'ticket_list']);
    bin/console ecotone:projection:delete ticket_list
    artisan ecotone:projection:delete ticket_list
    $messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'ticket_list']);
    bin/console ecotone:projection:backfill ticket_list
    artisan ecotone:projection:backfill ticket_list
    $messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'ticket_list']);
    bin/console ecotone:projection:rebuild ticket_list
    artisan ecotone:projection:rebuild ticket_list
    $messagingSystem->runConsoleCommand('ecotone:projection:rebuild', ['name' => 'ticket_list']);
    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    #[ProjectionDeployment(manualKickOff: true)]
    class TicketListProjection
    {
        // Won't auto-initialize — requires explicit CLI init
    }
    class TicketController
    {
       // Query Bus will be auto registered in Depedency Container.
       public function __construct(private QueryBus $queryBus) {}
       
       public function createTicketAction(Request $request) : Response
       {
          $result = $this->queryBus->send(
             new GetTicketById(
                $request->get("ticketId")            
             )
          );
          
          return new Response(\json_encode($result));
       }
    }
    $ticket = $messagingSystem->getQueryBus()->send(
      new GetTicketById(
        $ticketId            
      )
    );
    class TicketController
    {
       public function __construct(private QueryBus $queryBus) {}
       
       public function createTicketAction(Request $request) : Response
       {
          $result = $this->queryBus->sendWithRouting(
             "ticket.getById",
             $request->get("ticketId")            
          );
          
          return new Response(\json_encode($result));
       }
    }
    $ticket = $messagingSystem->getQueryBus()->sendWithRouting(
       "ticket.getById",
       $ticketId            
    );
    class TicketController
    {
       public function __construct(private QueryBus $queryBus) {}
       
       public function createTicketAction(Request $request) : Response
       {
          $result = $this->queryBus->sendWithRouting(
             "ticket.getById",
             $request->get("ticketId"),
             // Tell Ecotone which format you want in return
             expectedReturnedMediaType: "application/json"            
          );
          
          return new Response($result);
       }
    }
    $ticket = $messagingSystem->getQueryBus()->sendWithRouting(
       "ticket.getById",
       $ticketId,
       expectedReturnedMediaType: "application/json"            
    );
    class TicketService
    {
        #[QueryHandler] 
        public function getTicket(GetTicketById $query) : array
        {
            //return ticket
        }
    }
    class readonly GetTicketById
    {
        public function __construct(
            public string $ticketId
        ) {}
    }
    class TicketService
    {
        #[QueryHandler("ticket.getById")] 
        public function getTicket(string $ticketId) : array
        {
            //return ticket
        }
    }
    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    class TicketListProjection
    {
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            // handle event
        }
    }
    #[ProjectionV2('calendar_overview')]
    #[FromAggregateStream(Calendar::class)]
    #[FromAggregateStream(Meeting::class)]
    class CalendarOverviewProjection
    {
        #[EventHandler]
        public function onCalendarCreated(CalendarWasCreated $event): void
        {
            // handle calendar event
        }
    
        #[EventHandler]
        public function onMeetingScheduled(MeetingWasScheduled $event): void
        {
            // handle meeting event
        }
    }
    #[ProjectionV2('legacy_tickets')]
    #[FromStream(stream: 'ticket_stream', aggregateType: 'App\Domain\Ticket')]
    class LegacyTicketProjection
    {
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            // handle event from explicitly named stream
        }
    }
    #[EventHandler]
    public function onTicketRegistered(TicketWasRegistered $event): void
    {
        // Only called for TicketWasRegistered events
    }
    #[NamedEvent('ticket.registered')]
    class TicketWasRegistered
    {
        public function __construct(
            public readonly string $ticketId,
            public readonly string $type
        ) {}
    }
    #[EventHandler]
    public function onTicketRegistered(TicketWasRegistered $event): void
    {
        // Works automatically — Ecotone knows TicketWasRegistered maps to 'ticket.registered'
    }
    #[EventHandler('ticket.registered')]
    public function onTicketRegistered(array $event): void
    {
        // Subscribe by name, receive raw array — no class dependency needed
    }
    #[EventHandler('*')]
    public function onAnyEvent(array $event): void
    {
        // Called for every event in the stream
    }
    #[EventHandler('ticket.registered')]
    public function onTicketRegistered(array $event): void
    {
        // $event is the raw array — no deserialization overhead
        $ticketId = $event['ticketId'];
    }
    PHP Has Grown Up

    PHP is no longer just for simple web applications. Modern PHP (8.1+) has union types, enums, fibers, readonly properties, and first-class attributes. Frameworks like Laravel and Symfony provide the web layer. What was missing was the architecture layer that lets a system grow from first command handler to event-sourced microservices without rewrites — the equivalent of what Spring Integration and NServiceBus provide in their ecosystems.

    Ecotone fills that gap. Built on the same Enterprise Integration Patterns that underpin Spring Integration, NServiceBus, and Apache Camel, Ecotone brings decades-proven architectural patterns to PHP as attribute-driven code on your existing Laravel or Symfony application.

    How Ecotone Compares

    Capability
    Java (Axon)
    .NET (NServiceBus)
    PHP (Ecotone)

    CQRS

    Yes

    Yes

    Yes

    Event Sourcing

    Yes

    What You Get With Ecotone

    • Enterprise Integration Patterns as the foundation — not a custom abstraction

    • Framework integration — works on top of Laravel and Symfony, not replacing them

    • Attribute-driven configuration — PHP 8 attributes instead of XML or YAML

    • Production resilience — retries, error channels, dead letter, outbox, deduplication

    • Full testing support — test message flows, aggregates, sagas, and event sourcing in isolation

    • Observability — OpenTelemetry integration for tracing and metrics

    • Multi-tenancy — built-in support for tenant-isolated processing

    Next Steps

    • Why Ecotone? — Detailed positioning and integration story

    • Installation — Get started in 5 minutes

    • Enterprise Features — Advanced capabilities for scaling teams

    • — Hands-on learning path

    or
    $order->setStatus('paid')
    . There's no enforcement boundary. The invariant is whatever the most-careful caller remembered to check.

    How Ecotone Solves It

    An Aggregate is a class where the only way to mutate state is through #[CommandHandler] methods. Those methods are the rules. There's no setStatus() because the rule is: "to mark an order as paid, send a MarkOrderAsPaid command, and the aggregate decides whether that's allowed." The webhook, the controller, the console command — they all go through the same path. The invariant lives in one method and cannot be bypassed.

    Ecotone handles loading the aggregate from your repository, routing the command to the right method, and saving the result. You write the business logic; the framework handles the plumbing.


    This chapter covers the basics of implementing an Aggregate. We will use Command Handlers, so first read the External Command Handler section to understand how Commands are sent and handled.

    Aggregate Command Handlers

    Working with Aggregate Command Handlers is the same as with External Command Handlers — mark a method with #[CommandHandler] and Ecotone registers it. The difference is that Aggregate handlers are instance methods (or static factory methods), and Ecotone takes care of fetching the aggregate, calling the method, and saving the result:

    With Ecotone, you write only the middle line — declared as a method on the aggregate itself.

    By providing the #[Identifier] attribute, you tell Ecotone which property identifies this Aggregate (an Entity in Symfony/Doctrine, a Model in Laravel). Ecotone uses it to fetch the aggregate before each command.

    When you send a Command, Ecotone reads the property with the matching name from the Command and uses it to load the Aggregate.

    Once the identifier resolves, Ecotone uses the configured Repository to fetch the aggregate, call the method, and save the result.

    State-Stored Aggregate

    An Aggregate is a regular object that owns state and the methods that change it. Instead of public setters, it exposes #[CommandHandler] methods — each one is a business operation that enforces its own invariants:

    1. #[Aggregate] tells Ecotone this class is an Aggregate Root.

    2. #[Identifier] marks the external reference point. Ecotone uses it to load the aggregate when a command arrives.

    3. A #[CommandHandler] on a static method is a factory — it must return a new aggregate instance. The constructor stays private; the factory is the only way to create the aggregate.

    4. A #[CommandHandler] on an instance method is a business operation. The aggregate is fetched, the method runs, and the result is saved. Note there's no setStatus() — the only path from PLACED to PAID is through pay(), which enforces the rule that an order cannot be paid twice.

    The webhook listener, the admin controller, the retry job — they all do the same thing: send a MarkOrderAsPaid command. The rule lives once, in Order::pay(), and the rest of the codebase cannot work around it.

    Works with: Laravel, Symfony, and Standalone PHP

    // Without Ecotone — the same three lines repeated in every handler:
    $product = $this->repository->getById($command->id());
    $product->changePrice($command->getPriceAmount());
    $this->repository->save($product);
    #[Aggregate]
    class Product
    {
        #[Identifier]
        private string $productId;
    class ChangePriceCommand
    {
        private string $productId; // same property name as Aggregate's Identifier
        private Money $priceAmount;
    #[Aggregate] // 1
    class Order
    {
        #[Identifier] // 2
        private string $orderId;
    
        private OrderStatus $status;
        private Money $amount;
    
        private function __construct(string $orderId, Money $amount)
        {
            $this->orderId = $orderId;
            $this->amount = $amount;
            $this->status = OrderStatus::PLACED;
        }
    
        #[CommandHandler] // 3
        public static function place(PlaceOrder $command): self
        {
            if ($command->amount->isNegativeOrZero()) {
                throw new InvalidOrderAmount();
            }
    
            return new self($command->orderId, $command->amount);
        }
    
        #[CommandHandler] // 4
        public function pay(MarkOrderAsPaid $command, PaymentGateway $gateway): void
        {
            if ($this->status === OrderStatus::PAID) {
                throw new OrderAlreadyPaid($this->orderId);
            }
            if ($this->status === OrderStatus::CANCELLED) {
                throw new CannotPayCancelledOrder();
            }
    
            $gateway->charge($this->amount, $command->paymentMethod);
            $this->status = OrderStatus::PAID;
        }
    }

    For more advanced scenarios — mapping by expression, multiple identifiers, or computed identifiers — see .

    You don't need to implement a Repository yourself. Ecotone provides built-in , , and integrations with and . See the for details.

    DbalBusinessMethod
    .

    The first parameter passed to DbalBusinessMethod is actual SQL, where we can provide set of named parameters. Ecotone will automatically bind parameters from method declaration to SQL ones by names.

    Custom Parameter Name

    We may bind parameter name explicitly by using DbalParameter attribute.

    This can be used when we want to decouple interface parameter names from binded parameters or when name in database column is not explicit enough for being part of interface.

    Returning number of records changed

    If we want to return amount of the records that have been changed, we can add int type hint to our Business Method:

    Query Business Methods

    We may want to fetch data from the database and for this we will be using DbalQueryBusinessMethod.

    The above will return result as associative array with the columns provided in SELECT statement.

    Fetching Mode

    To format result differently we may use different fetch modes. The default fetch Mode is associative array.

    First Column Fetch Mode

    This will extract the first column from each row, which allows us to return array of person Ids directly.

    First Column of first row Mode

    To get single variable out of Result Set we can use First Column of first row Mode.

    This way we can provide simple interfaces for things Aggregate SQLs, like SUM or COUNT.

    First Row Mode

    To fetch first Row of given Result Set, we can use First Row Mode.

    This will return array containing person_id and name.

    Returning Nulls

    When using First Row Mode, we may end up having no returned row at all. In this situation Dbal will return false, however if Return Type will be nullable, then Ecotone will convert false to null.

    Returning Iterator

    For big result set we may want to avoid fetching everything at once, as it may consume a lot of memory. In those situations we may use Iterator Fetch Mode, to fetch one by one.

    Parameter Types

    Each parameter may have different type and Ecotone will try to recognize specific type and set it up accordingly. If we want, we can take over and define the type explicitly.

    install Dbal Module first
    interface PersonApi
    {
        #[DbalWrite("INSERT INTO persons VALUES (:personId, :name)")]
        public function register(int $personId, string $name): void;
    }
    #[DbalWrite('INSERT INTO persons VALUES (:personId, :name)')]
    public function register(
        #[DbalParameter(name: 'personId')] int $id,
        string $name
    ): void;
    #[DbalWrite('UPDATE persons SET name = :name WHERE person_id = :personId')]
    public function changeName(int $personId, string $name): int;
    interface PersonApi
    {
        #[DbalQueryMethod('SELECT person_id, name FROM persons LIMIT :limit OFFSET :offset')]
        public function getNameList(int $limit, int $offset): array;
    }
    /**
     * @return int[]
     */
    #[DbalQuery(
        'SELECT person_id FROM persons ORDER BY person_id ASC LIMIT :limit OFFSET :offset',
        fetchMode: FetchMode::FIRST_COLUMN
    )]
    public function getPersonIds(int $limit, int $offset): array;
    #[DbalQuery(
        'SELECT COUNT(*) FROM persons',
        fetchMode: FetchMode::FIRST_COLUMN_OF_FIRST_ROW
    )]
    public function countPersons(): int;
    #[DbalQuery(
        'SELECT person_id, name FROM persons WHERE person_id = :personId',
        fetchMode: FetchMode::FIRST_ROW
    )]
    public function getNameDTO(int $personId): array;
    #[DbalQuery(
        'SELECT person_id, name FROM persons WHERE person_id = :personId',
        fetchMode: FetchMode::FIRST_ROW
    )]
    public function getNameDTOOrNull(int $personId): PersonNameDTO|null;
    #[DbalQuery(
        'SELECT person_id, name FROM persons ORDER BY person_id ASC',
        fetchMode: FetchMode::ITERATE
    )]
    public function getPersonIdsIterator(): iterable;
    #[DbalQuery('SELECT * FROM persons WHERE person_id IN (:personIds)')]
    public function getPersonsWith(
        #[DbalParameter(type: ArrayParameterType::INTEGER)] array $personIds
    ): array;

    Above example will use DbalConnectionFactory::class for database Connection, which is the default for . If you want to run Business Method on different connection, you can do it using connectionReferenceName parameter inside the Attribute.

    Expose Identifier using Method

    We may also expose identifier over public method by annotating it with attribute IdentifierMethod("productId").

    Targeting Identifier from Event/Command

    If the property name is different than Identifier in the Aggregate/Saga, we need to give Ecotone a hint, how to correlate identifiers. We can do that using TargetIdentifier attribute, which states to which Identifier given property references too:

    Targeting Identifier from Metadata

    When there is no property to correlate inside Command or Event, we can use Identifier from Metadata. When we've the identifier inside Metadata then we can use identifierMetadataMapping. Suppose the orderId identifier is available in metadata under key orderNumber, then we can then use this mapping:

    Dynamic Identifier

    We may provide Identifier dynamically using Command Bus. This way we can state explicitly what Aggregate/Saga instance we do refer too. Thanks to we don't need to define Identifier inside the Command and we can skip any kind of mapping.

    In some scenario we won't be in deal to create an Command class at all. For example we may provide block user action, which changes the status:

    Advanced Identifier Mapping

    There may be cases where more advanced mapping may be needed. In those cases we can use identifier mapping based on Expression Language.

    When using identifierMapping configuration, we get access to the Message fully and to Dependency Container. To access specific part we will be using:

    • payload -> Represents our Event/Command class

    • headers -> Represents our Message's metadata

    • reference('name') -> Allow to access given service from our Dependency Container

    1. Suppose the orderId identifier is available in metadata under key orderNumber, then we can tell Message Handler to use this mapping:

    1. Suppose our Identifier is an Email object within Command class and we would like to normalize before it's used for fetching the Aggregate/Saga:

    1. Suppose we receive external order id, however we do have in database our internal order id that should be used as Identifier. We could then have a Service registered in DI under "orderIdExchange":

    Then we can make use of it in our identifier Mapping

     #[Aggregate]
    class Product
    {
        #[Identifier]
        private string $productId;
    class ChangePriceCommand
    {
        private string $productId;
        private Money $priceAmount;
    }

    You may use multiple aggregate identifiers or identifier as objects (e.g. Uuid) as long as they provide __toString method

    #[Aggregate]
    class Product
    {
        private string $id;
        
        #[IdentifierMethod("productId")]
        public function getProductId(): string
        {
            return $this->id;
        }
    class SomeEvent
    {
        #[TargetIdentifier("orderId")] 
        private string $purchaseId;
    }
    #[EventHandler(identifierMetadataMapping: ["orderId" => "orderNumber"])]
    public function failPayment(PaymentWasFailedEvent $event, CommandBus $commandBus) : self 
    {
       // do something with $event
    }
    $this->commandBus->sendWithRouting('user.block', metadata:
        'aggregate.id' => $userId // This way we provide dynamic identifier
    ])
    #[CommandHandler('user.block')]
    public function block() : void
    {
        $this->status = 'blocked';
    }
    #[EventHandler(identifierMapping: ["orderId" => "headers['orderNumber']"])]
    public function failPayment(PaymentWasFailedEvent $event, CommandBus $commandBus) : void 
    {
       // do something with $event
    }
    class BlockUser
    {
        private Email $email;
        
        (...)
        
        public function getEmail(): Email
        {
            return $this->email;
        }
    }
    #[CommandHandler(identifierMapping: [
       "email" => "payload.getEmail().normalize()"]
    )]
    public function block(BlockUser $command) : void
    {
       // do something with $command
    }
    class OrderIdExchange
    {
        public function exchange(string $externalOrderId): string
        {
            // do the mapping
            
            return $internalOrderId;
        }
    }
    #[EventHandler(identifierMapping: [
       "orderId" => "reference('orderIdExchange').exchange(payload.externalOrderId())"
    ])]
    public function when(OrderCancelled $event) : void
    {
       // do something with $event
    }

    We can make use of Before or Presend to enrich event's metadata with required identifiers.

    Event so we are using "aggregate.id" in the metadata, this will work exactly the same for Sagas. Therefore if we want to trigger Message Handler on the Saga, we can use "aggregate.id" too.

    Durability mechanism
    Where state lives

    Stateless Workflow

    Linear pipeline; the message carries everything between steps

    Channel + outbox redelivery; idempotent handlers

    The message in flight

    Saga

    State must persist across events arriving over time (hours, days, weeks)

    Event-keyed persistence (DB), optional event sourcing for full replay

    Your DB, rehydrated per #[Identifier] on the next event arrival

    Orchestrator (Enterprise)

    The step list itself is the workflow — possibly dynamic per input

    Routing slip on the channel; per-step channel redelivery


    Stateless Workflows — when the message is the state

    Use when steps are tightly linear (verify → charge → ship → notify), nothing needs to be remembered between steps, and the message itself carries the state from one handler to the next. Handlers are chained through outputChannelName; durability comes from the channel (outbox + redelivery), not from a persisted record.

    → Deep dive: Connecting Handlers with Channels


    Sagas — when state must persist between events

    Use when the process spans events arriving over time and you need to remember what already happened: payment received → wait → ship → notify, with hours or days between steps. A #[Saga] is a plain PHP class with state, an #[Identifier], and event handlers. State is persisted per identifier; on the next event arrival, Ecotone rehydrates the saga from your database.

    For full replay semantics — every state transition recorded as an event in your own database, the same durability model Temporal uses internally — use #[EventSourcingSaga] instead. See Durable Execution in PHP for the side-by-side with Temporal.

    → Deep dive: Sagas


    Orchestrators — when the step list is the workflow (Enterprise)

    Use when the workflow definition belongs in one place that a business stakeholder can read, when steps need to be reusable across multiple workflows, or when the step list is chosen dynamically from input (digital vs physical fulfillment, premium vs standard). The #[Orchestrator] method returns the channel list; each step is an independently testable #[InternalHandler].

    → Deep dive: Orchestrators


    How to Choose

    1. Does any state need to survive between message arrivals — does the next step depend on something that happened hours or days earlier? → Saga. (Event-sourced if you want full replay and projections over the workflow's history.)

    2. Is the step list itself the value — written in one place a stakeholder reads, or chosen dynamically per input? → Orchestrator.

    3. Linear pipeline where each step's output is the next step's input, and the message carries everything? → Stateless Workflow.

    The three are not exclusive — a Saga can dispatch into a Stateless Workflow, an Orchestrator step can publish events that drive a Saga. Pick the primary shape of the process; compose the rest around it.

    Materials

    Links

    • Building workflows in PHP using Orchestrator [Article]

    • Building workflows in PHP with pipe and filter architecture [Article]

    • Durable Execution in PHP — when "durable workflows" or Temporal comes up

    Works with: Laravel, Symfony, and Standalone PHP

    Building durable workflows in PHP? Start with Durable Execution in PHP — the three workflow shapes Ecotone supports, the trade-offs between them, and the code side-by-side.

    #[CommandHandler(routingKey: 'order.place', outputChannelName: 'order.verify_payment')]
    public function placeOrder(PlaceOrder $command): OrderData { /* ... */ }
    
    #[Asynchronous('async')]
    #[InternalHandler(inputChannelName: 'order.verify_payment', outputChannelName: 'order.ship')]
    public function verifyPayment(OrderData $order): OrderData { /* ... */ }
    
    #[Asynchronous('async')]
    #[InternalHandler(inputChannelName: 'order.ship', outputChannelName: 'order.notify')]
    public function ship(OrderData $order): OrderData { /* ... */ }
    #[Saga]
    final class OrderFulfillment
    {
        #[Identifier] private string $orderId;
        private string $status = 'placed';
    
        #[EventHandler]
        public static function start(OrderWasPlaced $event): self
        {
            return new self($event->orderId);
        }
    
        #[EventHandler]
        public function onPaymentReceived(PaymentReceived $event, CommandBus $bus): void
        {
            $this->status = 'paid';
            $bus->send(new ShipOrder($this->orderId));
        }
    }
    #[Orchestrator(inputChannelName: 'order.fulfill')]
    public function plan(PlaceOrder $order): array
    {
        return $order->isDigital()
            ? ['order.charge', 'order.deliver_digital', 'order.notify']
            : ['order.charge', 'order.reserve_stock', 'order.ship', 'order.notify'];
    }

    When double payment happens, we could for example trigger an automatic refund, or store this information in order to provide manual refund. Even things which looks like external problems can actually be uncovered scenarios. For example failing on taking subscription payment, may actually reveal that we need to reattempt it after some time.

    Let the Exception propagate

    The most basic solution we could apply is to let the Exception propagate and catch it in the place where we can make meaning out of it, for example Controller. This can make sense in Synchronous Scenarios, where we return to the Customer details about the problems based on Exception.

    and our Command Handler

    In above example Workflow will stop, as no Message will go to the next step "place.order". In the Controller then we can simply state, what was the problem.

    Handling failures in the Message Handler

    We may find out, that it happens that when we decline Order due to validation errors, some Customer are cancelling the Order completely, as most likely they got irritated and decide to go somewhere else to buy the products. This as a result make the Business lose they money, which of course we want to avoid in the first place.

    To solve that, we could accept all the Orders just as they are, and when given Order is considered to be invalid we still accept it. For example, if we lack of given Product, we can contact Customer to provide substitute and then if Customer agrees, change the Order and consider it valid.

    So now when the Order is considered invalid we store for internal reviewal process. We also return null from the method now. This will stop the Workflow from moving forward to the outputChannel.

    Recoverable Synchronous Errors

    One of the problems that we need to accept is that Network is not reliable. This means that that when we will want to store something in our Database, send an Message to the Message Broker or call External Service, network may simply fail and there are various reasons why it may happen.

    In our case, if we would want to store our Order synchronously, or send an Message to the Broker, it may happen that we will face an error. This of course means that Customer will not completely his Order, which is far from ideal.

    To solve this we can make use of Instant Retries on the Command Bus.

    When failure happens, Command Bus is triggered again

    When failure happens Command Bus will automatically be triggered once more (depending on the configuration). This way we can self-heal application from transient error like network related problems.

    Recoverable Asynchronous Errors

    So when Failure happens during Asynchronous handling of given Message, we've still can kick off instant retries to try to recover immediately. However we get a bit more options here now as we are no more in HTTP Context, we can now delay the Retry too. Delaying the retries may be especially useful when dealing with External Services, as it may happen that they will be down for some period of time, which instant retries will not solve. In those situations we still want to Application to seal-heal, so we don't need to bother with those situations and for this we can use Delayed Retries.

    Retrying the Command Handler with delay

    Unrecoverable Asynchronous Errors

    In case External Service is down for longer period of time, we may actually not be able to self-heal. In case of coding errors (bugs) we may also end up in situation where not matter how many retries we would do, we still won't recover.

    For this situation, Ecotone provides Dead Letter Storage, which allows us to store the the Message and replay after the problem is fixed.

    Store in Dead Letter when urecoverable and replay when needed

    Customizing Global Error Handling

    If we already have some solution to handle Asynchronous Errors in our Application, we can take over the process using Error Channel. Error Chanel is a Message Channel where all unhandled Asynchronous Errors go. You can read more about in related documentation.

    Customizing Error Handling on Message Handler Level

    We could also catch exception using Middleware like behaviour and provide custom logic that we would like to trigger. This can be easily built using Ecotone's Interceptors.

    You can read more about in related documentation.

    final readonly class OrderController
    {
        public function __construct(private CommandBus $commandBus) {}
        
        public function placeOrder(Request $request): Response
        {
            $orderId = $request->get('orderId');
            $customerId = $request->get('customerId');
            $items = $request->get('items');
    
            try {
                $this->commandBus->send(PlaceOrder::create($orderId, $customerId, $items));   
            }catch (InvalidOrder $exception) {
                // Customize Response based on the Exception details
                return new Response($exception->getMessage(), 422);
            }
    
            return new Response('Order placed');
        }
    }
    class ProcessOrder
    {
        #[CommandHandler(
            'verify.order',
            outputChannelName: 'place.order'
        )]
        public function verify(PlaceOrder $command): PlaceOrder
        {
            // verify the order
            
            if ($orderInvalid) {
                throw new InvalidOrder($orderInvalidDetails);
            }
        }
    }
    class ProcessOrder
    {
        #[CommandHandler(
            'verify.order',
            outputChannelName: 'place.order'
        )]
        public function verify(PlaceOrder $command): ?PlaceOrder
        {
            // verify the order
            
            if ($orderInvalid) {
                // Store Order for reviewal process
                $this->orderToReviewRepository->save($order);        
                
                // This will stop the flow from moving forward to outputChannel
                return null;
            }
        }
    }

    Uncovered Business Scenarios are not failures. It's just gap in the knowledge about what we need to do. In those situations we just need confirm with our Product people how to deal with those situations and implement given behaviour.

    If our Architecture let us, it's good to treat exceptions as something exceptional, not as something to steer the Workflow. This way we can make it explicit in the code, what different scenarios we expect to happen.

    Depending on the application architecture, we may actually validate the Order before it even enters the Workflow. This may happen for example with Symfony Forms, then we can consider Order to be valid when it enters the Workflow.

    This kind of explicit way of solving problems allows us to switch the code from synchronous to asynchronous easily. As now even if we would do Validation asynchronously Customer experience would stay the same.

    Instant, Delayed Retries and Dead Letter creates a solution where Messages goes in circle till the moment they are handled or deleted. This ensure no data is lost along the way, and we more often than not do not need to deal with failures as our Application can self-heal from those problems. And if unrecoverable error happens, we get ability to easily replay the Message to resume the Workflow, after fix is applied.

    Pass your metadata (headers), as second parameter.

    Then you may access them directly in Message Handlers:

    Converting Headers

    If you have defined Converter for given type, then you may type hint for the object and Ecotone will do the conversion:

    And then we can use Classes instead of scalar types for our Headers:

    Automatic Header Propagation

    Ecotone by default propagate all Message Headers automatically. This as a result preserve context, which can be used on the later stage. For example we may provide executorId header and skip it in our Command Handler, however use it in resulting Event Handler.

    Automatic metadata propagation

    This will execute Command Handler:

    and then even so, we don't resend this Header when publishing Event, it will still be available for us:

    Message Identification and Correlation

    When using Messaging we may want to be able to trace where given Message came from, who was the parent how it was correlated with other Messages. In Ecotone all Messages contains of Message Id, Correlation Id and Parent Id within Metadata. Those are automatically assigned and propagated by the Framework, so from application level code we don’t need to deal manage those Messaging level Concepts.

    Id

    Each Message receives it's own unique Id, which is Uuid generated value. This is used by Ecotone to provide capabilities like Message Deduplication, Tracing and Message identification for Retries and Dead Letter.

    Parent Id

    "parentId" header refers to Message that was direct ancestor of it. In our case that can be correlation of Command and Event. As a result of sending an Command, we publish an Event Message.

    id and parentId will be automatically assignated by Ecotone

    Parent id will always refer to the previous Message. What is important however is that, if we have multiple Event Handlers each of them will receive it's own copy of the Message with same Id.

    Different Event Handlers receives same copy of the Event Message

    Correlation Id

    Correlation Id is useful for longer flows, which can span over multiple Message Handlers. In those situations we may be interested in how our Message flow have branched:

    Ecotone taking care of propagating CorrelationId between Message Handlers
    $this->commandBus->send(
       new CloseTicketCommand($request->get("ticketId")),
       ["executorUsername" => $security->getUser()->getUsername()]
    );
    #[CommandHandler]
    public function closeTicket(
          CloseTicketCommand $command, 
          #[Header("executorUsername")] string $executor
    ) {
    //        handle closing ticket
    }  
    class UsernameConverter
    {
        #[Converter]
        public function from(string $data): Username
        {
            return Username::fromString($data);
        }    
    }
    #[CommandHandler]
    public function closeTicket(
          CloseTicketCommand $command, 
          #[Header("executorUsername")] Username $executor
    ) {
    //        handle closing ticket
    }
    $this->commandBus->send(
       new BlockerUser($request->get("userId")),
       ["executorId" => $security->getUser()->getCurrentId()]
    );
    #[CommandHandler]
    public function closeTicket(BlockerUser $command, EventBus $eventBus) {
        // handle blocking user
        
        $eventBus->publish(new UserWasBlocked($command->id));
    }
    #[EventHandler]
    public function closeTicket(
        UserWasBlocked $command, 
        #[Header('executorId')] $executorId
    ) {
        // handle storing audit data
    }

    Ecotone provides a lot of support for Conversion, so we can work with higher level business class not scalars. Find out more in .

    When publishing Events from Aggregates or Sagas, metadata will be propagated automatically too.

    Using Message Id, Correlation Id are especially useful find out what have happened during the flow and if any part of the flow has failed. Using already propagated Headers, we may build our own tracing solution on top of what Ecotone provides or use inbuilt support for .

    No Data Migration Needed

    Both V1 and V2 projections read from the same underlying Event Store. The events don't change — only the projection infrastructure does. This means upgrading is purely about registering a new projection, not migrating data.

    Upgrade Steps

    1. Create the V2 Projection

    Take your existing V1 projection and create a V2 version alongside it. The main changes are:

    • Replace #[Projection("name", Aggregate::class)] with #[ProjectionV2('name_v2')] + #[FromAggregateStream(Aggregate::class)]

    • Keep your event handlers and lifecycle hooks as they are

    V1 (existing):

    V2 (new):

    2. Initialize and Backfill

    Deploy the V2 projection, then initialize and backfill it:

    bin/console ecotone:projection:init ticket_list_v2
    bin/console ecotone:projection:backfill ticket_list_v2
    artisan ecotone:projection:init ticket_list_v2
    artisan ecotone:projection:backfill ticket_list_v2

    The V2 projection will process all historical events from the Event Store and catch up to the present.

    3. Verify

    Compare the V2 read model against V1 to confirm the data matches:

    4. Switch Traffic

    Once verified, update your application's query handlers to read from the V2 table. Then remove the V1 projection.

    What Changes Between V1 and V2

    Aspect

    V1 (#[Projection])

    V2 (#[ProjectionV2])

    Stream declaration

    #[Projection("name", Aggregate::class)]

    #[ProjectionV2('name')] + #[FromAggregateStream(Aggregate::class)]

    Position tracking

    Prooph-based, stored in projections table

    Ecotone-native, stored in projection state table

    #[Projection('ticket_list', Ticket::class)]
    class TicketListProjection
    {
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            $this->connection->insert('ticket_list', [
                'ticket_id' => $event->ticketId,
                'ticket_type' => $event->type,
                'status' => 'open',
            ]);
        }
    
        #[ProjectionInitialization]
        public function init(): void { /* CREATE TABLE ticket_list */ }
    }
    #[ProjectionV2('ticket_list_v2')]
    #[FromAggregateStream(Ticket::class)]
    class TicketListV2Projection
    {
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            $this->connection->insert('ticket_list_v2', [
                'ticket_id' => $event->ticketId,
                'ticket_type' => $event->type,
                'status' => 'open',
            ]);
        }
    
        #[ProjectionInitialization]
        public function init(): void { /* CREATE TABLE ticket_list_v2 */ }
    
        #[ProjectionDelete]
        public function delete(): void { /* DROP TABLE ticket_list_v2 */ }
    
        #[ProjectionReset]
        public function reset(): void { /* DELETE FROM ticket_list_v2 */ }
    }
    $v1Tickets = $connection->fetchAllAssociative('SELECT * FROM ticket_list ORDER BY ticket_id');
    $v2Tickets = $connection->fetchAllAssociative('SELECT * FROM ticket_list_v2 ORDER BY ticket_id');
    
    assert($v1Tickets === $v2Tickets, 'V1 and V2 data should match');

    Both projections can run side by side — they read from the same Event Store but write to different tables. There is no conflict.

    V1 projections continue to work. You can migrate at your own pace — there is no deadline to switch.

    class PlaceOrder
    {
        private string $orderId;
        private string $productName;
    
        public function __construct(string $orderId, string $productName)
        {
            $this->orderId = $orderId;
            $this->productName = $productName;
        }
    
        public function getOrderId(): string
        {
            return $this->orderId;
        }
    
        public function getProductName(): string
        {
            return $this->productName;
        }
    }
    use Ecotone\Modelling\Attribute\CommandHandler;
    
    class OrderService
    {
        private array $orders;
    
        #[CommandHandler]
        public function placeOrder(PlaceOrder $command) : void
        {
            $this->orders[$command->getOrderId()] = $command->getProductName();
        }
    }
    class GetOrder
    {
        private string $orderId;
    
        public function __construct(string $orderId)
        {
            $this->orderId = $orderId;
        }
    
        public function getOrderId(): string
        {
            return $this->orderId;
        }
    }
    use Ecotone\Modelling\Attribute\CommandHandler;
    use Ecotone\Modelling\Attribute\QueryHandler;
    
    class OrderService
    {
        private array $orders;
    
        #[CommandHandler]
        public function placeOrder(PlaceOrder $command) : void
        {
            $this->orders[$command->getOrderId()] = $command->getProductName();
        }
    
        #[QueryHandler]
        public function getOrder(GetOrder $query) : string
        {
             if (!array_key_exists($query->getOrderId(), $this->orders)) {
                 throw new \InvalidArgumentException("Order was not found " . $query->getOrderId());
             }
    
             return $this->orders[$query->getOrderId()];
        }
    }
    $commandBus->send(new PlaceOrder(1, "Milk"));
    
    echo $queryBus->send(new GetOrder(1));
    Read Blog Post about CQRS in PHP and Ecotone
    class OrderWasPlaced
    {
        private string $orderId;
        private string $productName;
    
        public function __construct(string $orderId, string $productName)
        {
            $this->orderId = $orderId;
            $this->productName = $productName;
        }
    
        public function getOrderId(): string
        {
            return $this->orderId;
        }
    
        public function getProductName(): string
        {
            return $this->productName;
        }
    }
    class NotificationService
    {
        const ASYNCHRONOUS_MESSAGES = "asynchronous_messages";
    
        #[Asynchronous("asynchronous_messages")]
        #[EventHandler(endpointId:"notifyAboutNeworder")]
        public function notifyAboutNewOrder(OrderWasPlaced $event) : void
        {
            echo "Handling asynchronously: " . $event->getProductName() . "\n";
        }
    }
    class Configuration
    {
        #[ServiceContext]
        public function enableRabbitMQ()
        {
            return AmqpBackedMessageChannelBuilder::create(NotificationService::ASYNCHRONOUS_MESSAGES);
        }
    }
    $eventBus->publish(new OrderWasPlaced(1, "Milk"));
    
    # Running asynchronous consumer
    $messagingSystem->run("asynchronous_messages");
    Read more about Asynchronous in PHP and Ecotone
    Building Reactive Message-Driven Systems in PHP
    RabbitMQ

    Why Ecotone?

    Why Ecotone — the PHP architecture layer that grows with your system, without rewrites

    What Ecotone Is (and Isn't)

    Ecotone is not a framework replacement. You don't rewrite your Laravel or Symfony application to use Ecotone — you add it.

    Ecotone is a Composer package that adds architecture on top of your framework: command, query, and event buses wired from attributes; aggregates as plain PHP classes; sagas and projections as first-class patterns; event sourcing, transactional outbox, and distributed messaging all on the same messaging foundation.

    You keep your ORM (Eloquent or Doctrine), your routing, your templates, your deployment. Ecotone provides the architecture layer — the part that keeps your system maintainable as complexity grows.

    One Package That Grows With Your System

    The core promise of Ecotone: no forced architectural migrations as your domain grows. Every other PHP choice commits to a ceiling on day one. Spatie laravel-event-sourcing has no sagas. EventSauce assembles everything around it. Patchlevel has no outbox or distributed bus. Symfony Messenger is dispatch-only; aggregates, ES, sagas, and outbox are all separate library decisions.

    With Ecotone, you start with #[CommandHandler] on day one. You add #[Asynchronous] when you need async. You add #[Saga] when you need a stateful workflow. You add #[EventSourcingAggregate] when audit and replay become requirements. You add #[DistributedBus] when your system splits into services.

    The same codebase, the same classes, new attributes next to the old ones. No library swap. No parallel stack. No "migration from library A to library B" week.

    Patterns Proven in Other Ecosystems — Now on PHP

    Ecotone is built on , the same foundation behind:

    Ecosystem
    Pattern-driven architecture layer

    The patterns are decades-tested in banking, telecom, and logistics systems. Ecotone brings them to PHP as attribute-driven code on your existing Laravel or Symfony application — so your team writes POPOs, and Ecotone applies the patterns.

    What You Get, Mapped to Problems

    Instead of learning pattern names first, start with the problem you're solving:

    Your problem
    What the industry calls it
    Ecotone feature

    How It Integrates

    Ecotone plugs into your existing framework without requiring changes to your application structure.

    Laravel

    Laravel's queue runs jobs, not business processes — anything resembling aggregates, sagas, workflows, or event sourcing ends up stitched together from separate libraries. Ecotone fills that layer directly: works with Eloquent for aggregate persistence, Laravel Queues for async message channels, and Laravel Octane for high-performance scenarios. Configuration via your standard Laravel config files.

    Symfony

    Symfony Messenger handles dispatch — aggregates, sagas, event sourcing, and transactional outbox are left to you. Ecotone fills that layer directly: works with Doctrine ORM for aggregate persistence, Symfony Messenger Transport for async message channels, and standard Bundle configuration. Ecotone auto-discovers your attributes in the src directory.

    Standalone

    For applications without Laravel or Symfony, Ecotone Lite provides the full feature set with minimal dependencies.

    Trusted in Regulated Production

    Ecotone runs in production at payment gateways where retried handlers cannot double-charge, at credit card systems where transaction loss is catastrophic, at certification authorities whose event log is the audit log, at e-commerce platforms orchestrating order-payment-fulfillment sagas, at public transportation subscription systems managing nationwide transit subscriptions with Kafka integration to Java services, and at two-sided marketplaces coordinating customer orders, provider subscriptions, and B2B partnerships.

    The capabilities on are not hypothetical. They run in systems where failure is either regulated, expensive, or public.

    Start Today, Grow Into Every Pattern

    Day one: install the package, add #[CommandHandler] to one method, run your tests.

    Week one: add #[Asynchronous] to move handlers off the request cycle.

    Month three: add #[Saga] for your first multi-step business process; add the transactional outbox.

    Year two: event-source the aggregates where audit matters; add distributed bus when your system splits into services.

    Same classes. Same codebase. Same team. No forced migration between stages.

    for advanced multi-tenancy, distributed bus with service map, orchestrators, and production-grade Kafka.

    Working with Metadata

    Working with event metadata in Event Sourcing

    All Events may contain additional Metadata. This is especially useful for storing information that are not required for Command to be handled, yet are important for auditing and projecting purposes.

    Metadata

    In Ecotone any communication happens via Messages, and Messages contains of Payload and Headers (Metadata).

    So far we've discussed only the Payload part, for example ProductWasCreated Event Class is actually an Payload.

    What we actually store in the Event Stream is Message, so Payload and Metadata.

    Ecotone Framework use the Metadata for Framework related details, which can be used for identifying messages, correlating, and targeting (which Aggregate it's related too). However we can also use the Metadata for additional information in our Application too.

    Metadata Propagation

    Ecotone provides Metadata propagation, which take cares of passing Metadata between Command and Events without the need for us to do it manually. This way we can keep our business code clean, yet still be able to access the Metadata later.

    Even so, the Metadata is not used in our Ticket Aggregate, when the Event will be stored in the Event Stream, it will be stored along side with our provided Metadata. Therefore we will be able to access it in any Event Handlers:

    Manual Propagation

    We can also manually add Metadata directly from Command Handler, by packing the our data into Event class.

    and then access it from any subflows:

    Accessing Metadata in Command Handler

    We may access metadata sent from Command Bus in Command Handler when needed:

    [Enterprise] Accessing Metadata during Event Application

    Pass metadata to #[EventSourcingHandler] methods for context-aware aggregate reconstruction -- without polluting event payloads with infrastructure concerns.

    You'll know you need this when:

    • Your event-sourced aggregates serve multiple tenants and reconstruction logic varies by tenant context

    • Event streams are merged from multiple source systems and you need to distinguish origin during replay

    • You need to protect business invariants based on metadata stored alongside events (e.g., executor identity)

    Execution Modes

    PHP Event Sourcing Projection Execution Modes

    The Problem

    Your projection runs in the same request as the command handler, and under heavy load it slows down your API. Or you have multiple projections and don't want one slow projection to block others. How do you control when and where projections execute, and what consistency trade-offs come with each choice?

    Execution modes determine where your projection runs (same process or background worker) and when it processes events (immediately or later). Each mode comes with different consistency guarantees.

    Choosing the Right Mode

    This is about where and when execution happens, and what consistency consequences you accept:

    Feature
    Sync Event-Driven
    Async Event-Driven
    Polling (Enterprise)

    Synchronous Event-Driven (Default)

    By default, projections execute synchronously — in the same process and the same database transaction as the Command Handler that produced the events.

    When to use:

    • Low write volume — a few writes per second

    • Testing — immediate feedback, no async complexity

    • Simple applications — where eventual consistency adds unnecessary complexity

    Trade-off: If the projection is slow (complex queries, external calls), it slows down the entire command handling. For high-throughput scenarios, consider asynchronous execution.

    Asynchronous Event-Driven

    To decouple the projection from the command handler, mark it as asynchronous. The event is delivered via a message channel and processed by a background worker:

    The projection code stays exactly the same — you just add #[Asynchronous('projections')]. Ecotone handles delivering the trigger event via the projections channel.

    To start the background worker:

    When to use:

    • High write volume — projection processing shouldn't slow down commands

    • Multiple projections — each can process at its own pace

    • Production workloads — decoupled, resilient processing

    Trade-off: Data in the Read Model may be slightly behind the Event Store (eventual consistency). If you query immediately after a command, you might get stale results.

    Batch Size and Flushing

    By default, projections load up to 1000 events per batch. You can customize this with #[ProjectionExecution]:

    How Batching Works

    Events are processed in batches, and each batch is wrapped in its own database transaction. After each batch:

    1. #[ProjectionFlush] handler is called (if defined)

    2. The projection's position is saved

    3. The transaction is committed

    This prevents one massive transaction from locking your database tables for the entire projection run. Even if you have 100,000 events to process, the database is only locked for one batch at a time.

    Polling (Enterprise)

    Polling projections run as a dedicated background process that periodically queries the event store for new events:

    quickstart-examples/StatefulProjection at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/EventSourcing at main · ecotoneframework/quickstart-examplesGitHub
    GitHub - ecotoneframework/php-ddd-cqrs-event-sourcing-symfony-laravel-ecotone: Ecotone - ES DDD CQRS PHP Symfony Laravel exampleGitHub
    quickstart-examples/Schedule at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/Microservices at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/MicroservicesAdvanced at main · ecotoneframework/quickstart-examplesGitHub
    Advanced Microservice Integration

    Gap Detection and Consistency

    PHP Event Sourcing Projection Gap Detection

    The Problem

    Two users place orders at the exact same time. Both transactions write to the event store, but one commits a split-second before the other. Your projection processes event #11 but event #10 isn't visible yet — and silently gets skipped forever. How do you guarantee no events are lost?

    quickstart-examples/OutboxPattern at main · ecotoneframework/quickstart-examplesGitHub
    GitHub - ecotoneframework/php-ddd-cqrs-event-sourcing-symfony-laravel-ecotone: Ecotone - ES DDD CQRS PHP Symfony Laravel exampleGitHub
    quickstart-examples/MultiTenant/Laravel/AsynchronousEvents at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/MultiTenant/Laravel/Events at main · ecotoneframework/quickstart-examplesGitHub
    GitHub - ecotoneframework/php-ddd-cqrs-event-sourcing-symfony-laravel-ecotone: Ecotone - ES DDD CQRS PHP Symfony Laravel exampleGitHub
    quickstart-examples/MultiTenant/Symfony/MessageBus at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/MultiTenant/Symfony/AsynchronousEvents at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/EventHandling at main · ecotoneframework/quickstart-examplesGitHub

    The slip header on the message

    Gap detection

    Time-based (blocking)

    Track-based (non-blocking)

    Partitioning

    Not available

    #[Partitioned]

    Backfill

    Manual reset + trigger

    ecotone:projection:backfill CLI command

    Rebuild

    Manual reset + trigger

    ecotone:projection:rebuild CLI command

    Blue-green

    Not available

    #[ProjectionDeployment]

    Flush mechanism

    Per-event persistence

    Configurable batch commits

    Manual

    Yes

    Sagas

    Yes

    Yes

    Yes

    Workflow Orchestration

    Manual

    Yes

    Yes

    Resiliency (Retries, Dead Letter, Outbox)

    Yes

    Yes

    Yes

    Distributed Messaging

    Yes

    Yes

    Yes

    Multi-Tenancy

    Manual

    Manual

    Built-in

    Message Broker Support

    Kafka, RabbitMQ, etc.

    RabbitMQ, Azure, etc.

    RabbitMQ, Kafka, SQS, Redis

    Observability

    Micrometer

    OpenTelemetry

    OpenTelemetry

    Testing Support

    Axon Test Fixtures

    NServiceBus Testing

    Built-in Test Support

    Tutorial
    Identifier Mapping
    Event Sourcing Repositories
    Document Store Repositories
    Doctrine ORM
    Eloquent
    Repository section
    Dbal Module
    Interceptors
    Conversion section
    OpenTelemetry

    This feature is available as part of Ecotone Enterprise.

    Product Was Created is Payload of Message stored in Event Stream
    Example Event Stream containing of two Events for same Aggregate Instance
    executor id is propagated to the Event's metadata

    Batched, per-batch commits

    Batched, per-batch commits

    Triggering

    On event publish

    On event via channel

    Polls database at intervals

    Best for

    Low write volume, testing

    Production workloads

    Dedicated background worker

    Consistency

    Immediate

    Eventual

    Eventual

    Transaction

    Execution mode does not affect horizontal scaling. For parallel processing across multiple aggregates, see Scaling and Advanced — which uses Partitioned or Streaming projections (Enterprise).

    You can start with synchronous projections for simplicity, and switch to asynchronous later by adding a single attribute — no code changes needed in your projection handlers.

    Synchronous projections run within the same database transaction as the Event Store changes. When you query the Read Model right after a command, you always get consistent, up-to-date data.

    Multiple projections can share the same async channel (same consumer process), or each can have its own dedicated channel.

    Ecotone automatically manages transactions at batch boundaries. In async mode, each batch gets its own transaction — not the entire message processing. If you use Doctrine ORM, Ecotone also flushes and clears the EntityManager at batch boundaries automatically, preventing memory leaks.

    Polling projections are available as part of Ecotone Enterprise.

    Same as command

    $this->commandBus->send(new AssignPerson(1000, 12), metadata: [
        'executorId' => '123
    ]);
    #[EventSourcingAggregate]
    class Ticket
    {
        use WithAggregateVersioning;
    
        #[Identifier]
        private string $ticketId;
    
        (...)
    
        #[CommandHandler]
        public function assign(AssignPerson $command) : array
        {
            return [new PersonWasAssigned($this->ticketId, $command->personId)];
        }
    }
    public function handle(
        PersonWasAssigned $event, 
        // Accessing Metadata
        #[Header("executorId")] $executorId
    ): void
    {
        // do something with metadata
    };
    #[EventSourcingAggregate]
    class Ticket
    {
        use WithAggregateVersioning;
    
        #[Identifier]
        private string $ticketId;
        private string $type;
    
        (...)
    
        #[CommandHandler]
        public function assign(AssignPerson $command) : array
        {
            return [
                Event::create(
                    new PersonWasAssigned($this->ticketId, $command->personId), 
                    [
                       'ticketType' => $this->ticketType
                    ]
                )
            ];
        }
    }
    public function handle(
        PersonWasAssigned $event, 
        // Accessing Metadata
        #[Header("ticketType")] $ticketType
    ): void
    {
        // do something with metadata
    };
    #[EventSourcingAggregate]
    class Ticket
    {
        use WithAggregateVersioning;
    
        #[Identifier]
        private string $ticketId;
        private string $ownerId;
    
        (...)
    
        #[CommandHandler]
        public function change(
            ChangeTicket $command, 
            // Accessing Metadata
            #[Header("executorId")] $executorId
        ) : array
        {
            // do something with executorId
        }
    #[EventSourcingAggregate]
    class Ticket
    {
        use WithAggregateVersioning;
    
        #[Identifier]
        private string $ticketId;
        private string $ownerId;
    
        (...)
    
        #[CommandHandler]
        public function change(ChangeTicket $command, #[Header] $executorId) : array
        {
            if ($this->ownerId !== $executorId) {
                throw new \InvalidArgumentException("Only owner can change Ticket");
            }
        
            return new TicketChanged($this->ticketId, $command->type);
        }
        
        #[EventSourcingHandler]
        public function applyTicketCreated(
            TicketCreated $event,
            // Accessing Metadata
            #[Header("executorId")] $executorId,
        ) : void
        {
            $this->id = $event->id;
            $this->ownerId = $executorId;
        }
    }
    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    class TicketListProjection
    {
        // No additional attributes needed — synchronous is the default
        
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            // This runs in the same transaction as the command
        }
    }
    #[Asynchronous('projections')]
    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    class TicketListProjection
    {
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            // This runs in a separate process, triggered by the message channel
        }
    }
    bin/console ecotone:run projections -vvv
    artisan ecotone:run projections -vvv
    $messagingSystem->run('projections');
    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    #[ProjectionExecution(eventLoadingBatchSize: 500)]
    class TicketListProjection
    {
        // Events are loaded and processed 500 at a time
    }
    #[ProjectionFlush]
    public function flush(): void
    {
        // Called after each batch of events is processed
        // Useful for flushing buffers, clearing caches, etc.
    }
    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    #[Polling('ticket_list_poller')]
    class TicketListProjection
    {
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            // Executed when the poller finds new events
        }
    }

    Sagas, Workflow Orchestration

    Async processing is unreliable and hard to debug

    Resilient Messaging

    Retried handlers re-run sibling handlers and produce duplicate side effects

    Per-handler failure isolation

    Complex routing flows collapse into conditional dispatch code

    EIP composable messaging (pipes, content-based routing, splitters)

    Renaming a command class breaks in-flight messages

    Endpoint-ID routing

    Services need to communicate reliably across boundaries

    Distributed Messaging

    Webhooks arrive twice and double-charge customers

    Deduplication, per-bus retry/DLQ policy

    Multiple tenants need isolated processing with their own queues and priorities

    Multi-Tenant messaging

    # Your framework stays. Ecotone adds the architecture on top.
    composer require ecotone/laravel
    # or
    composer require ecotone/symfony-bundle

    Java

    Spring Integration, Axon Framework

    .NET

    NServiceBus, MassTransit, Wolverine

    PHP

    Ecotone

    Business logic is scattered across controllers and services

    CQRS (Command Query Responsibility Segregation)

    Message Bus and CQRS

    You need a full audit trail and the ability to rebuild state

    Event Sourcing

    Event Sourcing

    composer require ecotone/laravel
    composer require ecotone/symfony-bundle
    composer require ecotone/lite-application
    Enterprise Integration Patterns
    Laravel Module Documentation
    Symfony Module Documentation
    Ecotone Lite Documentation
    Ecotone's feature set
    Learn about Enterprise features

    Complex multi-step processes are hard to follow and maintain

    Where the Problem Comes From

    Gap detection matters specifically for globally tracked projections. A global stream combines events from many different aggregates into a single ordered sequence. When multiple transactions write events for different aggregates in parallel, they each get a position number — but they may commit in any order.

    Consider two concurrent transactions:

    • TX1 writes event at position 10 (for Ticket-A), starts first but commits slowly

    • TX2 writes event at position 11 (for Ticket-B), starts second but commits first

    When the projection queries the stream after TX2 commits, it sees position 11 — but position 10 is not yet visible (TX1 hasn't committed). If the projection simply advances its position to 11, event 10 is lost forever.

    Four Ways to Handle Gaps

    Globally tracked projections all face the same race condition. The strategies for dealing with it form a spectrum trading throughput, complexity, and safety. Ecotone chose the last one — but understanding why the others fall short is what makes that choice motivated rather than arbitrary.

    Strategy
    Safety
    Throughput
    Cost

    No detection

    ❌ Silently loses events

    ✅ Maximum

    Nothing — until production breaks

    Time-based blocking

    ✅ Eventually consistent

    No Gap Detection

    The naive approach: read events in order, advance the position, trust the database. In a single-threaded test it works perfectly. In production, the moment two transactions write concurrently and commit out of order, events disappear from the read model with no signal at all.

    Time-Based Blocking

    Many event sourcing systems solve this by making the projection wait — "if I see position 11 but not 10, pause and wait for 10 to appear."

    The problem with waiting:

    • If TX1 takes 5 seconds to commit, the entire projection halts for 5 seconds

    • All events after position 10 are blocked — even when they're from completely unrelated aggregates. A projection that only cares about Tickets can stall waiting on a slow Order transaction it never wanted to read.

    • In high-throughput systems, this waiting cascades and can bring down the whole projection pipeline.

    Time-based gap detection trades throughput for safety and still doesn't solve the problem at the root cause — it just defers the same race into a wait loop.

    Database-Level Write Locking

    A more aggressive variant: serialize every event-store write through a single advisory lock (SELECT pg_advisory_xact_lock(N)). With only one writer in flight at a time, events can never commit out of order, so gaps cannot occur.

    The cost is that a typical system has dozens of unrelated writes happening concurrently — order placement, payment processing, inventory adjustments, user registrations. None of them logically need to coordinate. With write locking, none of them can proceed in parallel either. Adding more workers does not help: extra workers just queue up on the lock and create contention, not throughput.

    Write locking eliminates the gap problem by eliminating concurrency — which is rarely what you want.

    Ecotone's Approach: Track-Based Non-Blocking

    Instead of waiting, Ecotone records the gap and moves on. The position is stored as a compact format that tracks both where the projection is and which positions are missing:

    On the next run:

    • If event 10 has appeared (TX1 committed), it gets processed and removed from the gap list

    • If event 10 is still missing, it stays in the gap list — the projection continues processing new events

    This approach never blocks. The projection keeps making progress on events that are available, while tracking gaps for eventual catch-up.

    Gap Cleanup

    Not all gaps will be filled — an event could be genuinely missing (deleted, or from a rolled-back transaction that was never committed). Ecotone cleans up stale gaps using two strategies:

    • Offset-based: gaps more than N positions behind the current position are removed. They are too old to represent an in-flight transaction.

    • Timeout-based: gaps older than a configured time threshold (based on event timestamps) are removed.

    Both strategies ensure the gap list stays bounded and doesn't grow indefinitely.

    Why Partitioned Projections Don't Need Gap Detection

    Partitioned projections track position per aggregate, not globally. Events within a single aggregate are guaranteed to be stored in order — each event's version is strictly previous + 1.

    If two transactions try to write to the same aggregate concurrently, the Event Store raises an optimistic lock exception — one transaction will fail and retry. This is guaranteed at the Event Store level.

    Because events within a partition can never be committed out of order, gaps within a partition cannot happen. Gap detection is only needed when tracking across multiple partitions in a global stream — exactly what globally tracked projections do.

    This is silent data loss. No error. No log entry. No exception. The read model is permanently wrong, and you will not find out until someone reports the problem.

    The projection logic looks correct in development — because your dev environment runs one request at a time. It is only under production concurrency that events start silently disappearing.

    "11:10"          →  "I'm at position 11, but position 10 is a known gap"
    "15:10,12,14"    →  "I'm at position 15, with known gaps at 10, 12, and 14"

    Track-based gap detection is the safest and fastest approach: it never blocks processing, never loses events, and naturally catches up as late-arriving transactions commit. This is why Ecotone chose this strategy over time-based waiting.

    This is another advantage of : they sidestep the gap detection problem entirely, because each partition's event ordering is guaranteed by the Event Store's concurrency control.

    Orchestration Layer

    The PHP orchestration layer for distributed systems — Orchestrators, Sagas, Distributed Bus, Service Map, and EIP primitives on Laravel and Symfony

    For a PHP team that needs to orchestrate — multi-step business processes, cross-service messaging between PHP applications, EIP-style routing of messages by content or context, multi-tenant traffic shaping in one deployment — Ecotone delivers declarative orchestration through PHP attributes, on the database and broker you already operate, integrated with Laravel and Symfony.

    This page covers the orchestration story from three angles: process orchestration (Orchestrators, Sagas, chained workflows), service-to-service orchestration (Distributed Bus, Service Map), and message-routing orchestration (EIP primitives). The comparison closes with how Ecotone differs from BPMN engines like Camunda/Zeebe and the "stitch it from Messenger + state machines" alternative.

    The Problem You Recognize

    Three different versions of "where is the logic?" — the same architectural failure mode in different costumes.

    The process problem. Order fulfillment spans payment + inventory + shipping + notification, with branching (digital vs physical fulfillment), waiting (24-hour timeouts), and compensation (refund + restock on cancellation). The orchestration is spread across event listeners that trigger event listeners, cron jobs scanning is_processed columns, and a step_completed_at table. Nobody can explain the full process without reading every file.

    The service problem. Three PHP services need to communicate. You've built HTTP calls between them, custom serialization per pair, custom retry logic, and the question "how do new subscribers learn about events from Service A?" has no answer that doesn't involve another point-to-point integration.

    The routing problem. Messages need to fan out: one event triggers four handlers, each on a different broker, each with different priority and retry policy, some tenant-routed, some not. Symfony Messenger or Laravel Queues plus custom middleware can do this — eventually — but the routing logic ends up spread across YAML config, middleware classes, and service-tag wiring.

    What the Industry Calls It

    Orchestration Layer — the architectural tier that sits between application code and message infrastructure, declaring how messages flow, which steps compose into processes, and where messages cross service boundaries. In Java this layer is Spring Integration or Axon; in .NET it's NServiceBus, MassTransit, or Wolverine. In PHP the equivalent vocabulary — Enterprise Integration Patterns expressed as PHP 8 attributes — is what Ecotone provides.

    How Ecotone Solves It

    Orchestrators — declarative multi-step workflows (Enterprise)

    The #[Orchestrator] attribute defines a workflow as a sequence of channel names. Each step is a #[InternalHandler] independently testable and reusable. The orchestrator method returns the channel list — including dynamic step lists chosen from input data.

    The orchestration logic lives in one method that a business stakeholder can read. Each step is a plain handler.

    Sagas — stateful process managers

    When the process spans events arriving over time (payment received → ship → notify, with hours or days between steps), use a #[Saga]. State is persisted per #[Identifier]; on the next event arrival, the saga reloads and reacts. #[Delayed] handlers fire timeouts without cron.

    Stateless workflows — chained handlers

    When the message is the state, chain handlers via outputChannelName. No saga record needed; durability comes from the channel (outbox + redelivery).

    Distributed Bus + Service Map — service-to-service orchestration

    The Distributed Bus moves commands and events between PHP services over the brokers you already operate (RabbitMQ, Kafka, SQS, Redis). The Service Map (Enterprise) carries the topology — which service consumes which routing keys, on which broker — so adding a service is a config change, not a code change in every caller. Multi-broker single-topology: some services on Kafka, others on RabbitMQ, all coordinated through the Service Map.

    EIP primitives — composable message routing

    Routers (route by payload or header), splitters (one message → many), filters, enrichers, transformers — all as PHP attributes on plain methods. The Enterprise Integration Patterns vocabulary, expressed as code, not XML or YAML.

    Multi-tenant routing in one deployment

    Header-routed channels resolve at runtime: a tenant header on a message routes it to the tenant-specific channel. One deployment, N tenants, isolated processing — without one namespace per tenant or one cluster per tenant.

    How It Compares

    Dimension
    Camunda / Zeebe + PHP client
    Symfony Messenger + custom state machine
    Ecotone

    Camunda/Zeebe are mature BPMN engines; if your organisation already operates a JVM cluster and stakeholders want BPMN diagrams, they remain the right answer at the engine level. One PHP-specific factual point: as of 2026, no actively-maintained Camunda PHP client exists — the most-discoverable community SDK (tistre/camunda_php_client) was archived in February 2025. For PHP teams that don't want to add a Java cluster, that don't have an actively-maintained PHP-side SDK, and for the "stitch it from primitives" path that ends in six libraries with no shared retry or PII story, Ecotone covers the layer.

    Next Steps

    • — declarative multi-step workflows (Enterprise)

    • — stateful process managers

    • — stateless workflows

    Aggregate Command Handlers

    DDD PHP

    Read Aggregate Introduction sections first to get more details about Aggregates.

    Aggregate Factory Method

    New Aggregates are initialized using public factory method (static method).

    #[Aggregate]
    class Ticket
    {
        #[Identifier]
        private Uuid $ticketId;
        private string $description;
        private string $assignedTo;
        
        #[CommandHandler]
        public static function createTicket(CreateTicket $command): static
        {
            $ticket = new self();
            $ticket->id = Uuid::generate();
            $ticket->assignedTo = $command->assignedTo;
            $ticket->description = $command->description;
    
            return $ticket;
        }
    }

    After calling createTicket aggregate will be automatically stored.

    Factory method is static method in the Aggregate class. You may have multiple factory methods if needed.

    Sending Command looks exactly the same like in scenario.

    Aggregate Action Method

    Aggregate actions are defined using public method (non-static). Ecotone will ensure loading and saving the aggregate after calling action method.

    ChangeTicket should contain the identifier of Aggregate instance on which action method should be called.

    And then we call it from Command Bus:

    Calling Aggregate without Command Class

    In fact we don't need to provide identifier in our Commands in order to execute specific Aggregate instance. We may not need a Command class in specific scenarios at all.

    In this scenario, if we would add Command Class, it would only contain of the identifier and that would be unnecessary boilerplate code. To solve this we may use in order to provide information about instance of the aggregate we want to call.

    "aggregate.id" is special metadata that provides information which aggregate we want to call.

    Redirected Aggregate Creation

    There may be a cases where you would like to do conditional logic, if aggregate exists do thing, otherwise this. This may be useful to keep our higher level code clean of "if" statements and to simply API by exposing single method.

    Both Command Handlers are registered for same command CreateTicket, yet one method is factory method and the second is action method. When Command will be sent, Ecotone will try to load the aggregate first, if it will be found then changeTicket method will be called, otherwise createTicket.

    Publishing Events from Aggregate

    For standard Aggregates (non Event-Sourced) we can use WithEvents trait or provide method that with AggregateEvents attribute to provide list of Events that Aggregate has recorded. After saving changes to the Aggregate, Ecotone will automatically publish related Events

    Calling Aggregate with additional arguments

    Just as standard Command Handler, we can pass Metadata and DI Services to our Aggregates.

    Converting Parameters

    Converting parameters in Database Business Interface queries

    We may want to use higher level object within our Interface than simple scalar types. As those can't be understood by our Database, it means we need Conversion. Ecotone provides default conversions and possibility to customize the process.

    Default Date Time Conversion

    Ecotone provides inbuilt Conversion for Date Time based objects.

    #[DbalWrite('INSERT INTO activities VALUES (:personId, :time)')]
    public function add(string $personId, \DateTimeImmutable $time): void;

    By default Ecotone will convert time using Y-m-d H:i:s.u format. We may override this using .

    Default Class Conversion

    If your Class contains __toString method, it will be used for doing conversion.

    We may override this using .

    Converting Array to JSON

    For example database column may be of type JSON or Binary. In those situation we may state what Media Type given parameter should be converted too, and Ecotone will do the conversion before it's executing SQL.

    In above example roles will be converted to JSON before SQL will be executed.

    Value Objects Conversion

    If we are using higher level classes like Value Objects, we will be able to change the type to expected one. For example if we are using we can register Converter for our PersonRole Class and convert it to JSON or XML.

    Then we will be able to use our Business Method with PersonRole, which will be converted to given Media Type before being saved:

    This way we can provide higher level classes, keeping our Interface as close as it's needed to our business model.

    Using Expression Language

    Calling Method Directly on passed Object

    We may use Expression Language to dynamically evaluate our parameter.

    payload is special parameter in expression, which targets value of given parameter, in this example it will be PersonName. In above example before storing name in database, we will call toLowerCase() method on it.

    Using External Service for evaluation

    We may also access any Service from our Dependency Container and run a method on it.

    reference is special function within expression which allows us to fetch given Service from Dependency Container. In our case we've fetched Service registered under "converter" id and ran normalize method passing PersonName.

    Using Method Level Dbal Parameters

    We may use Dbal Parameters on the Method Level, when parameter is not needed.

    Static Values

    In case parameter is a static value.

    Dynamic Values

    We can also use dynamically evaluated parameters and access Dependency Container to get specific Service.

    Dynamic Values using Parameters

    In case of Method and Class Level Dbal Parameters we get access to passed parameters inside our expression. They can be accessed via method parameters names.

    Using Class Level Dbal Parameters

    As we can use method level, we can also use class level Dbal Parameters. In case of Class level parameters, they will be applied to all the method within interface.

    Using Expression language in SQL

    To make our SQLs more readable we can also use the expression language directly in SQLs.

    Suppose we Pagination class

    then we could use it like follows:

    To enable expression for given parameter, we need to follow structure :(expression), so to use limit property from Pagination class we will write :(pagination.limit)

    Working with Event Streams

    Working with Event Streams in Ecotone PHP

    In previous chapter we discussed that Event Sourcing Aggregates are built from Event Streams stored in the data store. Yet it's important to understand how those Events gets to the Event Stream in the first place.

    Working with Event Stream directly

    Let's start by manually appending Events using Event Store. This will help us understand better the concepts behind the Event Stream and Event Partitioning. After we will understand this part, we will introduce Event Sourcing Aggregates, which will abstract away most of the logic that we will need to do in this chapter.

    Working with Event Stream directly may be useful when migrating from existing system where we already had an Event Sourcing solution, which we want to refactor to Ecotone.

    Creating new Event Stream

    After installing Ecotone's Event Sourcing we automatically get access to Event Store abstraction. This abstraction provides an easy to work with Event Streams.

    Let's suppose that we do have Ticketing System like Jira with two basic Events "Ticket Was Registered" and "Ticket Was Closed". Of course we need to identify to which Ticket given event is related, therefore will have some Id.

    In our code we can define classes for those:

    To store those in the Event Stream, let's first declare it using - Event Store abstraction.

    Event Store provides few handy methods:

    As we want to append some Events, let's first create an new Event Stream

    This is basically enough to create new Event Stream. But it's good to understand what actually happens under the hood.

    What is the Event Stream actually

    In short Event Stream is just audit of series of Events. From the technical point it's a table in the Database. Therefore when we create an Event Stream we are actually creating new table.

    Event Stream table contains:

    • Event Id - which is unique identifier for Event

    • Event Name - Is the named of stored Event, which is to know to which Class it should be deserialized to

    • Payload - is actual Event Class, which is serialized and stored in the database as JSON

    Appending Events to Event Stream

    To append Events to the Event Stream we will use "appendTo" method

    This will store given Event in Ticket's Event Stream

    Above we've stored Events for Ticket with id "123". However we can store Events from different Tickets in the same Event Stream.

    We now can load those Events from the Event Stream

    This will return iterator of Ecotone's Events

    As we can see this maps to what we've been storing in the Event Stream table. Payload will contains our deserialized form of our event, so for example TicketWasRegistered.

    Concurrent Access

    Let's consider what may actually happen during concurrent access to our System. This may be due more people working on same Ticket or simply because our system did allow for double clicking of the same action.

    In those situations we may end up storing the same Event twice

    Without any protection we will end up with Closing Events in the Event Stream. That's not really ideal, as we will end up with Event Stream having incorrect history:

    This is the place where we need to get back to persistence strategy:

    We've created this Stream with "simple" persistence strategy. This means we can apply any new Events without guards. This is fine in scenarios where we are dealing with no business logic involved like collecting metrics, statistics. where all we to do is to push push Events into the Event Stream, and duplicates are not really a problem. However simple strategy (which is often the only strategy in different Event Sourcing Frameworks), comes with cost:

    • We lose linear history of our Event Stream, as we allow for storing duplicates. This may lead to situations which may lead to incorrect state of the System, like Repayments being recorded twice.

    • As a result of duplicated Events (Which hold different Message Id) we will trigger side effects twice. Therefore our Event Handlers will need to handle this situation to avoid for example trigger requests to external system twice, or building wrong Read Model using .

    • As we do allow for concurrent access, we can actually make wrong business decisions. For example we could give to the Customer promotion code twice.

    The "simple strategy" is often the only strategy that different Event Sourcing Frameworks provide. However after the solution is released to the production, we often start to recognize above problems, yet now as we don't have other way of dealing with those, we are on mercy of fixing the causes, not the root of the problem. Therefore we need more sophisticated solution to this problem, to solve the cause of it not the side effects. And to solve the cause we will be using different persistence strategy called "partition strategy".

    Partitioning Events

    Event Stream can be split in partitions. Partition is just an sub-stream of Events related to given Identifier, in our context related to Ticket.

    Partition is linear history for given identifier, where each Event is within partition is assigned with version. This way we now, which event is at which position. Therefore in order to partition the Stream, we need to know the partition key (in our case Ticket Id). By knowing the partition key and last version of given partition, we can apply an Event at the correct position. To create partitioned stream, we would create Event Stream with different strategy:

    This will create Event Stream table with constraints, which will require:

    • Aggregate Id - This will be our partition key

    • Aggregate Type - This may be used if we would store more Aggregate types within same Stream (e.g. User), as additional partition key

    • Aggregate Version - This will ensure that we won't apply two Events at the same time to given partition

    We append those as part of Event's metadata:

    Let's now see, how does it help us ensuring that our history is always correct. Let's assume that currently we do have single Event in the partition

    Now let's assume two requests happening at the same time:

    This way allows us to be sure that within request we are dealing with latest Event Stream, because if that's not true we will end up in concurent exception. This kind of protection is crucial when dealing with business logic that depends on the previous events, as it ensures that there is no way to bypass it.

    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, , and then get

    • 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:

    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.

    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:

    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.

    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.

    Demo

    Failure Handling

    PHP Event Sourcing Projection Failure Handling and Recovery

    The Problem

    Your projection handler throws an exception halfway through processing a batch of 100 events. Are the first 50 events committed or rolled back? Does the failure block all other projections, or just this one? And when the bug is fixed, does the projection automatically recover?

    Trigger-Based Architecture

    Most of the resilience properties on this page — self-healing recovery, failure isolation, safe batch retries — come from one design decision:

    The incoming event is a trigger, not the data being processed. When Ecotone runs a projection, it does not feed the trigger event into your handler. It uses the trigger as a signal to read events from the Event Store starting at the projection's last committed position. The handler always processes events fetched from the source of truth, not events that happened to ride along in a message.

    This is what lets crashed projections pick up exactly where they stopped after a fix is deployed: the Event Store still has every event, and the projection's stored position still points to the last successful commit. Restart the worker and it reads forward from that position — no manual reset, no backfill.

    It is also what makes failure isolation work: each async projection receives its own copy of the trigger and tracks its own position. One projection's stuck position doesn't affect another's.

    Keep this mental model in mind through the rest of this page. "Trigger arrives → projection reads from Event Store at last position → projection commits new position" is the whole loop.

    How Projections Are Triggered

    By default, Projections run synchronously. When a Command Handler stores events in the Event Stream, the Projection is triggered immediately — in the same process and the same database transaction.

    The Projection subscribes to those events and is executed as a result:

    Because both the Event Store write and the Projection update happen in the same transaction, your Read Model is always consistent with the Event Stream:

    This is important for understanding what gets reverted on failure — when a synchronous projection fails, the entire transaction (including the Event Store write) is rolled back. For , the Event Store write and the Projection run in separate transactions.

    Transaction Boundaries

    Each batch of events is wrapped in a single database transaction. If any event in the batch causes an exception:

    1. The entire batch is rolled back — no partial writes

    2. The projection's position is not advanced — the same events will be reprocessed on the next run

    3. The projection's state is not persisted — no corrupted state

    This is all-or-nothing per batch. You never end up with half-processed data.

    Batch Commits — Not One Giant Transaction

    With #[ProjectionExecution(eventLoadingBatchSize: N)], events are loaded in batches. Each batch gets its own transaction. Concretely, with eventLoadingBatchSize: 500:

    • Batch 1 (events 1–500): processed successfully → committed, position advanced to 500

    • Batch 2 (events 501–1000): exception thrown at event 750 → entire batch rolled back, position stays at 500

    • Next run: resumes from event 501. Batch 1's work is safe — only the failing batch is replayed.

    You never get partial writes from a failing batch, and you never lose committed work from earlier batches.

    Why Batching Matters Beyond Failure Recovery

    For a backfill or rebuild that has to chew through millions of events, batching is not just about safe rollback — it is what keeps the worker alive. Without batches:

    • Memory grows unboundedly. Doctrine's EntityManager keeps references to every entity it has seen until you flush and clear it. Process a million events in one transaction and the heap fills until the OS kills the worker.

    • Locks stay held. A single 100,000-event transaction locks the projection table for the entire run.

    • Recovery cost is enormous. A crash at event 999,000 means replaying all 999,000 events on restart.

    Batched commits put a ceiling on all three. Ecotone manages transactions at batch boundaries automatically and, if you use Doctrine ORM, also flushes and clears the EntityManager at each batch boundary so memory stays flat across long runs.

    Failure Isolation Between Projections

    When running projections asynchronously, Ecotone delivers a copy of the trigger message to each async handler independently. This means if one projection fails, the failure does not propagate to other projections — even if they share the same message channel.

    Example: You have TicketListProjection and TicketStatsProjection both running on the projections async channel. If TicketStatsProjection throws an exception, TicketListProjection continues processing normally. Each projection is isolated.

    Failure Impact Within a Projection

    Within a single projection, how a failure affects processing depends on the projection type:

    Global Projection

    A failure blocks the entire projection. Because a global projection tracks a single position across all events in the stream, a failing event prevents any subsequent events from being processed — even events for unrelated aggregates.

    Example: Event #50 fails for Ticket-A. Events #51-#100 (for Ticket-B, Ticket-C, etc.) cannot be processed until event #50 succeeds.

    Partitioned Projection (Enterprise)

    A failure blocks only the specific partition (single aggregate instance). All other partitions continue processing normally.

    Example: Ticket-A's partition fails on an event. Ticket-B and Ticket-C partitions continue processing independently. Only Ticket-A is stuck.

    This is a major resilience advantage — one problematic aggregate doesn't bring down the entire projection.

    Streaming Projection (Enterprise)

    A failure blocks whatever partition is defined on the Message Broker side (e.g., a Kafka partition). Other broker partitions continue independently.

    Recovery by Execution Mode

    How the system recovers from a failure depends on the execution mode:

    Synchronous

    The exception propagates to the caller (the command handler). There is no automatic retry — the failure is immediately visible to the user.

    Asynchronous

    Handled by the messaging channel's retry configuration. You can configure retry strategies with backoff:

    When all retries are exhausted, the message can be routed to an .

    Polling

    The next poll cycle implicitly retries the failed batch. Since the position wasn't advanced, the poller will attempt the same events again.

    Self-Healing Projections

    A key insight: the incoming event is just a trigger. The Projection does not process the event message directly — it fetches events from the Event Stream itself, starting at its last committed position.

    This is what makes projections self-healing. Consider what happens when a Projection fails because a column only accepts 10 characters, but the event contains a 13-character ticket type:

    If the next event (TicketWasClosed) arrives, the Projection won't skip the failed event — it will fetch from the Event Stream starting at its last known position. Once you fix the column size, the next trigger will automatically process both events:

    Because the projection's position is only advanced on successful commit, fixing the bug and restarting is enough. No manual intervention needed — no resetting, no backfilling. Deploy the fix and the projection catches up automatically.

    quickstart-examples/MultiTenant/Laravel/Aggregate at main · ecotoneframework/quickstart-examplesGitHub

    Event Sourcing

    Event Sourcing in PHP — built-in event store, partitioned and streaming projections, gap detection, projection emission, and end-to-end PII encryption on Laravel and Symfony

    For a PHP team building event-sourced systems, Ecotone delivers a built-in event store on PostgreSQL or MySQL, #[EventSourcingAggregate] for event-sourced aggregates, and #[ProjectionV2] for read models — global tracked, partitioned, or streaming. Gap detection is on by default. Projections can emit downstream events. Blue-green rebuild and async backfill ship for projections that grow into millions of events. End-to-end PII encryption applies across the event store, broker payloads, and structured logs through one shared conversion pipeline. All declarative through PHP attributes, on Laravel or Symfony.

    This page walks the Ecotone capabilities in depth and closes with a head-to-head comparison against the PHP event-sourcing landscape.

    Lesson 2: Tactical DDD

    DDD PHP

    Aggregate

    An Aggregate is an entity or group of entities that is always kept in a consistent state. Aggregates are very explicitly present in the Command Model, as that is where change is initiated and business behaviour is placed.

    Let's create our first Aggregate Product.

    Projections with Document Store

    PHP Event Sourcing Projections with Document Store

    The Problem

    You want to build a Read Model quickly but writing raw SQL for every projection — CREATE TABLE, INSERT, UPDATE, SELECT — is tedious and error-prone. You just want to store and retrieve PHP objects or arrays without managing schema yourself. How do you build projections without writing SQL?

    Blue-Green Deployments

    PHP Event Sourcing Projection Blue-Green Deployments

    The Problem

    You need to add a column to your projection's table and change how events are processed. But rebuilding takes 30 minutes, and during that time your users see an empty dashboard. How do you deploy projection changes with zero downtime?

    Business Workflows
    Resiliency
    Resiliency
    Business Workflows
    Message Bus and CQRS
    Distributed Bus
    Resiliency
    Multi-Tenancy Support

    ❌ Cascading stalls

    Latency, deadlocks under load

    DB-level write locking

    ✅ No gaps possible

    ❌ Single global lock

    Every write serializes; cannot scale

    Track-based non-blocking

    ✅ Eventually consistent

    ✅ Never blocks

    A compact gap list per projection

    Partitioned Projections
    Logo
    Logo
    Logo
    Logo

    Hand-written state machine, often a Laravel package + custom YAML

    Declarative #[Orchestrator] in PHP, one method, one file

    Cross-service communication

    Camunda Worker pattern (PHP polls Camunda)

    HTTP calls or Messenger transports per pair, hand-rolled retry/serialization

    Distributed Bus + Service Map, multi-broker single topology

    EIP primitives (routers, splitters, filters)

    Not the right shape

    Hand-rolled middleware

    First-class attributes

    Multi-tenant routing

    Manual per-tenant deployments / namespaces

    Custom middleware per project

    Header-routed channels, dynamic resolution

    Resiliency primitives shared across orchestrator + handlers

    Two separate models

    Each library/package its own retry story

    One model — retry, error channel, DLQ, dedup, OTel uniform across orchestrator steps and standalone handlers

    Integration with Laravel / Symfony

    External system

    Symfony-native; Laravel needs glue

    Bundle for Symfony, Provider for Laravel, Lite for any PSR-11 container

    — cross-service messaging
  • Distributed Bus with Service Map — topology-aware distribution (Enterprise)

  • Multi-tenancy Support — header-routed channels

  • EIP Routing — routers, splitters, filters

  • Complex Business Processes — workflow + saga side by side

  • Microservice Communication — service-to-service patterns

  • Runtime

    Java cluster you operate

    PHP process

    PHP process — the database and broker you already operate

    Workflow definition

    As You Scale: Ecotone Enterprise adds Orchestrators with dynamic step lists, Distributed Bus with Service Map for multi-broker topology, Dynamic Message Channels for tenant-routed traffic, and Streaming Projections over Kafka / RabbitMQ Streams.

    Orchestrators
    Sagas
    Connecting Handlers with Channels

    BPMN diagram + external PHP client integration

    Distributed Bus

    When factory method is called from Command Bus, then Ecotone will return new assigned identifier.

    When we avoid creating Command Classes with identifiers only, we decrease amount of boilerplate code that we need to maintain.

    Redirected aggregate creation works the same for Event Sourced Aggregates.

    External Command Handlers
    Metadata

    Read more about Ecotone's in Converters related section.

    Custom Converters
    Custom Converters
    JMS Converter Module
    Metadata - Is additional information stored alongside the Event

    Event Store is automatically available in your Dependency Container after installing Symfony or Laravel integration. In case of Ecotone Lite, it can be retrievied directly.

    We could also fetch list of Events without deserializing them. $events = $eventStore->load("ticket", deserialize: false);

    In that situations payload will contains an associative array. This may be useful when iterating over huge Event Streams, when there is no need to actually work with Objects. Besides that ability to load in batches may also be handy.

    Projections
    Ticket Events
    Event Stream table
    Two Events stored in the Event Stream
    Ticket was closed is duplicated in the Event Stream
    Ticket Event Stream partioned for each Ticket
    First request will succeed as will be quicker to store at position 2
    Second request will fail due to database constraint, as position two is already taken

    The state can be a simple array or a class. Ecotone automatically serializes and deserializes it for you.

    Gateways are automatically registered in your Dependency Container, so you can inject them like any other service.

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

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

    Ecotone takes care of persisting and loading the state between batches automatically. You only need to focus on the accumulation logic in event handlers and the persistence logic in flush. This pattern is ideal for projections that need to rebuild quickly over large event streams.

    emit an event
    deleted
    Example implementation using Ecotone Lite.

    Ecotone manages transactions at batch boundaries automatically. If you use Doctrine ORM, Ecotone also flushes and clears the EntityManager at each batch boundary, preventing memory leaks and stale entity state.

    Multiple async projections on the same channel are fully isolated from each other. A failure in one projection never blocks or affects another.

    Projections are self-healing. A bug in a handler doesn't permanently corrupt the Read Model — fix the code, and the projection recovers on the next trigger. This works because events are never lost — they stay in the Event Stream, and the projection always fetches from its last committed position.

    asynchronous projections
    error channel or dead letter queue
    Event Sourced Aggregate stores events, then they are published
    Projection executes after events are published
    Command Handler and Projection wrapped in same transaction
    Projection fails because column is too small
    Projection fetches from Event Stream — incoming event is just a trigger
    Why This Matters: Rebuild Cost Shapes Team Behavior

    If a rebuild takes hours or days, people stop running them. They batch projection changes into rare "rebuild windows." They modify projection tables directly instead of running their changes through a rebuild. They manually patch rows to fix individual bugs. Once enough manual patches accumulate, nobody trusts a rebuild anymore — running one would erase fixes that exist only in production.

    The promise of event sourcing — that the read model is just a function of the event log — only holds if replay is cheap enough to actually run. Blue-green deployments preserve that promise by making the most disruptive part of a rebuild (the period where the table is empty or inconsistent) invisible to users: v1 keeps serving while v2 catches up.

    The Blue-Green Strategy

    Instead of rebuilding the existing projection in-place (which clears the data), deploy a new version alongside the old one:

    1. v1 continues serving traffic normally

    2. v2 is deployed and catches up from historical events in the background

    3. Once v2 is fully caught up, switch traffic from v1 to v2

    4. Delete v1

    Both projections run against the same Event Store — no data migration or copying needed.

    Using #[ProjectionName] for Versioned Tables

    The key mechanism is #[ProjectionName] — it injects the projection name as a parameter into your handlers. Use it to dynamically name your tables, so tickets_v1 and tickets_v2 coexist in the same database:

    Because the table name comes from the projection name, deploying tickets_v2 creates a completely separate table — no conflicts with tickets_v1.

    Deploying Version 2

    When you need to deploy changes, create v2 with #[ProjectionDeployment]:

    Two settings control the deployment:

    • manualKickOff: true — the projection won't auto-initialize. You control when it starts.

    • live: false — events emitted via EventStreamEmitter are suppressed during the catch-up phase. This prevents duplicate notifications to downstream consumers.

    Why manualKickOff Matters

    Without it, deploying tickets_v2 would let any other action in the system kick the projection into life. The moment a new event for Ticket arrives — or any trigger touches the projection's channel — v2 would auto-initialize and start trying to catch up.

    For a globally tracked projection that has to chew through millions of historical events, that is exactly what you do not want happening during deploy. The async channel can be monopolised for half an hour while v2 races to catch up; other projections sharing the channel stall; the rest of the system feels the deploy. manualKickOff: true keeps v2 dormant until you explicitly run ecotone:projection:init — at the moment that suits your ops schedule, not whenever the next event happens to arrive.

    Step-by-Step Deployment Flow

    1. Deploy v2

    Deploy your code with the tickets_v2 projection class. Because manualKickOff: true, nothing happens yet.

    2. Initialize v2

    Create the v2 table:

    3. Backfill v2

    Populate v2 with all historical events:

    During this phase, v1 continues serving traffic normally. v2 processes historical events in the background.

    4. Verify v2

    This step is what makes blue-green safer than rebuild — but only if you actually do it. Skip it and you discover v2 is wrong only after users complain.

    What to check, in order of effort:

    • Structural diff — row counts, distinct values per column, null distribution. Anything wildly off is a sign that the v2 handler missed an event type or wrote the wrong type.

    • Spot checks — pick a handful of known-good and known-tricky aggregates, query both tables, compare row-by-row. Tickets near edge cases (closed-then-reopened, type-changed mid-life, etc.) catch most handler bugs.

    • Edge-case sampling — query for rows where the new logic should differ from v1 (the whole reason you're deploying v2). Confirm v2 actually produces the new values.

    • Shadow reads — if you can afford it, run live queries against both tables and log discrepancies. Catches anything the static comparison missed.

    If anything looks wrong, you have not lost anything: v1 is untouched, delete v2 and try again.

    5. Switch Traffic

    Update your application's query handlers to read from tickets_v2 instead of tickets_v1.

    6. Enable Live Mode

    Update the v2 projection to remove manualKickOff and set live: true, so it processes new events and emits downstream events normally.

    7. Delete v1

    This calls #[ProjectionDelete] which drops the tickets_v1 table.

    Event Emission Control

    The live: false setting is critical for projections that emit events. Without it, the backfill phase would re-emit all historical events — sending thousands of duplicate notifications to downstream consumers.

    With live: false:

    • Events emitted during backfill are silently discarded

    • Once you switch to live: true, new events are emitted normally

    • Downstream consumers only see events once

    Upgrading from Global to Partitioned

    Blue-green deployments also work for changing projection types. You can deploy v2 as a Partitioned Projection alongside your existing global v1:

    The same Event Store backs both projections. The only difference is how events are tracked and processed — v2 uses per-aggregate partitions instead of a single global position. Once v2 catches up, switch traffic and delete v1.

    The features described on this page are available as part of Ecotone Enterprise.

    final class OrderFulfillmentOrchestrator
    {
        #[Orchestrator(inputChannelName: 'order.fulfill')]
        public function plan(PlaceOrder $order): array
        {
            return $order->isDigital()
                ? ['order.charge', 'order.deliver_digital', 'order.notify']
                : ['order.charge', 'order.reserve_stock', 'order.ship', 'order.notify'];
        }
    
        #[InternalHandler(inputChannelName: 'order.charge')]
        public function charge(PlaceOrder $order, PaymentService $payments): PlaceOrder { /* ... */ return $order; }
    
        #[InternalHandler(inputChannelName: 'order.ship')]
        public function ship(PlaceOrder $order, ShippingService $shipping): PlaceOrder { /* ... */ return $order; }
    
        // ...
    }
    $distributedBus->sendCommand(
        targetServiceName: 'billing-service',
        command: new IssueRefund($orderId, $amount),
    );
    #[Distributed]
    #[CommandHandler]
    public function issueRefund(IssueRefund $command): void { /* runs on the billing service */ }
    #[Router('route.payment')]
    public function route(PlaceOrder $order): string
    {
        return $order->amount > 10_000 ? 'orders.high_value' : 'orders.standard';
    }
    
    #[Splitter(inputChannelName: 'order.fulfill', outputChannelName: 'item.ship')]
    public function splitItems(PlaceOrder $order): array
    {
        return $order->items;
    }
    $ticketId = $this->commandBus->send(
       new CreateTicket($assignedTo, $description)
    );
    #[Aggregate]
    class Ticket
    {
        #[Identifier]
        private Uuid $ticketId;
        private string $description;
        private string $assignedTo;
           
        #[CommandHandler]
        public function changeTicket(ChangeTicket $command): void
        {
            $this->description = $command->description;
            $this->assignedTo = $command->assignedTo;
        }
    }
    class readonly ChangeTicket
    {
        public function __construct(
            public Uuid $ticketId;
            public string $description;
            public string $assignedTo
        ) {}
    }
    $ticketId = $this->commandBus->send(
       new ChangeTicket($ticketId, $description, $assignedTo)
    );
    #[Aggregate]
    class Ticket
    {
        #[Identifier]
        private Uuid $ticketId;
        private bool $isClosed = false;
           
        #[CommandHandler("ticket.close")]
        public function close(): void
        {
            $this->isClosed = true;
        }
    }
    $this->commandBus->sendWithRouting(
        "ticket.close", 
        metadata: ["aggregate.id" => $ticketId]
    )
    #[Aggregate]
    class Ticket
    {
        #[Identifier]
        private Uuid $ticketId;
        private string $description;
        private string $assignedTo;
        
        #[CommandHandler]
        public static function createTicket(CreateTicket $command): static
        {
            $ticket = new self();
            $ticket->id = Uuid::generate();
            $ticket->assignedTo = $command->assignedTo;
            $ticket->description = $command->description;
    
            return $ticket;
        }
        
        #[CommandHandler]
        public function changeTicket(CreateTicket $command): void
        {
            $this->description = $command->description;
            $this->assignedTo = $command->assignedTo;
        }
    }
    #[Aggregate]
    class Ticket
    {
        use WithEvents; // Provides methods for collecting events
    
        #[Identifier]
        private Uuid $ticketId;
        
        #[CommandHandler]
        public static function createTicket(
            CreateTicket $command
        ): static
        {
            $self = new self($command->id);
            
            $self->recordThat(new TicketWasCreated($command->id));
            
            return $self;
        }
    }
    #[Aggregate]
    class Ticket
    {
        #[Identifier]
        private Uuid $ticketId;
        
        #[CommandHandler]
        public static function createTicket(
            CreateTicket $command,
            #[Header("executorId")] string $executorId,
            #[Reference] Clock $clock,
        ): static
        {
            return new self(
                $command->id,
                $executorId,
                $clock->currentTime(),
            );
        }
    }
    #[DbalWrite('INSERT INTO activities VALUES (:personId, :time)')]
    public function store(PersonId $personId, \DateTimeImmutable $time): void;
    final readonly class PersonId
    {
        public function __construct(private string $id) {}
    
        public function __toString(): string
        {
            return $this->id;
        }
    }
     /**
      * @param string[] $roles
      */
     #[DbalWrite('UPDATE persons SET roles = :roles WHERE person_id = :personId')]
     public function changeRoles(
         int $personId,
         #[DbalParameter(convertToMediaType: MediaType::APPLICATION_JSON)] array $roles
     ): void;
    final class PersonRoleConverter
    {
        #[Converter]
        public function from(PersonRole $personRole): string
        {
            return $personRole->getRole();
        }
        
        #[Converter]
        public function to(string $role): PersonRole
        {
            return new PersonRole($role);
        }
    }
     /**
      * @param PersonRole[] $roles
      */
     #[DbalWrite('UPDATE persons SET roles = :roles WHERE person_id = :personId')]
     public function changeRolesWithValueObjects(
         int $personId,
         #[DbalParameter(convertToMediaType: MediaType::APPLICATION_JSON)] array $roles
     ): void;
    #[DbalWrite('INSERT INTO persons VALUES (:personId, :name)')]
    public function register(
        int $personId,
        #[DbalParameter(expression: 'payload.toLowerCase()')] PersonName $name
    ): void;
    #[DbalWrite('INSERT INTO persons VALUES (:personId, :name)')]
    public function insertWithServiceExpression(
        int $personId,
        #[DbalParameter(expression: "reference('converter').normalize(payload)")] PersonName $name
    ): void;
    #[DbalWrite('INSERT INTO persons VALUES (:personId, :name, :roles)')]
    #[DbalParameter(name: 'roles', expression: "['ROLE_ADMIN']", convertToMediaType: MediaType::APPLICATION_JSON)]
    public function registerAdmin(int $personId, string $name): void;
    #[DbalWrite('INSERT INTO persons VALUES (:personId, :name, :registeredAt)')]
    #[DbalParameter(name: 'registeredAt', expression: "reference('clock').now()")]
    public function registerAdmin(int $personId, string $name): void;
    #[DbalWrite('INSERT INTO persons VALUES (:personId, :name, :roles)')]
    #[DbalParameter(name: 'roles', expression: "name === 'Admin' ? ['ROLE_ADMIN'] : []", convertToMediaType: MediaType::APPLICATION_JSON)]
    public function registerUsingMethodParameters(int $personId, string $name): void;
    #[DbalParameter(name: 'registeredAt', expression: "reference('clock').now()")]
    class AdminAPI
    {
        #[DbalWrite('INSERT INTO persons VALUES (:personId, :name, :registeredAt)')]
        public function registerAdmin(int $personId, string $name): void;
    }
    final readonly class Pagination
    {
        public function __construct(public int $limit, public int $offset)
        {
        }
    }
    interface PersonService
    {
        #[DbalQuery('
                SELECT person_id, name FROM persons 
                LIMIT :(pagination.limit) OFFSET :(pagination.offset)'
        )]
        public function getNameListWithIgnoredParameters(
            Pagination $pagination
        ): array;
    }
    final readonly class TicketWasRegistered
    {
        public function __construct(
            public string $id, 
            public string $type
        ) {}
    }
    
    final readonly class TicketWasClosed
    {
        public function __construct(
            public string $id, 
        ) {}
    }
    interface EventStore
    {
        /**
         * Creates new Stream with Metadata and appends events to it
         *
         * @param Event[]|object[] $streamEvents
         */
        public function create(string $streamName, array $streamEvents = [], array $streamMetadata = []): void;
        /**
         * Appends events to existing Stream, or creates one and then appends events if it does not exists
         *
         * @param Event[]|object[] $streamEvents
         */
        public function appendTo(string $streamName, array $streamEvents): void;
    
        /**
         * @return Event[]
         */
        public function load(
            string $streamName,
            int $fromNumber = 1,
            int $count = null,
            MetadataMatcher $metadataMatcher = null,
            bool $deserialize = true
        ): iterable;
    }
    $eventStore->create("ticket", streamMetadata: [
        "_persistence" => 'simple', // we will get back to that in later part of the section
    ]);
    $eventStore->appendTo(
        "ticket",
        [
            new TicketWasRegistered('123', 'critical'),
            new TicketWasClosed('123')
        ]
    );
    $eventStore->appendTo(
        "ticket",
        [
            new TicketWasRegistered('124', 'critical'),
        ]
    );
    $events = $eventStore->load("ticket");
    class Event
    {
        private function __construct(
            private string $eventName,
            private object|array $payload,
            private array $metadata
        )
        
        (...)
    // concurrent request 1
    
    $eventStore->appendTo(
        "ticket",
        [
            new TicketWasClosed('123'),
        ]
    );
    
    // concurrent request 2
    $eventStore->appendTo(
        "ticket",
        [
            new TicketWasClosed('123'),
        ]
    );
    $eventStore->create("ticket", streamMetadata: [
        "_persistence" => 'simple'
    ]);
    $eventStore->create("ticket", streamMetadata: [
        "_persistence" => 'partition',
    ]);
    $eventStore->appendTo(
        $streamName,
        [
            Event::create(
                new TicketWasRegistered('123', 'Johnny', 'alert'),
                metadata: [
                    '_aggregate_id' => 1,
                    '_aggregate_version' => 1,
                    '_aggregate_type' => 'ticket',
                ]
            )
        ]
    );
    #[ProjectionV2('ticket_counter')]
    #[FromAggregateStream(Ticket::class)]
    class TicketCounterProjection
    {
        #[EventHandler]
        public function when(
            TicketWasRegistered $event,
            #[ProjectionState] TicketCounterState $state
        ): TicketCounterState {
            return $state->increase();
        }
    }
    interface TicketCounterGateway
    {
        #[ProjectionStateGateway(TicketCounterProjection::NAME)]
        public function getCounter(): TicketCounterState;
    }
    #[Converter]
    public function toCounterState(array $state): CounterState
    {
        return new CounterState(
            ticketCount: $state['ticketCount'] ?? 0,
            closedTicketCount: $state['closedTicketCount'] ?? 0,
        );
    }
    interface TicketCounterGateway
    {
        #[ProjectionStateGateway('ticket_counter')]
        public function fetchStateForPartition(string $aggregateId): CounterState;
    }
    $gateway = $container->get(TicketCounterGateway::class);
    
    // Fetch state for a specific aggregate
    $stateForTicket1 = $gateway->fetchStateForPartition('ticket-1');
    $stateForTicket2 = $gateway->fetchStateForPartition('ticket-2');
    interface CalendarCounterGateway
    {
        #[ProjectionStateGateway('calendar_counter')]
        #[FromAggregateStream(Calendar::class)]
        public function fetchCalendarState(string $aggregateId): CounterState;
    
        #[ProjectionStateGateway('calendar_counter')]
        #[FromAggregateStream(Meeting::class)]
        public function fetchMeetingState(string $aggregateId): CounterState;
    }
    #[ProjectionV2('ticket_stats')]
    #[FromAggregateStream(Ticket::class)]
    #[ProjectionExecution(eventLoadingBatchSize: 1000)]
    class TicketStatsProjection
    {
        public function __construct(private Connection $connection) {}
    
        #[EventHandler]
        public function onTicketRegistered(
            TicketWasRegistered $event,
            #[ProjectionState] array $state
        ): array {
            // No database call — just update in-memory state
            $type = $event->type;
            $state[$type] = ($state[$type] ?? 0) + 1;
            return $state;
        }
    
        #[EventHandler]
        public function onTicketClosed(
            TicketWasClosed $event,
            #[ProjectionState] array $state
        ): array {
            $state['closed'] = ($state['closed'] ?? 0) + 1;
            return $state;
        }
    
        #[ProjectionFlush]
        public function flush(#[ProjectionState] array $state): void
        {
            // One database operation per batch instead of per event
            foreach ($state as $type => $count) {
                $this->connection->executeStatement(
                    'INSERT INTO ticket_stats (type, count) VALUES (?, ?) 
                     ON DUPLICATE KEY UPDATE count = ?',
                    [$type, $count, $count]
                );
            }
        }
    
        #[ProjectionInitialization]
        public function init(): void
        {
            $this->connection->executeStatement(<<<SQL
                CREATE TABLE IF NOT EXISTS ticket_stats (
                    type VARCHAR(50) PRIMARY KEY,
                    count INT NOT NULL DEFAULT 0
                )
            SQL);
        }
    }
    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    #[ProjectionExecution(eventLoadingBatchSize: 500)]
    class TicketListProjection
    {
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            // Processed in batches of 500
            // Each batch: load → process → flush → commit
        }
    
        #[ProjectionFlush]
        public function flush(): void
        {
            // Called after each batch, before commit
        }
    }
    DbalBackedMessageChannelBuilder::create('projections')
        ->withReceiveTimeout(1000)
    bin/console ecotone:projection:init tickets_v2
    artisan ecotone:projection:init tickets_v2
    bin/console ecotone:projection:backfill tickets_v2
    artisan ecotone:projection:backfill tickets_v2
    bin/console ecotone:projection:delete tickets_v1
    artisan ecotone:projection:delete tickets_v1
    #[ProjectionV2('tickets_v1')]
    #[FromAggregateStream(Ticket::class)]
    class TicketsProjection
    {
        public function __construct(private Connection $connection) {}
    
        #[ProjectionInitialization]
        public function init(#[ProjectionName] string $projectionName): void
        {
            $this->connection->executeStatement(<<<SQL
                CREATE TABLE IF NOT EXISTS {$projectionName} (
                    ticket_id VARCHAR(36) PRIMARY KEY,
                    ticket_type VARCHAR(25),
                    status VARCHAR(25)
                )
            SQL);
        }
    
        #[EventHandler]
        public function onTicketRegistered(
            TicketWasRegistered $event,
            #[ProjectionName] string $projectionName
        ): void {
            $this->connection->insert($projectionName, [
                'ticket_id' => $event->ticketId,
                'ticket_type' => $event->type,
                'status' => 'open',
            ]);
        }
    
        #[EventHandler]
        public function onTicketClosed(
            TicketWasClosed $event,
            #[ProjectionName] string $projectionName
        ): void {
            $this->connection->update(
                $projectionName,
                ['status' => 'closed'],
                ['ticket_id' => $event->ticketId]
            );
        }
    
        #[ProjectionDelete]
        public function delete(#[ProjectionName] string $projectionName): void
        {
            $this->connection->executeStatement("DROP TABLE IF EXISTS {$projectionName}");
        }
    
        #[ProjectionReset]
        public function reset(#[ProjectionName] string $projectionName): void
        {
            $this->connection->executeStatement("DELETE FROM {$projectionName}");
        }
    
        #[QueryHandler('getTickets')]
        public function getTickets(#[ProjectionName] string $projectionName): array
        {
            return $this->connection->fetchAllAssociative(
                "SELECT * FROM {$projectionName}"
            );
        }
    }
    #[ProjectionV2('tickets_v2')]
    #[FromAggregateStream(Ticket::class)]
    #[ProjectionDeployment(manualKickOff: true, live: false)]
    class TicketsV2Projection extends TicketsProjection
    {
        // Same handlers — or modified handlers with your schema changes
        // The table name will be 'tickets_v2' thanks to #[ProjectionName]
    }
    #[ProjectionV2('tickets_v2')]
    #[FromAggregateStream(Ticket::class)]
    #[Partitioned]
    #[ProjectionDeployment(manualKickOff: true, live: false)]
    class TicketsV2Projection extends TicketsProjection
    {
        // Same handlers, now partitioned
    }
    The Problem You Recognize

    Your application stores current state. The customer disputes a charge, and the support team asks "what exactly happened to this order?" You read logs, joins, and updated-at timestamps, and you piece together a story.

    A new read model is requested. You write a migration script — but the source data is "the current state of orders" and there's no way to verify the migration is correct because the events that produced the current state are gone.

    A projection needs rebuilding because the rebuild logic changed. The single rebuild worker takes 14 hours on a 50-million-event stream. During those 14 hours, new events arrive and either block on the projection or risk being silently skipped because a concurrent transaction committed between two reads.

    You want to add a new subscriber to the same event stream a year after the original projection shipped. The events are gone, replaced by a current-state table.

    These are the symptoms Event Sourcing solves — if the event-sourcing implementation actually delivers a queryable history, parallel rebuild, gap detection, and projection-to-projection events.

    What the Industry Calls It

    Event Sourcing — store every state change as an immutable event in an append-only stream; rebuild state by replaying events; treat the event log as the system of record and read models as derived views. The pattern compounds with CQRS (separate write and read models) and Domain-Driven Design (events as ubiquitous language).

    In PHP, four serious implementations exist: Spatie laravel-event-sourcing, EventSauce, Patchlevel event-sourcing, and Ecotone. They diverge on the operationally-important parts: projection scaling, gap detection, projection emission, PII boundaries, and framework portability.

    How Ecotone Solves It

    Event-sourced aggregates as plain PHP

    Plain PHP class. Plain events. #[CommandHandler] records events; #[EventSourcingHandler] rebuilds state from the recorded history. Ecotone handles persistence, loading, and concurrency.

    Projections that scale, with gap detection and emission

    Three things to notice that PHP event-sourcing libraries usually don't deliver out of the box:

    1. Gap detection is on by default. Events committed in concurrent transactions (where the gap-creator's transaction commits after the next event's commit) cannot be silently skipped. The projection tracks gaps and waits or fills them.

    2. Partitioned projections rebuild in parallel. Use #[Partitioned] to declare per-aggregate partitioning, and the rebuild splits across N workers — 50 million events finish in roughly N times less wall-clock time, not 14 hours on one worker.

    3. Projections emit downstream events. Use EventStreamEmitter inside a projection handler to publish a new event for sagas, other projections, or external handlers to subscribe via the normal #[EventHandler]. During rebuild, emission is automatically suppressed so downstream consumers aren't flooded with duplicate historical events.

    End-to-end PII encryption

    One #[Sensitive] attribute encrypts the field in the event store, on the wire over RabbitMQ / SQS / Kafka / Redis / DBAL outbox, and in your structured logs — because all serialization flows through one shared conversion pipeline. Crypto-shred a customer by deleting their key; their events become unreadable everywhere they live.

    Blue-green rebuilds, async backfill, streaming projections

    Ecotone Enterprise adds the operational tooling event-sourced production needs:

    • Blue-green deployments — run a new projection version in parallel; switch traffic atomically once it catches up.

    • Async backfill — push rebuild work to async workers; combine with partitioning to scale linearly with worker count.

    • Streaming projections — consume events from Kafka or RabbitMQ Streams instead of the database event store, for cross-system integration and external event sources.

    How It Compares

    Dimension
    Spatie laravel-event-sourcing
    EventSauce
    Patchlevel event-sourcing
    Ecotone

    Framework support

    Laravel only

    Framework-agnostic

    Framework-agnostic (Doctrine DBAL core, Symfony bundle ships)

    Laravel + Symfony + Ecotone Lite

    Two framings worth holding together. First, Spatie / EventSauce / Patchlevel are event-sourcing libraries — they cover the ES slice. Everything around it (CQRS message buses, sagas as process managers, outbox, distributed bus, multi-tenant routing, async messaging, shared retry / DLQ / PII middleware) is your responsibility to assemble from other libraries. Second, on the operational axes that matter at high event volume, Ecotone covers parallel rebuild, default-on gap detection, projection emission with rebuild auto-suppression, end-to-end PII encryption, and Laravel + Symfony parity. Patchlevel is the closest PHP-native competitor on the ES axes — it has a subscription engine and per-subscription isolation that Spatie and EventSauce don't — but as a library it shares the same operational-burden shape, and the subscription engine's failure model (one failing handler blocks the subscription's whole pipeline) is the most consequential production gap.

    Next Steps

    • Event Sourcing Introduction — event-sourced aggregates from scratch

    • Projections — read models, partitioning, gap detection

    • Emitting Events from Projections — EventStreamEmitter and the rebuild-suppression rule

    • — how concurrent commits stay visible

    • — evolve events safely

    • — the #[Sensitive] attribute and the conversion pipeline

    • — the audit-specific framing

    #[EventSourcingAggregate]
    final class Order
    {
        use WithAggregateVersioning;
    
        #[Identifier] private string $orderId;
        private OrderStatus $status = OrderStatus::Placed;
        private int $amount = 0;
    
        #[CommandHandler]
        public static function place(PlaceOrder $command): array
        {
            return [new OrderWasPlaced($command->orderId, $command->amount)];
        }
    
        #[EventSourcingHandler]
        public function whenPlaced(OrderWasPlaced $event): void
        {
            $this->orderId = $event->orderId;
            $this->amount = $event->amount;
        }
    
        #[CommandHandler]
        public function pay(PayOrder $command): array
        {
            if ($this->status !== OrderStatus::Placed) {
                return [];
            }
            return [new OrderWasPaid($this->orderId, $command->paymentId)];
        }
    
        #[EventSourcingHandler]
        public function whenPaid(OrderWasPaid $event): void
        {
            $this->status = OrderStatus::Paid;
        }
    }
    #[ProjectionV2(name: 'order_list')]
    #[FromAggregateStream(Order::class)]
    final class OrderListProjection
    {
        #[EventHandler]
        public function whenPlaced(OrderWasPlaced $event, ProjectionState $state): void
        {
            // Build the read model. State is partitioned per Order::class, gap-aware,
            // and rebuildable in parallel from the event history.
        }
    }
    final class CustomerRegistered
    {
        public function __construct(
            public readonly string $customerId,
            #[Sensitive] public readonly string $email,
            #[Sensitive] public readonly string $fullName,
        ) {}
    }

    As You Scale: Ecotone Enterprise adds , , , and — production tooling for event-sourced systems at scale.

    Aggregate attribute marks class to be known as Aggregate

  • Identifier marks properties as identifiers of specific Aggregate instance. Each Aggregate must contains at least one identifier.

  • CommandHandler enables command handling on specific method just as we did in Lesson 1. If method is static, it's treated as a factory method and must return a new aggregate instance. Rule applies as long as we use State-Stored Aggregate instead of Event Sourcing Aggregate.

  • QueryHandler enables query handling on specific method just as we did in Lesson 1.

  • Now remove App\Domain\Product\ProductService as it contains handlers for the same command and query classes. Before we will run our test scenario, we need to register Repository.

    Repository

    Repositories are used for retrieving and saving the aggregate to persistent storage. We will build an in-memory implementation for now.

    1. Repository attribute marks class to be known to Ecotone as Repository.

    2. We need to implement some methods in order to allow Ecotone to retrieve and save Aggregate. Based on implemented interface, Ecotone knowns, if Aggregate is state-stored or event sourced.

    3. canHandle tells which classes can be handled by this specific repository.

    4. findBy return found aggregate instance or null. As there may be more, than single indentifier per aggregate, identifiers are array.

    5. save saves an aggregate instance. You do not need to bother right what is $metadata and $expectedVersion.

    # As default auto wire of Laravel creates new service instance each time 
    # service is requested from Depedency Container, we need to register 
    # ProductService as singleton.
    
    # Go to bootstrap/QuickStartProvider.php and register our ProductService
    
    namespace Bootstrap;
    
    use App\Domain\Product\InMemoryProductRepository;
    use Illuminate\Support\ServiceProvider;
    
    class QuickStartProvider extends ServiceProvider
    {
        public function register()
        {
            $this->app->singleton(InMemoryProductRepository::class, function(){
                return new InMemoryProductRepository();
            });
        }
    (...)
    Everything is set up by the framework, please continue...
    Everything is set up, please continue...

    Have you noticed what we are missing here? Our Event Handler was not called, as we do not publish the ProductWasRegistered event anymore.

    Event Publishing

    In order to automatically publish events recorded within Aggregate, we need to add method annotated with AggregateEvents. This will tell Ecotone where to get the events from. Ecotone comes with default implementation, that can be used as trait WithEvents.

    namespace App\Domain\Product;
    
    use Ecotone\Modelling\Attribute\Aggregate;
    use Ecotone\Modelling\Attribute\Identifier;
    use Ecotone\Modelling\Attribute\CommandHandler;
    use Ecotone\Modelling\Attribute\QueryHandler;
    
    #[Aggregate]
    class Product
    {
        #[Identifier]
        private int $productId;
    
        private int $cost;
    
        private function __construct(int $productId, int $cost)
        {
            $this->productId = $productId;
            $this->cost = $cost;
        }
    
        #[CommandHandler]
        public static function register(RegisterProductCommand $command) : self
        {
            return new self($command->getProductId(), $command->getCost());
        }
    
        #[QueryHandler]
        public function getCost(GetProductPriceQuery $query) : int
        {
            return $this->cost;
        }
    }

    Not having code for Lesson 2?

    git checkout lesson-2

    namespace App\Domain\Product;
    
    use Ecotone\Modelling\Attribute\Repository;
    use Ecotone\Modelling\StandardRepository;
    
     #[Repository] // 1
    class InMemoryProductRepository implements StandardRepository // 2
    {
        /**
         * @var Product[]
         */
        private $products = [];
    
        // 3
        public function canHandle(string $aggregateClassName): bool
        {
            return $aggregateClassName === Product::class;
        }
    
        // 4
        public function findBy(string $aggregateClassName, array $identifiers): ?object
        {
            if (!array_key_exists($identifiers["productId"], $this->products)) {
                return null;
            }
    
            return $this->products[$identifiers["productId"]];
        }
    
        // 5
        public function save(array $identifiers, object $aggregate, array $metadata, ?int $expectedVersion): void
        {
            $this->products[$identifiers["productId"]] = $aggregate;
        }
    }
    use Ecotone\Modelling\WithEvents;
    
    #[Aggregate]
    class Product
    {
        use WithEvents;
    
        #[Identifier]
        private int $productId;
    
        private int $cost;
    
        private function __construct(int $productId, int $cost)
        {
            $this->productId = $productId;
            $this->cost = $cost;
    
            $this->recordThat(new ProductWasRegisteredEvent($productId));
        }
    (...)
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    100
    Good job, scenario ran with success!

    If you want to known more details about Aggregate start with chapter

    Usually you will mark services as Query Handlers not aggregates .However Ecotone does not block possibility to place Query Handler on Aggregate. It's up to you to decide.

    If you want to known more details about Repository start with chapter

    Let's run our testing command:

    You may implement your own method for returning events, if you do not want to be coupled with the framework.

    Let's run our testing command:

    Congratulations, we have just finished Lesson 2. In this lesson we have learnt how to make use of Aggregates and Repositories. Now we will learn about Converters and Metadata

    What is the Document Store?

    Ecotone's DocumentStore is a key-value store that automatically serializes and deserializes PHP objects and arrays to JSON. You organize data in collections (like database tables) and access individual documents by ID.

    It's available out of the box with DBAL — no extra setup needed. Think of it as a simpler alternative to writing raw SQL for your Read Models.

    Building a Projection with Document Store

    Instead of injecting a Connection and writing SQL, inject DocumentStore and work with PHP objects directly:

    Notice there's no #[ProjectionInitialization] to create tables, no #[ProjectionDelete] to drop them — the Document Store handles storage automatically.

    Available Operations

    The DocumentStore interface provides these methods:

    Method
    Description

    addDocument($collection, $id, $document)

    Add a new document. Throws if ID already exists.

    updateDocument($collection, $id, $document)

    Update existing document. Throws DocumentNotFound if missing.

    upsertDocument($collection, $id, $document)

    Add or update — inserts if new, updates if exists.

    Storing PHP Objects

    The Document Store can store PHP objects directly — Ecotone automatically serializes them to JSON and deserializes them back:

    Using upsertDocument for Simpler Logic

    When you don't want to distinguish between "first time" and "update", use upsertDocument to simplify your handlers:

    Lifecycle with Document Store

    When using Document Store, you can simplify your lifecycle hooks by operating on collections:

    Testing with In-Memory Document Store

    For tests, Ecotone provides an InMemoryDocumentStore that works identically to the DBAL version but stores everything in memory — no database needed:

    When to Use Document Store vs Raw SQL

    Document Store
    Raw SQL (Connection)

    Setup effort

    Minimal — no schema management

    Requires CREATE TABLE, migrations

    Query flexibility

    Key-value only (by ID, by collection)

    Full SQL (JOINs, WHERE, aggregations)

    #[ProjectionV2('available_balance')]
    #[FromAggregateStream(Account::class)]
    class AvailableBalanceProjection
    {
        public function __construct(private DocumentStore $documentStore) {}
    
        #[EventHandler]
        public function whenAccountSetup(AccountSetup $event): void
        {
            $this->documentStore->addDocument(
                'available_balance',
                $event->accountId,
                ['balance' => 0]
            );
        }
    
        #[EventHandler]
        public function whenPaymentMade(PaymentMade $event): void
        {
            $current = $this->documentStore->getDocument(
                'available_balance',
                $event->accountId
            );
    
            $this->documentStore->updateDocument(
                'available_balance',
                $event->accountId,
                ['balance' => $current['balance'] + $event->amount]
            );
        }
    
        #[QueryHandler('getCurrentBalance')]
        public function getCurrentBalance(string $accountId): int
        {
            return $this->documentStore->getDocument(
                'available_balance',
                $accountId
            )['balance'];
        }
    }
    class WalletBalance
    {
        public function __construct(
            public readonly string $walletId,
            public readonly int $currentBalance,
        ) {}
        
        public function add(int $amount): self
        {
            return new self($this->walletId, $this->currentBalance + $amount);
        }
    }
    
    #[ProjectionV2('wallet_balance')]
    #[FromAggregateStream(Wallet::class)]
    class WalletBalanceProjection
    {
        public function __construct(private DocumentStore $documentStore) {}
    
        #[EventHandler]
        public function whenWalletCreated(WalletWasCreated $event): void
        {
            $this->documentStore->addDocument(
                'wallet_balance',
                $event->walletId,
                new WalletBalance($event->walletId, 0)
            );
        }
    
        #[EventHandler]
        public function whenMoneyAdded(MoneyWasAddedToWallet $event): void
        {
            /** @var WalletBalance $wallet */
            $wallet = $this->documentStore->getDocument('wallet_balance', $event->walletId);
    
            $this->documentStore->updateDocument(
                'wallet_balance',
                $event->walletId,
                $wallet->add($event->amount)
            );
        }
    
        #[QueryHandler('getWalletBalance')]
        public function getBalance(string $walletId): WalletBalance
        {
            return $this->documentStore->getDocument('wallet_balance', $walletId);
        }
    }
    #[EventHandler]
    public function whenTicketRegistered(TicketWasRegistered $event): void
    {
        $this->documentStore->upsertDocument(
            'ticket_list',
            $event->ticketId,
            ['ticketId' => $event->ticketId, 'type' => $event->type, 'status' => 'open']
        );
    }
    
    #[EventHandler]
    public function whenTicketClosed(TicketWasClosed $event): void
    {
        $this->documentStore->upsertDocument(
            'ticket_list',
            $event->ticketId,
            ['ticketId' => $event->ticketId, 'status' => 'closed']
        );
    }
    #[ProjectionDelete]
    public function delete(): void
    {
        $this->documentStore->dropCollection('wallet_balance');
    }
    
    #[ProjectionReset]
    public function reset(): void
    {
        $this->documentStore->dropCollection('wallet_balance');
    }
    $ecotone = EcotoneLite::bootstrapFlowTestingWithEventStore(
        classesToResolve: [WalletBalanceProjection::class, Wallet::class],
        containerOrAvailableServices: [
            new WalletBalanceProjection(InMemoryDocumentStore::createEmpty()),
        ]
    );
    
    // Send commands, trigger projection, then query
    $ecotone->sendCommand(new CreateWallet('wallet-1'));
    $ecotone->sendCommand(new AddMoney('wallet-1', 100));
    
    $balance = $ecotone->sendQueryWithRouting('getWalletBalance', 'wallet-1');
    // $balance->currentBalance === 100

    When storing objects, Ecotone uses the configured serializer (e.g., JMS Converter) to convert them to JSON. The same object type is returned when reading — no manual deserialization needed.

    InMemoryDocumentStore is perfect for unit and integration tests — it has the same API as the DBAL version, runs instantly, and requires no database setup.

    You can mix both approaches in the same application — use Document Store for simple projections and raw SQL for complex ones. They are not mutually exclusive.

    Domain-Driven Design

    Domain-Driven Design in PHP — Aggregates, Sagas as process managers, Bounded Contexts via Distributed Bus, Domain Events as first-class citizens, on Laravel and Symfony

    For a PHP team building DDD-shaped software, Ecotone's vocabulary is DDD vocabulary — Aggregate, Identifier, Repository, Domain Event, Saga (as process manager), Bounded Context — and each pattern is a declarative PHP attribute, not a base class to extend or a contract to implement.

    The Problem You Recognize

    You've read Evans, you've read Vernon, you understand strategic and tactical DDD. The team has done the modelling work and identified bounded contexts. Now you're trying to express that model in PHP — and the tooling pushes back.

    • Aggregates end up extending a base class from some DDD library that brings in its own command bus, its own event publication mechanism, and its own opinions about repositories.

    • Domain events are dispatched through a third library's event dispatcher, which has its own subscription model that doesn't compose with the application's other event handlers.

    • Sagas are home-grown state machines because the framework doesn't have a saga concept at all.

    • Bounded contexts are aspirational — there's no operational boundary between them; one application database, one shared model, "bounded" only in the wiki.

    • The PHP DDD libraries you've evaluated turned out to be effectively dormant (Prooph's event-sourcing and service-bus packages untouched since 2021; project site frozen at 2019), stable-but-not-evolving (Broadway's last functional release was May 2023; subsequent commits are CI and dependency bumps), or focused on one narrow slice (just ES, just aggregates, just buses).

    You want a single, maintained framework where the DDD vocabulary is also the framework's vocabulary.

    What the Industry Calls It

    Domain-Driven Design — model the business domain as the central concern of the software; use the ubiquitous language of domain experts in the code; segment the system into bounded contexts with explicit translations between them; encapsulate invariants inside aggregates; reflect state changes as domain events; coordinate long-running processes via process managers (often called sagas). Pattern catalog from Evans (2003), refined by Vernon (2013).

    In PHP, faithful tactical DDD has historically meant assembling the building blocks from disparate libraries — and the libraries don't share retry policies, transaction boundaries, identity mappings, or event publication. Ecotone provides the full set on one model.

    How Ecotone Solves It

    Aggregates — invariants, command handlers, events

    Plain final class. #[Aggregate] marks the type, #[Identifier] declares the identity, command handlers live on the aggregate (#[CommandHandler] on a static factory for creation, on an instance method for state-changing operations). No base class, no interface. Domain events are plain PHP classes the aggregate records.

    For event-sourced aggregates, #[EventSourcingAggregate] with #[EventSourcingHandler] event-application methods — see the landing for the full story.

    Repositories — abstraction without imposition

    Repositories use the inbuilt DBAL or Eloquent repository, or implement the Repository contract yourself for any persistence model. The aggregate stays free of persistence concerns; the framework wires the repository in.

    Sagas as process managers

    The saga is a process manager in Vernon's sense — it remembers state across events arriving over time, decides what to do next, and dispatches commands to keep the process moving. State persisted per #[Identifier]; on each event arrival, Ecotone reloads the saga from the database. Event-to-saga binding via payload field, header, or expression (identifierMapping).

    Bounded Contexts — operational, not aspirational

    The Distributed Bus and Service Map (Enterprise) move commands and events between PHP services over the brokers you operate. Each bounded context runs as its own deployable with its own database, communicating with other contexts via well-defined domain events on the bus. The translation between contexts becomes explicit: events emitted by Context A are subscribed to by translators in Context B (using #[Distributed] handlers on each side). Strategic DDD made operational.

    Domain Events as first-class citizens

    Domain events are plain PHP classes — no DomainEventInterface, no recordThat() mixin required by the framework. The aggregate records events on itself; Ecotone publishes them onto the event bus when the aggregate is persisted. Subscribers (other aggregates, sagas, projections, integration handlers) bind via #[EventHandler] with optional identifierMapping or routing constraints.

    Value Objects and conversion

    Value objects pass through the JMS Converter pipeline cleanly — serialize through type converters; deserialize on the way in. The same #[Sensitive] attribute that encrypts fields in the event store encrypts them in messages on the broker, so PII boundaries respect the domain's classification.

    How It Compares

    Dimension
    Broadway
    Prooph
    Spatie laravel-event-sourcing
    Assemble it yourself
    Ecotone

    PHP's DDD library landscape in 2026 is sparse: Broadway has been on dependency-bump-only maintenance since its May 2023 functional release, Prooph's event-sourcing and service-bus packages haven't received a commit since 2021, and Spatie's library scopes to Laravel-only event sourcing. Ecotone covers the tactical DDD vocabulary as one declarative model on Laravel, Symfony, or Ecotone Lite.

    Next Steps

    • — message buses, command handlers, query handlers, event handlers

    • — state-stored aggregates

    • — event-sourced aggregates and projections

    quickstart-examples/MultiTenant/Symfony/Aggregate at main · ecotoneframework/quickstart-examplesGitHub

    About

    Ecotone — the PHP architecture layer that grows with your system, without rewrites

    The PHP architecture layer that grows with your system — without rewrites

    You started clean — controllers, services, jobs. Eighteen months later, business logic is scattered across listeners, the queue retries handlers that already succeeded, the Stripe webhook double-charges customers because nothing dedupes it, and adding event sourcing means swapping libraries. Ecotone is built around a different growth path: one package that takes you from #[CommandHandler] on day one to event sourcing, sagas, outbox, and distributed messaging at scale — same codebase, no forced migrations between growth stages.

    Enterprise

    Ecotone Enterprise features for scaling multi-tenant and multi-service PHP systems

    Ecotone Free gives you production-ready CQRS, Event Sourcing, and Workflows — message buses, aggregates, sagas, async messaging, retries, error handling, and full testing support.

    Ecotone Enterprise is for when your system outgrows single-tenant, single-service, or needs advanced resilience and scalability.

    Free vs Enterprise at a Glance

    Capability
    quickstart-examples/CQRS at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/RefactorToReactiveSystem at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/RefactorToReactiveSystem at main · ecotoneframework/quickstart-examplesGitHub
    Step by step refactor from synchronous code to full resilient asynchronous code
    quickstart-examples/Asynchronous at main · ecotoneframework/quickstart-examplesGitHub

    deleteDocument($collection, $id)

    Delete a document by ID.

    getDocument($collection, $id)

    Get document. Throws DocumentNotFound if missing.

    findDocument($collection, $id)

    Get document. Returns null if missing (no exception).

    getAllDocuments($collection)

    Get all documents in a collection.

    countDocuments($collection)

    Count documents in a collection.

    dropCollection($collection)

    Drop entire collection.

    Best for

    Simple Read Models, rapid prototyping

    Complex queries, reporting, dashboards

    Lifecycle hooks

    dropCollection()

    CREATE TABLE / DROP TABLE / DELETE FROM

    Testing

    InMemoryDocumentStore — no DB needed

    Requires test database

    Projection rebuild

    Single process, one cursor

    Single process

    Subscription engine, blue-green via subscriber-id, single cursor per projection

    Per-projection cursors; partitioned rebuild scales across N workers

    Gap detection

    Not supported — concurrent-commit events can be silently skipped

    Not supported

    Opt-in

    On by default with GapAwarePosition

    Projection emission

    Reactors handle live emission only — no auto-suppression on rebuild, so historical replays trigger duplicate downstream events

    Not supported

    Subscriptions can dispatch via bus, but no rebuild-time auto-suppression

    EventStreamEmitter — emit downstream events from projections; auto-suppressed on rebuild

    Per-handler isolation

    Shared envelope; first throwing consumer affects siblings

    Shared envelope; first throwing consumer aborts siblings; retry re-runs every consumer

    Per-subscription isolation only — within a subscription, one failing handler advances no position; the subscription's whole pipeline blocks until resolved or skipped

    Per-handler isolation — a copy of every message dispatched to every handler

    PII encryption

    Not supported

    Not supported

    Crypto-shredding inside the event store only

    End-to-end: event store + broker payloads + structured logs through one pipeline

    Multi-tenant projections

    Manual

    Manual

    Manual

    Dynamic table naming + tenant-routed channels

    Streaming consumption (Kafka/RabbitMQ Streams)

    Not supported

    Not supported

    Not supported

    Streaming Projections

    Gap Detection and Consistency
    Event Versioning
    PII Encryption (GDPR)
    Audit Trail & State Rebuild
    Partitioned Projections
    Async Backfill & Rebuild
    Blue-Green Deployments
    Streaming Projections

    Active, in production since 2019

    Framework support

    Symfony-leaning (broadway/broadway-bundle)

    Framework-agnostic

    Laravel only

    You pick

    Laravel + Symfony + Ecotone Lite

    Aggregate model

    Yes

    Yes

    Yes

    Hand-roll

    #[Aggregate] / #[EventSourcingAggregate]

    Saga as process manager

    Limited

    Yes (Prooph Service Bus)

    Reactors, not full sagas

    Hand-roll a state machine

    #[Saga] with #[Identifier] mapping

    Bounded-context distribution

    Out of scope

    Manual

    Out of scope

    Six libraries

    Distributed Bus + Service Map

    Repository abstraction

    Yes

    Yes

    Eloquent-bound

    You pick

    DBAL / Eloquent / custom

    Shared message-driven middleware (retry, DLQ, dedup, OTel, PII)

    No

    Partial

    No

    Per-library

    One model across every building block

    Domain Event publication

    Library-specific

    Library-specific

    Library-specific

    Hand-roll

    Plain PHP events on the event bus

    Sagas — process managers
  • Identifier Mapping — binding events to aggregates and sagas

  • Repositories — persistence abstraction

  • Microservice Communication — Bounded Contexts on the wire

  • PHP for Enterprise Architecture — the architecture-layer framing

  • Maintenance status

    Stable since May 2023 — recent commits are CI / dependency bumps

    Core event-sourcing and service-bus packages untouched since 2021; site frozen at 2019

    Active

    As You Scale: Ecotone Enterprise adds Orchestrators for declarative multi-step workflows, Distributed Bus with Service Map for bounded-context distribution across multiple brokers, Streaming Projections over Kafka, and Advanced Event Sourcing Handlers for context-aware aggregate reconstruction.

    Event Sourcing
    CQRS Introduction
    Aggregate Introduction
    Event Sourcing

    Yours forever

    State-Stored Aggregate
    Repository
    #[Aggregate]
    final class Customer
    {
        #[Identifier] private string $customerId;
        private CustomerStatus $status = CustomerStatus::Active;
        private array $domainEvents = [];
    
        #[CommandHandler]
        public static function register(RegisterCustomer $command): self
        {
            $self = new self();
            $self->customerId = $command->customerId;
            $self->domainEvents[] = new CustomerRegistered($command->customerId, $command->email);
            return $self;
        }
    
        #[CommandHandler]
        public function suspend(SuspendCustomer $command): void
        {
            if ($this->status === CustomerStatus::Suspended) {
                return;
            }
            $this->status = CustomerStatus::Suspended;
            $this->domainEvents[] = new CustomerWasSuspended($this->customerId, $command->reason);
        }
    }
    #[Saga]
    final class CustomerOnboarding
    {
        #[Identifier] private string $customerId;
        private bool $emailVerified = false;
        private bool $kycPassed = false;
    
        #[EventHandler]
        public static function start(CustomerRegistered $event, CommandBus $bus): self
        {
            $bus->send(new RequestEmailVerification($event->customerId, $event->email));
            $bus->send(new InitiateKyc($event->customerId));
            return new self($event->customerId);
        }
    
        #[EventHandler]
        public function onEmailVerified(EmailVerified $event): void
        {
            $this->emailVerified = true;
            $this->completeIfReady();
        }
    
        #[EventHandler]
        public function onKycPassed(KycPassed $event): void
        {
            $this->kycPassed = true;
            $this->completeIfReady();
        }
    }
    bin/console ecotone:quickstart
    Running example...
    100
    Good job, scenario ran with success!
    Works on the Laravel or Symfony you already run.

    Install today. Write a command handler in the next five minutes. Add event sourcing, workflows, asynchronous processing when need — by writing new classes, not swapping libraries.


    Get Started Smoothly

    You start here on day one:

    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:

    • Command, Query, and Event Bus — wired automatically from your #[CommandHandler], #[QueryHandler], and #[EventHandler] attributes

    • Event routing — NotificationService subscribes to OrderWasPlaced without any manual wiring

    • Async execution — #[Asynchronous('notifications')] routes to RabbitMQ, SQS, Kafka, or DBAL — your choice of transport

    • Failure isolation — each event handler receives its own copy of the message; one handler's failure never affects another

    • Retries and dead letter — failed messages retry automatically, permanently failed ones go to a you can inspect and replay

    • Tracing — traces every message across sync and async flows

    Test exactly the flow you care about

    Extract a specific flow and test it in isolation — only the services you need:

    Now bring in the full async flow. Enable an in-memory channel and run it within the same test process:

    ->run('notifications') processes messages from the in-memory queue — right in the same process. The async handler executes deterministically, no timing issues, no polling, no external broker.

    The key: swap the in-memory channel for DBAL, RabbitMQ, or Kafka to test what runs in production — the test stays the same. The ease of in-memory testing stays with you no matter what backs your production system.


    The growth ladder

    Every other PHP alternative forces you to re-decide architecture at each column break — swap libraries, add glue, or stitch together a multi-package stack. No other single PHP package spans the full set of growth stages.


    Why companies pick Ecotone

    1. Composable with alternatives. Complete standalone.

    Ecotone doesn't have to be the entire stack. It composes with the other libraries above when a team already has a preferred tool for one layer:

    • Ecotone as the orchestration / saga / outbox layer

    • Ecotone as the CQRS, workflows, and asynchronous communication layer

    • Ecotone as the Event Sourcing layer

    Ecotone is the only toolkit on this list that runs fully standalone. Every other option requires you to integrate something for the layers it does not cover. Ecotone covers every layer itself when you want it to, and composes with alternatives when you prefer a specific tool for one piece. One package to upgrade, one model to teach.

    2. True per-handler failure isolation — a copy of the message to every handler

    Ecotone delivers per-handler failure isolation by dispatching a copy of the message to every handler. Each handler processes its own message, retries independently, and fails independently. A failure in one handler cannot affect, block, or re-trigger any sibling handler, because they are never sharing a message envelope in the first place.

    This is the structural difference from Symfony Messenger: Messenger dispatches a single envelope through multiple handlers; per-handler failure isolation is not a property it provides, and the community workarounds (per-handler transports, idempotency keys, custom dedup middleware) reduce the problem without solving it at the framework level.

    Pair that with a first-class transactional outbox and deduplication, and the three patterns you would otherwise assemble from idempotency middleware + a community outbox package + a custom dedup layer become default behavior.

    3. Production resilience is the default, not assembly

    • Transactional outbox — business write and message publish in one atomic commit. No "we published but the DB rolled back" ghost events. No "we committed but the publish crashed" lost events.

    • Dead letter queue — failed messages captured with full context, replayable with one command.

    • Deduplication — built in, attribute-driven.

    • OpenTelemetry tracing — every message traced across sync and async hops.

    • Retries with configurable backoff — per channel, no custom wiring.

    4. Framework-portable business code

    Same aggregates, handlers, sagas, and projections run on Laravel, Symfony, or any PSR-11 container. A team that starts on Laravel and later consolidates on Symfony (or vice versa) does not rewrite business logic. For companies hiring from both PHP pools, handling acquisitions, or wanting long-term framework independence, this removes a category of strategic risk.

    No other option on this list offers framework portability.

    5. Battle-tested patterns, not invented ones

    Ecotone is built on Enterprise Integration Patterns — the same foundation behind Spring Integration, Axon Framework (Java), NServiceBus and MassTransit (.NET), and Apache Camel. These patterns run banking, telecom, and logistics systems in production at scale, measured in decades.

    Ecotone brings them to PHP as attribute-driven code on your existing Laravel or Symfony application. Your team writes POPOs; Ecotone applies the patterns.

    6. Composable messaging — pipe, route, split, filter, transform on the fly

    The EIP foundation is not a lineage — it is a working catalogue of composable primitives you write as attributes:

    • Pipe handlers together — output of one handler becomes input of the next, no Bus::chain glue code.

    • Content-based routing — route the same message to different handlers based on its payload or headers. VIP orders go through the premium path, bulk orders through the batch path — no if statements in your domain code.

    • Splitters — one OrderPlaced event with ten line items fans out into ten per-item fulfillment messages, processed in parallel, each independently retryable.

    • Filters and transformers — reject, enrich, or reshape messages declaratively.

    The problem these primitives solve is complex integration flows that usually collapse into unmaintainable conditional dispatch code. In Ecotone, each primitive is an attribute on a handler or channel. The flow is readable in one place, the failure modes are first-class, and adding a new routing rule is an attribute, not a refactor.

    See it built end-to-end: Composing Building Blocks — a walk-through of an Order Fulfillment system that uses every primitive above, with side-by-side Symfony Messenger and Laravel Queues comparisons.

    7. Multi-tenancy down to the message channel

    Multi-tenancy in Ecotone is not just isolated storage. It extends through the messaging topology:

    • Tenant-isolated event streams — each tenant's events live in their own stream. No cross-tenant replay risk, no cross-tenant projection leakage.

    • Tenant-routed message channels — messages are automatically routed to per-tenant queues based on their metadata. One handler, many tenants, no cross-contamination.

    • Priority routing by customer status — a VIP customer's request is dispatched to a fast queue and processed with higher priority; a free-tier request goes through the standard queue. Same handler code, declarative routing.

    • Tenant-aware projections — each tenant gets its own read model, rebuildable in isolation.

    This is multi-tenancy as an architectural property of the message bus, not a WHERE clause in your queries.

    8. Endpoint-ID routing — refactor without fear, deserialize only when needed

    Ecotone routes messages by endpoint ID, not by class name. Three direct consequences:

    • Rename classes, move handlers, change namespaces — nothing breaks. The endpoint ID is the contract. You can move a command handler from one module to another, rename the command class itself, refactor the entire package structure, and every queued message in flight will still be routed to the right handler.

    • Deserialize only when needed. A message passing through a router or pipe is not deserialized until it reaches its actual endpoint. Messages that do not need processing at a given node stay serialized — meaningful throughput gains on high-fanout and pass-through workloads.

    • Protocol-stable integration between services. Published messages stay compatible with consumers as your internal code structure evolves. The fully-qualified class name is not part of the wire contract; the endpoint ID is.

    9. Compose patterns without glue code — the attribute is the wiring

    The reason CQRS, event sourcing, sagas, and projections compose seamlessly is that they are all handlers on the same messaging foundation. There is no wiring layer, no event-to-handler mapping configuration, no container registration, no factory class.

    Four different building blocks — Aggregate, Saga, Projection, async event handler — subscribing to the same event. There is no EventSubscriberInterface list to register, no saga-to-event mapping file, no projection orchestrator. Each subscriber receives its own copy of the message, retries independently, and lives its own lifecycle.

    This is what "one toolkit, every pattern, no glue" means in practice.


    Trusted in regulated and high-stakes production

    Ecotone runs in production at:

    • Payment gateways — where retried handlers cannot double-charge, and the outbox must guarantee that every committed transaction produces exactly one downstream message.

    • Credit card systems — where transaction loss is catastrophic, and every state change must be auditable and replayable.

    • Certification authorities — whose entire business depends on reconstructible, tamper-evident audit trails; the event log is the audit log.

    • E-commerce platforms — orchestrating order → payment → fulfillment → notification as declarative sagas with compensation.

    • Public transportation subscription systems — managing nationwide transit subscriptions (create, renew, terminate) with distributed bus integration to Java and PHP services over Kafka.

    • Two-sided marketplaces — coordinating customer orders, provider subscriptions, lead distribution, and B2B enterprise partnerships on one event-driven backbone.

    The features on the capability matrix below are not hypothetical. They run in systems where failure is either regulated (audit, payments), expensive (double-charges, lost deliveries), or public (transit, marketplaces).


    The Capability Matrix

    Capability
    Included out of the box

    Command / Query / Event bus

    Yes — auto-wired from attributes

    Per-handler failure isolation

    Yes — copy of the message per handler, structural not workaround

    Event Sourcing

    Yes — aggregates, projections, replay, snapshotting

    Read more: Why Ecotone?


    Where Ecotone fits in your stack

    Your framework stays. Your ORM stays. Your queues and transports stay. Your deployment stays. Ecotone is a Composer package that adds architecture on top of what you already run — business logic as POPOs, messaging topology as attributes, and your existing Laravel, Symfony, or PSR-11 container underneath.


    AI-ready by design

    Ecotone's declarative, attribute-based architecture is inherently friendly to AI code generators. When your AI assistant works with Ecotone code, two things happen:

    Less context needed, less code generated. A command handler with #[CommandHandler] and #[Asynchronous('orders')] tells the full story in two attributes — no bus configuration files, no handler registration, no retry setup to feed into the AI's context window.

    AI that knows Ecotone. Your AI assistant can work with Ecotone out of the box:

    • Agentic Skills — Ready-to-use skills that teach any coding agent how to correctly write handlers, aggregates, sagas, projections, tests, and more.

    • MCP Server — Direct access to Ecotone documentation for any AI assistant that supports Model Context Protocol — Claude Code, Cursor, Windsurf, GitHub Copilot, and others.

    • llms.txt — AI-optimized documentation files that give any LLM instant context about Ecotone's API and patterns.

    Declarative configuration that any coding agent can follow and reproduce. Testing support that lets it verify even the most advanced flows. Less guessing, no hallucinating — just confident iteration.


    Start with your framework

    Laravel — Laravel's queue runs jobs, not business processes. Stop stitching Spatie + Durable Workflow + Bus::chain + DIY outbox. Ecotone replaces the patchwork with one attribute-driven toolkit: aggregates with auto-published events, piped workflows, sagas, snapshots, transactional outbox — testable in-process, running on the queues you already have. composer require ecotone/laravel → Laravel Quick Start · Laravel Module docs

    Symfony — Symfony Messenger handles dispatch. For aggregates, sagas, or event sourcing, the usual path is bolting on a separate ES library, rolling your own outbox, and adding a saga or workflow layer on top. Ecotone replaces the patchwork with one attribute-driven toolkit: aggregates, sagas, event sourcing, piped workflows, transactional outbox, dedup, and per-handler failure isolation. Pure POPOs, Bundle auto-config, your Messenger transports preserved. composer require ecotone/symfony-bundle → Symfony Quick Start · Symfony Module docs

    Any PHP framework — Ecotone Lite works with any PSR-11 compatible container. composer require ecotone/lite-application → Ecotone Lite docs


    Proven patterns, proven runtime

    • In continuous development since 2017 — nine years of production.

    • Maintained by a core team of three (Dariusz Gafka, Jean de La Bédoyère, Piotr Zając) and an open-source contributor community.

    • No breaking changes across major versions. Ecotone follows a stability commitment — your business code keeps working across releases, so upgrades are safe to apply. See the changelog.

    • Commercial support, SLA, consulting, and workshops available for teams running Ecotone in production — to arrange a support agreement.

    • Built on Enterprise Integration Patterns — the same pattern language behind Spring Integration, Axon Framework, NServiceBus, MassTransit, and Apache Camel.

    • AI-ready: MCP server, ready-to-use coding-agent skills, llms.txt docs for any LLM.


    Evaluate it in one handler

    You do not need to migrate anything to try Ecotone. You do not need architectural buy-in to evaluate it. Install the package, add #[CommandHandler] to one method, run your tests, and decide.

    • If the pattern fits, add more attributes alongside your existing code — nothing in the rest of your codebase needs to change.

    • If it doesn't fit for this project, remove the package before you've invested. The evaluation is intentionally reversible; the architectural commitment happens later, as your domain actually grows into the deeper features.

    Five-minute install. Familiar patterns from day one. No forced architectural migrations as your system grows.

    • Install — Setup guide for any framework

    • Composing Building Blocks — Full walk-through of the composition model with Messenger / Laravel comparisons

    • Learn by example — Send your first command in 5 minutes

    • — Build a complete messaging flow step by step

    • — Hands-on training for your team

    composer require ecotone/laravel    # or ecotone/symfony-bundle
    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;
        }
    }
    
    class NotificationService
    {
        #[Asynchronous('notifications')]
        #[EventHandler]
        public function whenOrderPlaced(OrderWasPlaced $event, NotificationSender $sender): void
        {
            $sender->sendOrderConfirmation($event->orderId);
        }
    }
    $ecotone = EcotoneLite::bootstrapFlowTesting([OrderService::class]);
    
    $ecotone->sendCommand(new PlaceOrder('order-1'));
    
    $this->assertEquals('placed', $ecotone->sendQueryWithRouting('order.getStatus', 'order-1'));
    $notifier = new InMemoryNotificationSender();
    
    $ecotone = EcotoneLite::bootstrapFlowTesting(
        [OrderService::class, NotificationService::class],
        [NotificationSender::class => $notifier],
        enableAsynchronousProcessing: [
            SimpleMessageChannelBuilder::createQueueChannel('notifications')
        ]
    );
    
    $ecotone
        ->sendCommand(new PlaceOrder('order-1'))
        ->run('notifications');
    
    $this->assertEquals(['order-1'], $notifier->getSentOrderConfirmations());
       Day 1               Week 1                 Month 3                Year 1+
    ──────────────────   ───────────────────   ────────────────────   ──────────────────────
    #[CommandHandler]    + #[Asynchronous]     + #[Saga]              + #[EventSourcing
    #[EventHandler]      + #[Interceptor]      + Workflows              Aggregate]
    #[QueryHandler]      + Retries / DLQ       + transactional        + #[Projection]
                                                 outbox               + #[DistributedBus]
                                                                      + Multi Tenant Channels
    ──────────────────   ───────────────────   ────────────────────   ──────────────────────
      Familiar handlers.   Async with            Stateful workflows.    Event sourcing &
      Five-minute start.   resilience built in.  Outbox-consistent.     distributed bus.
    
              Same classes. Same codebase. Same team. No forced migration between stages.
    #[Aggregate]
    class Order {
        #[CommandHandler]                           // CommandBus routes here
        public static function place(PlaceOrder $command): self {
            $order = new self(/* ... */);
            $order->recordThat(new OrderWasPlaced(/* ... */));  // publishes event
            return $order;
        }
    }
    
    #[Saga]
    class OrderFulfillmentProcess {
        #[EventHandler]                             // subscribes to OrderWasPlaced
        public function onOrderPlaced(OrderWasPlaced $event): void { /* ... */ }
    }
    
    #[Projection(name: 'orders_read_model')]
    class OrdersProjection {
        #[EventHandler]                             // same event, different handler, own message copy
        public function whenOrderPlaced(OrderWasPlaced $event): void { /* ... */ }
    }
    
    class NotificationService {
        #[Asynchronous('notifications')]
        #[EventHandler]                             // same event again, async, independent retry
        public function notify(OrderWasPlaced $event): void { /* ... */ }
    }

    Join — ask questions and share what you're building.

    Free
    Enterprise

    CQRS (Commands, Queries, Events)

    Yes

    Yes

    Event Sourcing & Projections

    Yes

    Yes

    Sagas (Stateful Workflows)

    Yes

    Yes

    Handler Chaining (Pipe & Filter)

    Ecotone Plans

    Ecotone comes with two plans:

    • Ecotone Free comes with Apache License Version 2.0. It provides everything you need to build message-driven systems in PHP -- CQRS, aggregates, event sourcing, sagas, async messaging, interceptors, and full testing support. This covers all features not marked as Enterprise.

    • Ecotone Enterprise adds production-grade capabilities for teams whose systems have grown into multi-tenant, multi-service, or high-throughput environments. It brings advanced workflow orchestration, cross-service communication, resilient command handling, and resource optimization.

    Every Enterprise licence directly funds continued development of Ecotone's open-source core. When Enterprise succeeds, the entire ecosystem benefits.

    Signs You're Ready for Enterprise

    You don't need Enterprise on day one. These are the growth signals that tell you it's time:

    "We're serving multiple tenants and need isolation"

    A noisy tenant's queue backlog shouldn't affect others. Per-tenant scaling shouldn't mean building custom routing infrastructure.

    • Dynamic Message Channels -- Route messages per-tenant at runtime using header-based or round-robin strategies. Declare the routing once, Ecotone manages the rest. Add tenants by updating the mapping -- no handler code changes.

    "We have complex multi-step business processes"

    Business stakeholders ask "what are the steps in this process?" and the answer requires reading multiple files. Adding or reordering steps touches code in many places.

    • Orchestrators -- Define workflow sequences declaratively in one place. Each step is independently testable and reusable. Dynamic step lists adapt to input data without touching step code.

    "We're running multiple services that need to talk to each other"

    Building custom inter-service messaging wiring for each service pair has become unsustainable. Different services use different brokers and you need them to communicate.

    • Distributed Bus with Service Map -- Cross-service messaging that supports multiple brokers (RabbitMQ, SQS, Redis, Kafka) in a single topology. Swap transports without changing application code.

    "Our projections need to scale, rebuild safely, or deploy without downtime"

    A single global projection can't keep up with event volume. Rebuilding wipes the read model for 30 minutes. Changing projection schema means downtime for users.

    • Partitioned Projections -- One partition per aggregate with independent position tracking. Failures isolate to a single aggregate instead of blocking everything. Indexed event loading skips irrelevant events for dramatically faster processing. Works with both sync and async execution.

    • Async Backfill & Rebuild -- Push backfill and rebuild to asynchronous background workers with asyncChannelName. Combined with partitioned projections, the work is split into batches that multiple workers process in parallel — throughput scales linearly with worker count. A backfill that takes 2 hours with 1 worker takes 12 minutes with 10.

    • Blue-Green Deployments -- Deploy a new projection version alongside the old one. The new version catches up from history while the old one serves traffic. Switch when ready, delete the old one. Zero downtime.

    • -- Consume events directly from Kafka or RabbitMQ Streams instead of the database event store. For cross-system integration and external event sources.

    • -- Accumulate state in memory across a batch of events and persist once at flush. Process 1000 events with zero database writes, then one bulk insert. Dramatically faster rebuilds.

    "We need high-throughput event streaming"

    RabbitMQ throughput is becoming a bottleneck, or multiple services need to consume the same event stream independently.

    • Kafka Integration -- Native Kafka support with the same attribute-driven programming model. No separate producer/consumer boilerplate.

    • RabbitMQ Streaming Channel -- Kafka-like persistent event streaming on existing RabbitMQ infrastructure. Multiple independent consumers with position tracking.

    "Our production system needs to be resilient"

    Transient failures cause unnecessary handler failures. Duplicate commands from user retries or webhooks lead to double-processing. Exception handling is scattered across handlers.

    • Command Bus Instant Retries -- Recover from transient failures (deadlocks, network blips) with a single #[InstantRetry] attribute. No manual retry loops.

    • Command Bus Error Channel -- Route failed synchronous commands to dedicated error handling with #[ErrorChannel]. Replace scattered try/catch blocks with centralized error routing.

    • Gateway-Level Deduplication -- Prevent duplicate command processing at the bus level. Every handler behind that bus is automatically protected.

    "We want less infrastructure code in our domain"

    Repository injection boilerplate obscures business logic. Every handler follows the same fetch-modify-save pattern. Making the entire bus async requires annotating every handler individually.

    • Instant Aggregate Fetch -- Aggregates arrive in your handler automatically via #[Fetch]. No repository injection, just business logic.

    • Event Sourcing Handlers with Metadata -- Pass metadata to #[EventSourcingHandler] for context-aware aggregate reconstruction without polluting event payloads.

    • Asynchronous Message Buses -- Make an entire command or event bus async with a single configuration change, instead of annotating every handler.

    "We need per-handler control over async endpoint behavior"

    Database transactions are globally enabled for your message channel, but some handlers only call a 3rd party API or send emails — wrapping them in a transaction wastes connections and holds locks unnecessarily.

    • Asynchronous Execution Attributes -- Pass asynchronousExecution attributes on #[Asynchronous] to selectively disable transactions, message collectors, route failures to per-handler Error Channels, or inject custom configuration for specific handlers while keeping global defaults for the rest of the channel.

    "We need production-grade RabbitMQ consumption"

    Custom consumer scripts need manual connection handling, reconnection logic, and shutdown management.

    • Rabbit Consumer -- Set up RabbitMQ consumption with a single attribute. Built-in reconnection, graceful shutdown, and health checks out of the box.

    Materials

    Links

    • Ecotone Enterprise and Kafka, Distributed Bus, Dynamic Channels [Article]

    • Implementing Event-Driven Architecture [Article]

    Each Enterprise feature is marked with hint on the documentation page. Enterprise features can only be run with licence key. To evaluate Enterprise features, email us at "[email protected]" to receive trial key. Production license keys are available at .

    Logo
    quickstart-examples/MultiTenant/Laravel/MessageBus at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/MultiTenant/Laravel/EventSourcing at main · ecotoneframework/quickstart-examplesGitHub

    Lesson 4: Metadata and Method Invocation

    PHP Metadata and Method Invocation

    Not having code for Lesson 4? git checkout lesson-4

    Metadata

    Message can contain of Metadata. Metadata is just additional information stored along side to the Message's payload. It may contain things like currentUser, timestamp, contentType, messageId.

    In Ecotone headers and metadata means the same. Those terms will be used interchangeably.

    To test out Metadata, let's assume we just got new requirement for our Products in Shopping System.:

    User who registered the product, should be able to change it's price.

    Let's start by adding ChangePriceCommand

    We will handle this Command in a minute. Let's first add user information for registering the product. We will do it, using Metadata. Let's get back to our Testing Class EcotoneQuickstart and add 4th argument to our CommandBus call.

    sendWithRouting accepts 4th argument, which is associative array. Whatever we will place in here, will be available during message handling for us - This actually our Metadata. It's super simple to pass new Headers, it's matter of adding another key to the array. Now we can change our Product aggregate:

    We have added second parameter $metadata to our CommandHandler. Ecotone read parameters and evaluate what should be injected. We will see soon, how can we take control of this process. We can add changePrice method now to our Aggregate:

    And let's call it with incorrect userId and see, if we get the exception.

    Method Invocation

    We have been just informed, that customers are registering new products in our system, which should not be a case. Therefore our next requirement is:

    Only administrator should be allowed to register new Product

    Let's create simple UserService which will tell us, if specific user is administrator. In our testing scenario we will suppose, that only user with id of 1 is administrator.

    Now we need to think where we should call our UserService. The good place for it, would not allow for any invocation of product.register command without being administrator, otherwise our constraint may be bypassed. Ecotone does allow for auto-wire like injection for endpoints. All services registered in Depedency Container are available.

    Great, there is no way to bypass the constraint now. The isAdmin constraint must be satisfied in order to register new product. Let's correct our testing class.

    Injecting arguments

    Ecotone inject arguments based on Parameter Converters. Parameter converters , tells Ecotone how to resolve specific parameter and what kind of argument it is expecting. The one used for injecting services like UserService is Reference parameter converter. Let's see how could we use it in our product.register command handler.

    Let's suppose UserService is registered under user-service in Dependency Container. Then we would need to set up the CommandHandlerlike below.

    Reference- Does inject service from Dependency Container. If referenceName, which is name of the service in the container is not given, then it will take the class name as default.

    Payload - Does inject payload of the . In our case it will be the command itself

    Headers - Does inject all headers as array.

    Header - Does inject single header from the . There is more to be said about this, but at this very moment, it will be enough for us to know that such possibility exists in order to continue. You may read more detailed description in \

    Default Converters

    Ecotone, if parameter converters are not passed provides default converters. First parameter is always Payload. The second parameter, if is array then Headers converter is taken, otherwise if class type hint is provided for parameter, then Reference converter is picked. If we would want to manually configure parameters for product.register Command Handler, then it would look like this:

    We could also inject specific header and let Ecotone convert it directly to specific object (if we have Converter registered):

    CQRS Introduction - Commands

    Commands CQRS PHP

    In this section, we will look at how to use Commands, Events, and Queries. This will help you understand the basics of Ecotone’s CQRS support and how to build a message-driven application.

    Command Handlers are methods where we typically place our business logic, so we’ll start by exploring how to use them.

    Handling Commands

    Any service available in your Dependency Container can become a Command Handler. Command Handlers are responsible for performing business actions in your system. In Ecotone-based applications, you register a Command Handler by adding the CommandHandler attribute to the specific method that should handle the command:

    In the example above, the #[CommandHandler] attribute tells Ecotone that the "createTicket" method should handle the CreateTicketCommand.

    The first parameter of a Command Handler method determines which command type it handles — in this case, it is CreateTicketCommand.

    Sending Commands

    We send a Command using the Command Bus. After installing Ecotone, all Buses are automatically available in the Dependency Container, so we can start using them right away. Before we can send a Command, we first need to define it:

    To send a command, we use the send method on the CommandBus. The command gets automatically routed to its corresponding Command Handler

    Sending Commands with Metadata

    We can send commands with metadata (also called Message Headers) through the Command Bus. This lets us include additional context that doesn't belong in the command itself, or share information across multiple Command Handlers without duplicating it in each command class.

    And then to access given metadata, we will be using Header attribute:

    The #[Header] attribute tells Ecotone to fetch a specific piece of metadata using the key executorId. This way, Ecotone knows exactly which metadata value to pass into our Command Handler.

    Injecting Services into Command Handler

    If we need additional services from the Dependency Container to handle our business logic, we can inject them into our Command Handler using the #[Reference] attribute:

    In case Service is defined under custom id in DI, we may pass the reference name to the attribute:

    Sending Commands via Routing

    In Ecotone we may register Command Handlers under routing instead of a class name. This is especially useful if we will register to tell Ecotone how to deserialize given Command. This way we may simplify higher level code like Controllers or Console Line Commands by avoid transformation logic.

    Routing without Command Classes

    There may be cases where creating Command classes is unnecessary boilerplate, in those situations, we may simplify the code and make use scalars, arrays or non-command classes directly.

    Returning Data from Command Handler

    Sometimes we need to return a value immediately after handling a command. This is useful for scenarios that require instant feedback—for example, when processing a payment, we might need to return a redirect URL to guide the user to the payment gateway. Ecotone's allows for returning data from Command Handler, that will be available as a result from your CommandBus:

    The returned data will be available as result of the Command Bus.

    Sending Commands with deserialization

    When any mechanism is configured (For example ), we can let Ecotone do the deserialization in-fly, so we don't need to both with doing custom transformations in the Controller:

    Event Handling

    Event CQRS PHP

    Be sure to read CQRS Introduction before diving in this chapter.

    The difference between Events and Command is in intention. Commands are meant to trigger an given action and events are information that given action was performed successfully.

    Handling Events

    To register Event Handler, we will be using EventHandler attribute. By marking given method as Event Handler, we are stating that this method should subscribe to specific Event Class:

    In above scenario we are subscribing to TicketWasCreated Event, therefore whenever this Event will be published, this method will be automatically invoked. Events are Plain Old PHP Objects:

    Publishing Events

    To publish Events, we will be using EventBus. EventBus is available in your Dependency Container by default, just like Command and Query buses. You may use Ecotone's invocation control, to inject Event Bus directly into your Command Handler:

    Multiple Subscriptions

    Unlike Command Handlers which points to specific Command Handler, Event Handlers can have multiple subscribing Event Handlers.

    Subscribe to Interface or Abstract Class

    If your Event Handler is interested in all Events around specific business concept, you may subscribe to Interface or Abstract Class.

    And then instead of subscribing to TicketWasCreated or TicketWasCancelled, we will subscribe to TicketEvent.

    Subscribing by Union Classes

    We can also subscribe to different Events using union type hint. This way we can ensure that only given set of events will be delivered to our Event Handler.

    Subscribing to All Events

    We may subscribe to all Events published within the application. To do it we type hint for generic object.

    Subscribing to Events by Routing

    Events can also be subscribed by Routing.

    And then Event is published with routing key

    Subscribing to Events by Routing and Class Name

    There may be situations when we will want to subscribe given method to either routing or class name. Ecotone those subscriptions separately to protect from unnecessary wiring, therefore to handle this case, we can simply add another Event Handler which is not based on routing key.

    This way we explicitly state that we want to subscribe by class name and by routing key.

    Sending Events with Metadata

    Just like with Command Bus, we may pass metadata to the Event Bus:

    Metadata Propagation

    By default Ecotone will ensure that your Metadata is propagated. This way you can simplify your code by avoiding passing around Headers and access them only in places where it matters for your business logic.

    To better understand that, let's consider example in which we pass the metadata to the Command.

    However in order to perform closing ticket logic, information about the executorId is not needed, so we don't access that.

    However Ecotone will ensure that your metadata is propagated from Handler to Handler. This means that the context is preserved and you will be able to access executorId in your Event Handler.

    Logo
    quickstart-examples/MultiTenant/Symfony/Events at main · ecotoneframework/quickstart-examplesGitHub
    quickstart-examples/MultiTenant/Symfony/EventSourcing at main · ecotoneframework/quickstart-examplesGitHub

    Partitioned projections (parallel rebuild across workers, by aggregate / tenant / hash)

    Yes

    Streaming projections (durable per-projector cursor, push-based catch-up)

    Yes

    Non-blocking projection rebuild (blue-green + concurrent async backfill across workers, scales to millions of events)

    Yes — #[ProjectionDeployment] + #[ProjectionBackfill] + aggregate-ID partitioning

    Partition-scoped rebuild (rebuild a single aggregate's data in milliseconds while other partitions keep serving reads)

    Yes — #[PartitionAggregateId] in #[ProjectionReset]; effectively zero downtime even during full rebuilds

    Self-healing projections (trigger-based execution — projection always reads from Event Store at its last committed position; fix-deploy recovery, no manual reset or backfill script)

    Yes — default behavior; events never lost after a crash

    Gap detection (sequence gaps from concurrent commits are tracked and retried; events never silently skipped)

    Yes — enabled by default, non-blocking, bounded by maxGapOffset and gapTimeout

    Batched projection persistence (accumulate state across events via #[ProjectionState]; single #[ProjectionFlush] per batch; automatic Doctrine EntityManager flush + clear prevents OOM during multi-million-event catch-up)

    Yes — #[ProjectionExecution(eventLoadingBatchSize: N)]

    Projection-emitted events (downstream eventual-consistency notifications via EventStreamEmitter)

    Yes — emitted events fan out through the event bus to sagas, handlers, and other projections; automatic emission suppression during rebuild so downstream consumers aren't flooded with duplicate historical events

    Sagas (stateful long-running workflows)

    Yes

    Handler chaining workflows (stateless pipe-and-filter)

    Yes

    Content-based routing (route by payload or headers)

    Yes

    Splitters (fan-out)

    Yes

    Filters, transformers, enrichers

    Yes — EIP primitives as attributes

    Priority (sync ordering + native broker priority for async)

    Yes — one attribute, uniform across Event Handlers, Projections, Sagas

    Custom buses per use case

    Yes — extend CommandBus / EventBus / QueryBus, attach #[Deduplicated], #[ErrorChannel], #[InstantRetry] directly to the interface declaration

    Endpoint-ID message routing (class-name-independent)

    Yes — refactor-safe, lazy deserialization

    Transactional outbox

    Yes — DBAL + per-transport

    Dead letter queue + replay

    Yes

    Retries with configurable backoff

    Yes

    Deduplication

    Yes

    Async transports

    RabbitMQ, Kafka, SQS, Redis, DBAL

    Distributed Bus (cross-service messaging)

    Yes

    Multi-tenancy — tenant-isolated event streams, projections, channels

    Yes

    Tenant-routed message channels

    Yes — messages auto-routed to per-tenant queues

    Priority routing (VIP/standard queues by runtime context)

    Yes

    End-to-end PII encryption (event store + broker messages + log output)

    Yes — #[Sensitive] attribute; channel-level ChannelProtectionConfiguration for payload + named headers

    Automatic correlation / causation propagation

    Yes — correlation ID and parent-message ID travel from command to every emitted event with no middleware; full OpenTelemetry spans stitch themselves

    OpenTelemetry tracing

    Yes — sync and async hops

    Interceptors (cross-cutting concerns via attributes)

    Yes

    Framework portability (Laravel ↔ Symfony ↔ PSR-11)

    Yes

    In-process async testing (EcotoneLite)

    Yes

    dead letter queue
    OpenTelemetry integration
    contact us
    Go through tutorial
    Workshops, Support, Consultancy
    Ecotone's Community Channel

    Yes

    Yes

    Async Messaging (RabbitMQ, SQS, Redis)

    Yes

    Yes

    Retries & Dead Letter

    Yes

    Yes

    Outbox Pattern

    Yes

    Yes

    Interceptors (Middlewares)

    Yes

    Yes

    Testing Support

    Yes

    Yes

    Multi-Tenancy

    Yes

    Yes

    OpenTelemetry

    Yes

    Yes

    Orchestrators

    Yes

    Distributed Bus with Service Map

    Yes

    Dynamic Message Channels

    Yes

    Partitioned Projections

    Yes

    Blue-Green Deployments

    Yes

    Kafka Integration

    Yes

    Command Bus Instant Retries

    Yes

    Gateway-Level Deduplication

    Yes

    Streaming Projections
    High-Performance Flush State
    https://ecotone.tech
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo

    Let's run our testing command:

    Let's run our testing command:

    Great, we have just finished Lesson 4!

    In this Lesson we learned about using Metadata to provide extra information to our Message. Besides we took a look on how arguments are injected into endpoint and how we can make use of it. Now we will learn about powerful Interceptors, which can be describes as Middlewares on steroids.

    message
    message
    Method Invocation section.

    In case of Command Handlers there may be only single Handler for given Command Class. This is not a case for Event Handlers, multiple Event Handler may subscribe to same Event Class.

    You may inject any other Service available in your Dependency Container, into your Message Handler methods.

    Each Event Handler can be defined as Asynchronous. If multiple Event Handlers are marked for asynchronous processing, each of them is handled in isolation. This ensures that in case of failure, we can safely retry, as only failed Event Handler will be performed again.

    Ecotone is using message routing for cross application communication. This way applications can stay decoupled from each other, as there is no need to share the classes between them.

    If you make your Event Handler Asynchronous, Ecotone will ensure your metadata will be serialized and deserialized correctly.

    In Ecotone, the class itself is not a Command Handler — only the specific method is. This means you can place multiple Command Handlers inside the same class, to make correlated actions available under same API class.

    If you are using autowiring, all your classes are registered in the container under their class names. This means Ecotone can automatically resolve them without any extra configuration.

    If your service is registered under a different name in the Dependency Container, you can use ClassReference to point Ecotone to the correct service:

    All Messages (Commands, Queries, and Events), as well as Message Handlers, are just plain PHP objects. They don’t need to extend or implement any Ecotone-specific classes. This keeps your business code clean, simple, and easy to understand.

    If we use Asynchronous Command Handler, Ecotone will ensure our metadata will be serialized and deserialized correctly.

    Ecotone is using message routing for cross application communication. This way applications can stay decoupled from each other, as there is no need to share the classes between them.

    Ecotone provides flexibility which allows to create Command classes when there are actually needed. In other cases we may use routing functionality together with simple types in order to fulfill our business logic.

    Keep in mind that return values only work with synchronous Command Handlers. For asynchronous handlers, we can't return values directly because the command is processed in the background—instead, we'd use events or callbacks to communicate results back to the user when processing completes.

    Converters
    Serialization
    JMS
    namespace App\Domain\Product;
    
    class ChangePriceCommand
    {
        private int $productId;
    
        private Cost $cost;
    
        public function getProductId() : int
        {
            return $this->productId;
        }
    
        public function getCost() : Cost
        {
            return $this->cost;
        }
    }
    public function run() : void
    {
        $this->commandBus->sendWithRouting(
            "product.register",
            \json_encode(["productId" => 1, "cost" => 100]),
            "application/json",
            metadata: [
                "userId" => 1
            ]
        );
                
        echo $this->queryBus->sendWithRouting("product.getCost", \json_encode(["productId" => 1]), "application/json");
    }
    #[Aggregate]
    class Product
    {
        use WithAggregateEvents;
    
        #[Identifier]
        private int $productId;
    
        private Cost $cost;
    
        private int $userId;
    
        private function __construct(int $productId, Cost $cost, int $userId)
        {
            $this->productId = $productId;
            $this->cost = $cost;
            $this->userId = $userId;
    
            $this->recordThat(new ProductWasRegisteredEvent($productId));
        }
    
        #[CommandHandler("product.register")]
        public static function register(RegisterProductCommand $command, array $metadata) : self
        {
            return new self(
                $command->getProductId(), 
                $command->getCost(), 
                // all metadata is available for us. 
                // Ecotone automatically inject it, if second param is array
                $metadata["userId"]
            );
        }
    #[CommandHandler("product.changePrice")]
    public function changePrice(ChangePriceCommand $command, array $metadata) : void
    {
        if ($metadata["userId"] !== $this->userId) {
            throw new \InvalidArgumentException("You are not allowed to change the cost of this product");
        }
    
        $this->cost = $command->getCost();
    }
    public function run() : void
    {
        $this->commandBus->sendWithRouting(
            "product.register",
            \json_encode(["productId" => 1, "cost" => 100]),
            "application/json",
            [
                "userId" => 5
            ]
        );
    
        $this->commandBus->sendWithRouting(
            "product.changePrice",
            \json_encode(["productId" => 1, "cost" => 110]),
            "application/json",
            [
                "userId" => 3
            ]
        );        
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    
    InvalidArgumentException
                                                              
      You are not allowed to change the cost of this product 
    namespace App\Domain\Product;
    
    class UserService
    {
        public function isAdmin(int $userId) : bool
        {
            return $userId === 1;
        }
    }
    #[CommandHandler("product.register")]
    public static function register(
        RegisterProductCommand $command, 
        array $metadata, 
        // Any non first class argument, will be considered an DI Service to inject
        UserService $userService
    ) : self
    {
        $userId = $metadata["userId"];
        if (!$userService->isAdmin($userId)) {
            throw new \InvalidArgumentException("You need to be administrator in order to register new product");
        }
    
        return new self($command->getProductId(), $command->getCost(), $userId);
    }
    public function run() : void
    {
        $this->commandBus->sendWithRouting(
            "product.register",
            \json_encode(["productId" => 1, "cost" => 100]),
            "application/json",
            [
                "userId" => 1
            ]
        );
    
        $this->commandBus->sendWithRouting(
            "product.changePrice",
            \json_encode(["productId" => 1, "cost" => 110]),
            "application/json",
            [
                "userId" => 1
            ]
        );
    
        echo $this->queryBus->sendWithRouting("product.getCost", \json_encode(["productId" => 1]), "application/json");
    }
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    110
    Good job, scenario ran with success!
    #[CommandHandler("product.register")]
    public static function register(
        RegisterProductCommand $command, 
        array $metadata, 
        #[Reference("user-service")] UserService $userService
    ) : self
    #[CommandHandler("product.register")]
    public static function register(
        #[Payload] RegisterProductCommand $command, 
        #[Headers] array $metadata, 
        #[Reference] UserService $userService
    ) : self
    {
        // ...
    }
    #[CommandHandler("product.register")]
    public static function register(
        #[Payload] RegisterProductCommand $command, 
        // injecting specific header and doing the conversion string to UserId
        #[Header("userId")] UserId $metadata, 
        #[Reference] UserService $userService
    ) : self
    {
        // ...
    }
    class TicketService
    {
        #[EventHandler] 
        public function when(TicketWasCreated $event): void
        {
            // handle event
        }
    }
    class readonly TicketWasCreated
    {
        public function __construct(
            public string $ticketId
        ) {}
    }
    class TicketService
    {
        #[CommandHandler] 
        public function createTicket(
            CreateTicketCommand $command,
            EventBus $eventBus
        ) : void
        {
            // handle create ticket command
            
            $eventBus->publish(new TicketWasCreated($ticketId));
        }
    }
    class TicketService
    {
        #[EventHandler] 
        public function when(TicketWasCreated $event): void
        {
            // handle event
        }
    }
    
    class NotificationService
    {
        #[EventHandler] 
        public function sendNotificationX(TicketWasCreated $event): void
        {
            // handle event
        }
        
        #[EventHandler] 
        public function sendNotificationY(TicketWasCreated $event): void
        {
            // handle event
        }
    }
    interface TicketEvent
    {
    }
    class readonly TicketWasCreated implements TicketEvent
    {
        public function __construct(
            public string $ticketId
        ) {}
    }
    
    class readonly TicketWasCancelled implements TicketEvent
    {
        public function __construct(
            public string $ticketId
        ) {}
    }
    #[EventHandler]
    public function notify(TicketEvent $event) : void
    {
       // do something with $event
    }
    #[EventHandler]
    public function notify(TicketWasCreated|TicketWasCancelled $event) : void
    {
       // do something with $event
    }
    #[EventHandler]
    public function log(object $event) : void
    {
       // do something with $event
    }
    class TicketService
    {
        #[EventHandler("ticket.was_created")] 
        public function when(TicketWasCreated $event): void
        {
            // handle event
        }
    }
    class TicketService
    {
        #[CommandHandler] 
        public function createTicket(
            CreateTicketCommand $command,
            EventBus $eventBus
        ) : void
        {
            // handle create ticket command
            
            $eventBus->publishWithRouting(
                "ticket.was_created",
                new TicketWasCreated($ticketId)
            );
        }
    }
    class TicketService
    {
        #[EventHandler]
        #[EventHandler("ticket.was_created")] 
        public function when(TicketWasCreated $event): void
        {
            // handle event
        }
    }
    class TicketService
    {
        #[CommandHandler] 
        public function createTicket(
            CreateTicketCommand $command,
            EventBus $eventBus
        ) : void
        {
            // handle create ticket command
            
            $eventBus->publish(
                new TicketWasCreated($ticketId),
                metadata: [
                    "executorId" => $command->executorId()
                ]
            );
        }
    }
    class TicketService
    {
        #[EventHandler] 
        public function when(
            TicketWasCreated $event,
            // access metadata with given name
            #[Header("executorId")] string $executorId
        ): void
        {
            // handle event
        }
    }
    class TicketController
    {
       public function __construct(private CommandBus $commandBus) {}
       
       public function closeTicketAction(Request $request, Security $security) : Response
       {
          $this->commandBus->send(
             new CloseTicketCommand($request->get("ticketId")),
             ["executorId" => $security->getUser()->getId()]
          );
       }
    }
    $messagingSystem->getCommandBus()->send(
       new CloseTicketCommand($ticketId),
       ["executorId" => $executorId]
    );
    class TicketService
    {   
        #[CommandHandler]
        public function closeTicket(
            CloseTicketCommand $command, 
            EventBus $eventBus
        )
        {     
            // close the ticket
                 
            // we simply publishing an Event, we don't pass any metadata here 
            $eventBus->publish(new TicketWasCreated($ticketId));
        }   
    }
    class AuditService
    {
        #[EventHandler] 
        public function log(
            TicketWasCreated $event,
            // access metadata with given name
            #[Header("executorId")] string $executorId
        ): void
        {
            // handle event
        }
    }
    class TicketService
    {
        #[CommandHandler] 
        public function createTicket(CreateTicketCommand $command) : void
        {
            // handle create ticket command
        }
    }
    class readonly CreateTicketCommand
    {
        public function __construct(
            public string $priority,
            public string $description
        ){}
    }
    class TicketController
    {
       // Command Bus will be auto registered in Depedency Container.
       public function __construct(private CommandBus $commandBus) {}
       
       public function createTicketAction(Request $request) : Response
       {
          $this->commandBus->send(
             new CreateTicketCommand(
                $request->get("priority"),
                $request->get("description"),            
             )
          );
          
          return new Response();
       }
    }
    $messagingSystem->getCommandBus()->send(
        new CreateTicketCommand(
            $priority,
            $description,            
         )
    );
    class TicketController
    {
       public function __construct(private CommandBus $commandBus) {}
       
       public function closeTicketAction(Request $request, Security $security) : Response
       {
          $this->commandBus->send(
             new CloseTicketCommand($request->get("ticketId")),
             ["executorId" => $security->getUser()->getId()]
          );
       }
    }
    $messagingSystem->getCommandBus()->send(
       new CloseTicketCommand($ticketId),
       ["executorId" => $executorId]
    );
    class TicketService
    {   
        #[CommandHandler]
        public function closeTicket(
            CloseTicketCommand $command, 
            // by adding Header attribute we state what metadata we want to fetch
            #[Header("executorId")] string $executorId
        ): void
        {          
    //        handle closing ticket with executor from metadata
        }   
    }
    class TicketService
    {   
        #[CommandHandler]
        public function closeTicket(
            CloseTicketCommand $command, 
            #[Reference] AuthorizationService $authorizationService
        ): void
        {          
    //        handle closing ticket with executor from metadata
        }   
    }
    #[Reference("authorizationService")] AuthorizationService $authorizationService
    class TicketController
    {
       public function __construct(private CommandBus $commandBus) {}
       
       public function createTicketAction(Request $request) : Response
       {
          $commandBus->sendWithRouting(
             "createTicket", 
             $request->getContent(),
             "application/json" // we tell what format is used in the request content
          );
          
          return new Response();
       }
    }
    $messagingSystem->getCommandBus()->sendWithRouting(
       "createTicket", 
       $data,
       "application/json"
    );
    class TicketService
    {   
        // Ecotone will do deserialization for the Command
        #[CommandHandler("createTicket")]
        public function createTicket(CreateTicketCommand $command): void
        {
    //        handle creating ticket
        }   
    }
    class TicketController
    {
       private CommandBus $commandBus;
    
       public function __construct(CommandBus $commandBus)
       {
           $this->commandBus = $commandBus;   
       }
       
       public function closeTicketAction(Request $request) : Response
       {
          $commandBus->sendWithRouting(
             "closeTicket", 
             Uuid::fromString($request->get("ticketId"))
          );
          
          return new Response();
       }
    }
    $messagingSystem->getCommandBus()->sendWithRouting(
       "closeTicket", 
       Uuid::fromString($ticketId)
    );
    class TicketService
    {   
        #[CommandHandler("closeTicket")]
        public function closeTicket(UuidInterface $ticketId): void
        {
    //        handle closing ticket
        }   
    }
    class PaymentService
    {   
        #[CommandHandler]
        public function closeTicket(MakePayment $command): Url
        {
    //        handle making payment
    
            return $paymentUrl;
        }   
    }
    $redirectUrl = $this->commandBus->send($command);
       public function createTicketAction(Request $request) : Response
       {
          $ticketId = $this->commandBus->send(
                routingKey: 'createTicket',
                command: $request->getContent(),  // Ecotone will deserialize Command in-fly
                commandMediaType: 'application/json',
          );
          
          return new Response([
                'ticketId' => $ticketId
          ]);
       }
    #[ClassReference("ticketService")]
    class TicketService
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo
    Logo

    Backfill and Rebuild

    PHP Event Sourcing Projection Backfill and Rebuild

    The Problem

    You deployed a new "order analytics" projection to production, but it only processes events from now on. You have 2 years of order history sitting in the event store. How do you populate the projection with historical data? And later, when you fix a bug in the projection logic, how do you replay everything?

    Choosing the Right Strategy

    Before reaching for rebuild, consider the lighter alternatives. The cheapest fix is the one that doesn't require replaying any history at all.

    Situation
    Strategy
    Cost

    The No-Rebuild Tactic: Default Values

    If you are adding a new column to a projection, the first conversation to have is with Product, not with ops: can historical rows use a default value?

    Often yes. "We're adding a priority column — historical tickets without explicit priority can show as normal" is a five-minute deploy: extend #[ProjectionInitialization] with an idempotent migration, and the new handler computes the real value for events from now on.

    #[ProjectionInitialization] re-runs on every deploy, and IF NOT EXISTS keeps both statements idempotent. Historical rows get 'normal'; new tickets get their real priority from the updated handler. No rebuild, no backfill, no downtime.

    This will not always work — sometimes you genuinely need to recompute historical rows. But when it does, it skips the entire backfill/rebuild discussion.

    Backfill — Populating a New Projection

    Backfill processes all historical events from position 0 to the current position. It's used when you deploy a fresh projection and need to populate it with past data.

    Sync Backfill

    Add #[ProjectionBackfill] to your projection and run the CLI command:

    Then run:

    The backfill reads all events from the beginning of the stream, processing them in . After backfill completes, the projection is caught up and will process new events as they arrive.

    Async Backfill (Enterprise)

    For large event stores with millions of events, synchronous backfill may take too long — it runs in the CLI process and blocks until all events are processed. By setting asyncChannelName, the backfill command instead dispatches messages to a channel, turning the backfill into an asynchronous background process:

    Run the backfill command (dispatches messages instantly), then start workers to process them:

    Scaling Async Backfill with Partitioned Projections

    The real power of async backfill comes when combined with #[Partitioned]. Each partition (aggregate) can be backfilled independently, so the work is split into batches that multiple workers process in parallel:

    When you run the backfill command with 10,000 aggregates and backfillPartitionBatchSize: 100:

    1. Ecotone dispatches 100 messages to backfill_channel (10,000 / 100)

    2. Each message backfills 100 partitions

    3. Start 4 workers → 4 batches processed in parallel → 4x faster

    Rebuild — Reset and Replay (Enterprise)

    Rebuild is different from backfill: it resets an existing projection (clears data and position) and then replays all events from the beginning.

    Use rebuild when:

    • You fixed a bug in a handler and the Read Model has incorrect data

    • You changed the projection's schema and need to reprocess everything

    • You want to add a new event handler to an existing projection and apply it retroactively

    How rebuild works depends on the projection type — and the difference is significant.

    Rebuilding a Global Projection

    For a globally tracked projection, rebuild works as reset + backfill on the entire dataset:

    1. #[ProjectionReset] is called — clears all data (e.g., DELETE FROM ticket_list)

    2. Position is reset to the beginning

    3. All events in the stream are replayed through the handlers

    Rebuilding a Partitioned Projection

    For partitioned projections, rebuild is much safer. Instead of resetting the entire projection at once, Ecotone rebuilds each partition (aggregate) separately:

    1. For each partition: within a transaction, delete that partition's projected data and re-project it

    2. Other partitions are unaffected — they continue serving reads normally

    3. Only one aggregate's data is unavailable at a time, and only briefly

    Notice the key difference: #[ProjectionReset] receives #[PartitionAggregateId] — it only deletes the data for the specific aggregate being rebuilt, not the entire table.

    Controlling Rebuild Batch Size

    The partitionBatchSize parameter controls how many partitions are processed per rebuild command:

    With 1000 aggregates and partitionBatchSize: 50, Ecotone dispatches 20 rebuild commands — each processing 50 partitions.

    Scaling Rebuild with Async Workers

    For large projections, you can distribute rebuild work across multiple workers:

    When you run ecotone:projection:rebuild ticket_details:

    1. Ecotone counts the partitions (e.g., 1000 aggregates)

    2. Divides them into batches of 50 → 20 messages

    3. Sends all 20 messages to rebuild_channel

    This means you can rebuild a projection with millions of aggregates by simply scaling up your worker count. Just like with , throughput scales linearly with the number of workers.

    Run the rebuild command, then start workers:

    Sync Rebuild

    Without asyncChannelName, rebuild runs synchronously — all partitions are processed in the current process:

    Backfill vs Rebuild

    Backfill
    Rebuild (Global)
    Rebuild (Partitioned)

    Projection Introduction

    PHP Event Sourcing Projections

    The Problem

    Once you start storing storing events instead of updating rows — you will quickly find out your users still need a ticket list page, a dashboard, a report. How do you turn a stream of "what happened" into a table you can query?

    In traditional applications, when a ticket is created you run an INSERT, when it's closed you run an UPDATE. The database always holds the current state. But with Event Sourcing, you store what happened — TicketWasRegistered, TicketWasClosed — as an append-only log of events.

    Think of it like a bank account: instead of storing "balance = 500", you store every deposit and withdrawal. The balance is derived by replaying the history.

    But your users don't want to replay history every time they load a page. They need a ready-to-query table. That's what Projections do.

    Demo and Materials

    You will find extensive article series on Ecotone's Projection System on the blog. This will help understand essentials from the very beginning up to running Projections in production:

    What is a Projection?

    A Projection reads events from an Event Stream (the append-only log) and builds a read-optimized view from them — a database table, a document, a cache entry. Think of it as a materialized view built from events.

    The views built by Projections are called Read Models. They exist only for reading and can be rebuilt at any time from the Event Stream.

    The Git Mental Model

    If you are new to projections, this analogy carries you a long way:

    Git
    Event Sourcing

    The Event Stream is the authoritative history. Read Models are views of that history — you can have many of them, each one optimized for a different question (a ticket list, a counter, a search index). Throwing one away never destroys data, because the history is still there. Building a second one from scratch is just replaying the commits into a new working directory.

    This is also why projections are safe to evolve aggressively. When you fix a bug in a Read Model, you have not corrupted any "truth" — only a view of it. Rebuild the view from the events and it is correct again.

    From these events, we want to build a list of all tickets with their current status:

    Building Your First Projection

    Let's say we have a Ticket Event Sourced Aggregate that produces two events — TicketWasRegistered and TicketWasClosed. We want to build a read model table showing all in-progress tickets.

    That's all you need. Let's break down what each part does:

    1. #[ProjectionV2('ticket_list')] — marks this class as a Projection with name ticket_list

    2. #[FromAggregateStream(Ticket::class)] — tells the Projection to read events from the Ticket aggregate's stream

    3. #[ProjectionInitialization]

    There is no additional configuration needed. Ecotone takes care of delivering events, initializing, and triggering the Projection.

    Position Tracking

    Each Projection remembers where it left off in the Event Stream — like a bookmark in a book. When a new event triggers the Projection, it fetches only the events after its last position.

    This means:

    • New Projections start from the beginning of the stream and catch up to the present

    • Existing Projections only process new events they haven't seen yet

    • After a failure, the Projection resumes from its last successfully committed position

    This is what makes it possible to deploy a new Projection at any point in time and have it automatically build up from the full event history.

    Feature Overview

    Ecotone Projections come in two editions. The open-source edition covers the full projection lifecycle for globally tracked projections. Enterprise adds scaling, advanced operations, and deployment strategies.

    Feature
    Open Source
    Enterprise

    What's Next

    • — control which events reach your projection

    • — sync, async, and when to use each

    • — CLI commands, initialization, reset

    Introduction

    Message Driven System with Domain Driven Design principles in PHP

    What Ecotone Gives You

    Ecotone is a messaging framework that brings enterprise architecture patterns to PHP. It provides the infrastructure for CQRS, Event Sourcing, Sagas, Distributed Messaging, and Production Resilience — so you write business logic, not boilerplate.

    Everything in Ecotone is built around Messages. Commands express intentions ("place this order"), Events express facts ("order was placed"), and Queries express questions ("what are this user's orders?"). This isn't just a naming convention — it's the architectural foundation that enables async processing, resilience, workflows, and distributed systems.

    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

    Logo
    Logo
    Logo
    Logo
    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.

    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.

    Deleting the Projection

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

    Demo

    Example implementation using Ecotone Lite.

    The emitted event must live in the same storage as the read model.

    This is the rule that makes the whole pattern work. The emitted event, the read model write, and the position advancement all commit together in a single transaction. If you push the derived event straight to Kafka or RabbitMQ from inside the handler, the message flies out before the projection commits — the subscriber picks it up, queries the read model, and sees stale data. You are back where you started.

    EventStreamEmitter writes to the Event Store, so the guarantee holds. If you need to bridge the derived event to an external broker afterwards, do it from a downstream handler that subscribes to the emitted event — by the time that handler runs, the read model is committed.

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

    All emitted events are stored in streams, so you can create another projection that subscribes to them — building derived views from derived views.

    linkTo works from any place in the code. emit stores events in the projection's own stream and only works inside a projection.

    This is the key difference between using EventStreamEmitter versus EventBus. The EventBus would simply republish events during a rebuild, causing duplicates. EventStreamEmitter suppresses them.

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

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

    #[ProjectionV2('wallet_balance')]
    #[FromAggregateStream(Wallet::class)]
    class WalletBalanceProjection
    {
        #[EventHandler]
        public function whenMoneyWasAdded(
            MoneyWasAddedToWallet $event,
            EventStreamEmitter $eventStreamEmitter
        ): void {
            $wallet = $this->getWalletFor($event->walletId);
            $wallet = $wallet->add($event->amount);
            $this->saveWallet($wallet);
    
            $eventStreamEmitter->emit([
                new WalletBalanceWasChanged($event->walletId, $wallet->currentBalance)
            ]);
        }
    
        #[EventHandler]
        public function whenMoneyWasSubtracted(
            MoneyWasSubtractedFromWallet $event,
            EventStreamEmitter $eventStreamEmitter
        ): void {
            $wallet = $this->getWalletFor($event->walletId);
            $wallet = $wallet->subtract($event->amount);
            $this->saveWallet($wallet);
    
            $eventStreamEmitter->emit([
                new WalletBalanceWasChanged($event->walletId, $wallet->currentBalance)
            ]);
        }
    
        (...)
    }
    #[ProjectionV2('wallet_balance')]
    #[FromAggregateStream(Wallet::class)]
    #[Partitioned]
    class WalletBalanceProjection
    {
        #[EventHandler]
        public function whenMoneyWasAdded(
            MoneyWasAddedToWallet $event,
            #[ProjectionState] ?array $state
        ): array {
            $state ??= ['walletId' => $event->walletId, 'balance' => 0];
            $state['balance'] += $event->amount;
            return $state;
        }
    
        #[EventHandler]
        public function whenMoneyWasSubtracted(
            MoneyWasSubtractedFromWallet $event,
            #[ProjectionState] array $state
        ): array {
            $state['balance'] -= $event->amount;
            return $state;
        }
    
        #[ProjectionFlush]
        public function flush(
            #[ProjectionState] array $state,
            EventStreamEmitter $emitter
        ): void {
            $this->saveWallet($state['walletId'], $state['balance']);
    
            $emitter->emit([
                new WalletBalanceWasChanged($state['walletId'], $state['balance'])
            ]);
        }
    }
    class NotificationService
    {
        #[EventHandler]
        public function when(WalletBalanceWasChanged $event): void
        {
            // Send WebSocket notification — the Read Model is already up to date
        }
    }
    $eventStreamEmitter->linkTo('wallet', [
        new WalletBalanceWasChanged($event->walletId, $wallet->currentBalance)
    ]);
    $emitter->linkTo('completed_orders', [
        new OrderFullyCompleted(
            orderId: $state['orderId'],
            customerId: $state['customerId'],
            totalAmount: $state['totalAmount'],
            currency: $state['currency'],
            paidAt: $state['paidAt'],
            shippedAt: $state['shippedAt'],
            carrier: $state['carrier'],
        )
    ]);
    #[ProjectionV2('order_lifecycle')]
    #[FromAggregateStream(Order::class)]
    #[FromAggregateStream(Payment::class)]
    #[FromAggregateStream(Shipment::class)]
    class OrderLifecycleProjection
    {
        #[EventHandler]
        public function onOrderPlaced(
            OrderPlaced $event,
            #[ProjectionState] ?array $state
        ): array {
            return [
                'orderId' => $event->orderId,
                'customerId' => $event->customerId,
                'totalAmount' => $event->totalAmount,
                'currency' => $event->currency,
                'ordered' => true,
                'paid' => false,
                'shipped' => false,
            ];
        }
    
        #[EventHandler]
        public function onPaymentSettled(
            PaymentSettled $event,
            #[ProjectionState] array $state
        ): array {
            $state['paid'] = true;
            $state['paidAt'] = $event->settledAt;
            return $state;
        }
    
        #[EventHandler]
        public function onShipmentDelivered(
            ShipmentDelivered $event,
            #[ProjectionState] array $state
        ): array {
            $state['shipped'] = true;
            $state['shippedAt'] = $event->deliveredAt;
            $state['carrier'] = $event->carrier;
            return $state;
        }
    
        #[ProjectionFlush]
        public function flush(
            #[ProjectionState] array $state,
            EventStreamEmitter $emitter
        ): void {
            if ($state['ordered'] && $state['paid'] && $state['shipped']) {
                $emitter->linkTo('completed_orders', [
                    new OrderFullyCompleted(
                        orderId: $state['orderId'],
                        customerId: $state['customerId'],
                        totalAmount: $state['totalAmount'],
                        currency: $state['currency'],
                        paidAt: $state['paidAt'],
                        shippedAt: $state['shippedAt'],
                        carrier: $state['carrier'],
                    )
                ]);
            }
        }
    }
    raw domain events
        → wallet_balance projection
            emits WalletBalanceWasChanged
        → high_value_wallets projection
            emits HighValueWalletDetected
        → risk_assessment projection
            emits ComplianceCheckTriggered
    #[ProjectionV2('wallet_balance')]
    #[FromAggregateStream(Wallet::class)]
    #[ProjectionDeployment(live: false)]
    class WalletBalanceProjection
    {
        // EventStreamEmitter calls are silently skipped — no events are stored or published
    }

    Rebuild (in-place)

    Read model empty during rebuild

    Fixing a bug in a large or user-facing projection

    v1 keeps serving while v2 catches up

    Start 10 workers → 10x faster
    Multiple workers consume from rebuild_channel in parallel
  • Each worker rebuilds its batch of 50 partitions independently

  • Per-partition data cleared

    Calls reset?

    No

    Yes — entire table

    Yes — per aggregate

    Impact during run

    None (table is new)

    Table empty until done

    Only one aggregate briefly affected

    Parallel workers?

    Via async backfill

    Via async channel

    Via async channel + partition batches

    When to use

    First deployment

    Bug fix (simple projections)

    Bug fix (production, at scale)

    Open source?

    Yes (sync)

    Enterprise

    Enterprise

    Adding a column where historical rows can use a default value

    Default value migration (no replay)

    Five-minute deploy

    Adding a brand-new projection that has no data yet

    Backfill

    One pass over history

    #[ProjectionInitialization]
    public function init(): void
    {
        $this->connection->executeStatement(<<<SQL
            CREATE TABLE IF NOT EXISTS ticket_list (
                ticket_id VARCHAR(36) PRIMARY KEY,
                ticket_type VARCHAR(25),
                status VARCHAR(25)
            )
        SQL);
    
        $this->connection->executeStatement(<<<SQL
            ALTER TABLE ticket_list
            ADD COLUMN IF NOT EXISTS priority VARCHAR(25) NOT NULL DEFAULT 'normal'
        SQL);
    }
    #[ProjectionV2('order_analytics')]
    #[FromAggregateStream(Order::class)]
    #[ProjectionBackfill]
    class OrderAnalyticsProjection
    {
        #[EventHandler]
        public function onOrderPlaced(OrderWasPlaced $event): void
        {
            // This will process ALL historical OrderWasPlaced events during backfill
        }
    
        #[ProjectionInitialization]
        public function init(): void { /* CREATE TABLE */ }
    
        #[ProjectionReset]
        public function reset(): void { /* DELETE FROM */ }
    }
    bin/console ecotone:projection:backfill order_analytics
    artisan ecotone:projection:backfill order_analytics
    $messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'order_analytics']);
    #[ProjectionV2('order_analytics')]
    #[FromAggregateStream(Order::class)]
    #[ProjectionBackfill(asyncChannelName: 'backfill_channel')]
    class OrderAnalyticsProjection
    {
        // Same handlers as above
    }
    # Dispatches backfill messages to the channel
    bin/console ecotone:projection:backfill order_analytics
    
    # Start the worker (only ONE for a global projection — see note below)
    bin/console ecotone:run backfill_channel -vvv
    artisan ecotone:projection:backfill order_analytics
    artisan ecotone:run backfill_channel -vvv
    #[ProjectionV2('order_analytics')]
    #[FromAggregateStream(Order::class)]
    #[Partitioned]
    #[ProjectionBackfill(backfillPartitionBatchSize: 100, asyncChannelName: 'backfill_channel')]
    class OrderAnalyticsProjection
    {
        // Same handlers
    }
    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    #[ProjectionRebuild]
    class TicketListProjection
    {
        #[ProjectionReset]
        public function reset(): void
        {
            // Clears ALL data — entire table
            $this->connection->executeStatement('DELETE FROM ticket_list');
        }
    
        // ... event handlers
    }
    #[ProjectionV2('ticket_details')]
    #[FromAggregateStream(Ticket::class)]
    #[Partitioned]
    #[ProjectionRebuild(partitionBatchSize: 50)]
    class TicketDetailsProjection
    {
        #[ProjectionReset]
        public function reset(#[PartitionAggregateId] string $aggregateId): void
        {
            // Resets only THIS aggregate's data — not the whole table
            $this->connection->executeStatement(
                'DELETE FROM ticket_details WHERE ticket_id = ?',
                [$aggregateId]
            );
        }
    
        // ... event handlers
    }
    #[ProjectionRebuild(partitionBatchSize: 50)]
    #[ProjectionV2('ticket_details')]
    #[FromAggregateStream(Ticket::class)]
    #[Partitioned]
    #[ProjectionRebuild(partitionBatchSize: 50, asyncChannelName: 'rebuild_channel')]
    class TicketDetailsProjection
    {
        // ... same as above
    }
    # Trigger the rebuild (dispatches messages)
    bin/console ecotone:projection:rebuild ticket_details
    
    # Start workers (run multiple for parallel processing)
    bin/console ecotone:run rebuild_channel -vvv
    artisan ecotone:projection:rebuild ticket_details
    artisan ecotone:run rebuild_channel -vvv
    #[ProjectionRebuild(partitionBatchSize: 50)]
    // No asyncChannelName — rebuild happens immediately during CLI command

    Purpose

    Populate a new, empty projection

    Fix existing projection

    Fix existing projection

    Starting state

    Fresh (no data)

    Backfill runs synchronously and is available in the open-source edition.

    Run only one worker for a global projection. The position tracker is a single value: two workers consuming the same backfill channel would race to advance it, and one would overwrite the other's commit. Multiple workers only help once you can describe each unit of work as "one aggregate's events" — which is what partitioning gives you (next section).

    With partitioned projections, both backfill and rebuild scale linearly with worker count. A backfill that takes 2 hours with 1 worker takes 12 minutes with 10 workers.

    Async backfill is available as part of Ecotone Enterprise.

    Rebuild is available as part of Ecotone Enterprise.

    Global rebuild deletes all data first, then repopulates. During the rebuild window, the Read Model is empty or incomplete. This can also lock the table depending on your database. For zero-downtime alternatives, see Blue-Green Deployments.

    Partitioned rebuilds also isolate failures. If a handler bug only triggers on one aggregate's specific event sequence, only that partition gets stuck. The rest of the rebuild keeps progressing across the other aggregates. You can investigate the failing partition without an entire rebuild stalling for hours waiting for someone to wake up — and once you ship the fix, the stuck partition retries from where it stopped.

    A global rebuild has the opposite property: one bad partition blocks the whole queue.

    During rebuild, the Read Model is being repopulated. If you need zero-downtime rebuilds, see Blue-Green Deployments.

    configurable batches
    async backfill

    Fixing a bug in a small projection where downtime during rebuild is acceptable

    All data cleared first

    Evolve Live Projections Without Downtime

    A second Read Model from the same events

    git reset --hard

    #[ProjectionReset]

    — called when the Projection is first set up (creates the table)
  • #[EventHandler] — subscribes to specific event types. Ecotone routes events by the type-hint.

  • #[ProjectionDelete] and #[ProjectionReset] — called when the projection is deleted or reset

  • Yes

    Yes

    (init, delete, reset, trigger)

    Yes

    Yes

    Yes

    Yes

    Yes

    Yes

    (EventStreamEmitter)

    Yes

    Yes

    Yes

    Yes

    Yes

    Yes

    Yes

    Yes

    Yes

    Yes

    —

    Yes

    —

    Yes

    (Kafka, RabbitMQ)

    —

    Yes

    (parallel workers for partitioned)

    —

    Yes

    (sync and async with parallel workers)

    —

    Yes

    —

    Yes

    —

    Yes

    —

    Yes

    Custom extensions (StreamSource, StateStorage, PartitionProvider)

    —

    Yes

    Projections with State — keep state between events without external storage
  • Emitting Events — notify after the projection is up to date

  • Backfill and Rebuild — populate with historical data

  • Failure Handling — transactions, rollback, self-healing

  • Gap Detection — how Ecotone guarantees no events are lost

  • Scaling and Advanced — partitioned, streaming, polling (Enterprise)

  • Blue-Green Deployments — zero-downtime projection changes (Enterprise)

  • Commit history

    Event Stream

    Working directory

    Read Model

    git checkout

    Projection running

    #[ProjectionV2('ticket_list')]
    #[FromAggregateStream(Ticket::class)]
    class TicketListProjection
    {
        public function __construct(private Connection $connection) {}
    
        #[ProjectionInitialization]
        public function init(): void
        {
            $this->connection->executeStatement(<<<SQL
                CREATE TABLE IF NOT EXISTS ticket_list (
                    ticket_id VARCHAR(36) PRIMARY KEY,
                    ticket_type VARCHAR(25),
                    status VARCHAR(25)
                )
            SQL);
        }
    
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            $this->connection->insert('ticket_list', [
                'ticket_id' => $event->ticketId,
                'ticket_type' => $event->type,
                'status' => 'open',
            ]);
        }
    
        #[EventHandler]
        public function onTicketClosed(TicketWasClosed $event): void
        {
            $this->connection->update(
                'ticket_list',
                ['status' => 'closed'],
                ['ticket_id' => $event->ticketId]
            );
        }
    
        #[ProjectionDelete]
        public function delete(): void
        {
            $this->connection->executeStatement('DROP TABLE IF EXISTS ticket_list');
        }
    
        #[ProjectionReset]
        public function reset(): void
        {
            $this->connection->executeStatement('DELETE FROM ticket_list');
        }
    }

    Global (non-partitioned) projection

    Yes

    Yes

    Synchronous event-driven execution

    Yes

    Yes

    Why Projections Exist — Your First Read Model
    Your Projections Will Fail — Make Them Resilient
    When One Worker Can't Keep Up: Scaling Projections
    Stop Subscribing to Domain Events
    Event Streams and Handlers
    Execution Modes
    Lifecycle Management
    Events stored in the Event Stream
    Read Model: list of tickets with current status

    A second clone of the same repo

    Built on Enterprise Integration Patterns

    Ecotone is built on Enterprise Integration Patterns — the same foundation that powers Spring Integration (Java), NServiceBus (.NET), and Apache Camel. Communication between objects happens through Message Channels — pipes where one side sends messages and the other consumes them.

    Communication between Objects using Messages

    Because communication goes through Message Channels, switching from synchronous to asynchronous, or from one message broker to another, doesn't affect your business code. You change the channel configuration — your handlers stay the same.

    Application level code

    Ecotone provides different levels of abstractions, which we can choose to use from. Each abstraction is described in more details in related sections. In this Introduction section we will go over high level on how things can be used, to show what is Message based communication about.

    Command Handlers

    Let's discuss our example from the above screenshot, where we want to register User and trigger Notification Sender. In Ecotone flows, we would introduce Command Handler being responsible for user registration:

    As you can see, we also inject Event Bus which will publish our Event Message of User Was Registered.

    In our Controller we would inject Command Bus to send the Command Message:

    After sending Command Message, our Command Handler will be executed. Command and Event Bus are available in our Dependency Container after Ecotone installation out of the box.

    Click, to find out more...

    Event Handlers

    We mentioned Notification Sender to be executed when User Was Registered Event happens. For this we follow same convention of using Attributes:

    This Event Handler will be automatically triggered when related Event will be published. This way we can easily build decoupled flows, which hook in into existing business events.

    Click, to find out more...

    Command Handlers with Routing

    From here we could decide to make use Message routing functionality to decouple Controllers from constructing Command Messages.

    with this in mind, we can now user CommandBus with routing and even let Ecotone deserialize the Command, so our Controller does not even need to be aware of transformations:

    Click, to find out more...

    Interceptors

    What we could decide to do is to add so called Interceptors (middlewares) to our Command Bus to add additional data or perform validation or access checks.

    Pointcut provides the point which this interceptor should trigger on. In above scenario it will trigger when Command Bus is triggered before Message is send to given Command Handler. The reference attribute stays that given parameter is Service from Dependency Container and Ecotone should inject it.

    Click, to find out more...

    Message Metadata

    When we send Command using Command Bus, Ecotone under the hood construct a Message. Message contains of two things - payload and metadata. Payload is our Command and metadata is any additional information we would like to carry.

    Metadata can be easily then accessed from our Command Handler or Interceptors

    Besides metadata that we do provide, Ecotone provides additional metadata that we can use whenever needed, like Message Id, Correlation Id, Timestamp etc.

    Click, to find out more...

    Asynchronous Processing

    As we mentioned at the beginning of this introduction, communication happen through Message Channels, and thanks to that it's really easy to switch code from synchronous to asynchronous execution. For that we would simply state that given Message Handler should be executed asynchronously:

    Now before this Event Handler will be executed, it will land in Asynchronous Message Channel named "async" first, and from there it will be consumed asynchronously by Message Consumer (Worker process).

    Click, to find out more...

    Aggregates

    If we are using Eloquent, Doctrine ORM, or Models with custom storage implementation, we could push our implementation even further and send the Command directly to our Model.

    We are marking our model as Aggregate, this is concept from Domain Driven Design, which describe a model that encapsulates the business logic.

    Like you can see, we also added "block()" method, which will block given user. Yet it does not hold any Command as parameter. In this scenario we don't even need Command Message, because the logic is encapsulated inside nicely, and passing a status from outside could actually allow for bugs (e.g. passing UserStatus::active). Therefore all we want to know is that there is intention to block the user, the rest happens within the method.

    To execute our block method we would call Command Bus this way:

    There is one special metadata here, which is "aggregate.id", this tell Ecotone the instance of User which it should fetch from storage and execute this method on. There is no need to create Command Class at all, because there is no data we need to pass there. This way we can build features with ease and protect internal state of our Models, so they are not modified in incorrect way.

    Click, to find out more...

    Workflows

    One of the powers that Message Driven Architecture brings is ability to build most sophisticated workflows with ease. This is possible thanks, because each Message Handler is considered as Endpoint being able to connect to input and output Channels. This is often referenced as pipe and filters architecture, but in general this is characteristic of true message-driven systems.

    Let's suppose that our registered user can apply for credit card, and for this we need to pass his application through series of steps to verify if it's safe to issue credit card for him:

    We are using outputChannelName here to indicate where to pass Message after it's handled by our Command Handler. In here we could enrich our CardApplication with some additional data, or create new object. However it's fully fine to pass same object to next step, if there was no need to modify it.

    Let's define now location where our Message will land after:

    We are using here InternalHandler, internal handlers are not connected to any Command or Event Buses, therefore we can use them as part of the workflow steps, which we don't want to expose outside.

    Our Internal Handler contains of inputChannelName which points to the same channel as our Command Handlers outputChannelName. This way we bind Message Handlers together to create workflows. As you can see we also added Asynchronous attribute, as process of identity verification can take a bit of time, we would like it to happen in background.

    Let's define our last step in Workflow:

    This we've made synchronous which is the default if no Asynchronous attribute is defined. Therefore it will be called directly after Identity verification.

    Click, to find out more...

    Inbuilt Resiliency

    Ecotone handles failures at the architecture level to make Application clear of those concerns. As Messages are the main component of communication between Applications, Modules or even Classes in Ecotone, it creates space for recoverability in all parts of the Application. As Messages can be retried instantly or with delay without blocking other processes from continuing their work.

    Message failed and will be retried with delay

    As Message are basically data records which carry the intention, it opens possibility to store that "intention", in case unrecoverable failure happen. This means that when there is no point in delayed retries, because we encountered unrecoverable error, then we can move that Message into persistent store. This way we don't lose the information, and when the bug is fixed, we can simply retry that Message to resume the flow from the place it failed.

    Storing Message for later review, and replaying when bug is fixed

    There are of course more resiliency patterns, that are part of Ecotone, like:

    • Automatic retries to send Messages to Asynchronous Message Channels

    • Reconnection of Message Consumers (Workers) if they lose the connection to the Broker

    • Inbuilt functionalities like Message Outbox, Error Channels with Dead Letter, Deduplication of Messages to avoid double processing,

    • and many many more.

    Click, to find out more...

    Business Oriented Architecture

    Ecotone shifts the focus from technical details to the actual business processes, using Resilient Messaging as the foundation on which everything else is built. It provides seamless communication using Messages between Applications, Modules or even different Classes.

    Together with that we will be using Declarative Configuration with attributes to avoid writing and maintaining configuration files. We will be stating intention of what we want to achieve instead wiring things ourselves, as a result we will regain huge amount of time, which can be invested in more important part of the System. And together with that, we will be able to use higher level Build Blocks like Command, Event Handlers, Aggregates, Sagas which connects to the messaging seamlessly, and helps encapsulate our business logic. So all the above serves as pillars for creating so called Business Oriented Architecture:

    When all thee pillars are solved by Ecotone, what is left to write is Business Oriented Code
    1. Resilient Messaging - At the heart of Ecotone lies a resilient messaging system that enables loose coupling, fault tolerance, and self-healing capabilities.

    2. Declarative Configuration - Introduces declarative programming with Attributes. It simplifies development, reduces boilerplate code, and promotes code readability. It empowers developers to express their intent clearly, resulting in more maintainable and expressive codebases.

    3. Building Blocks - Building blocks like Message Handlers, Aggregates, Sagas, facilitate the implementation of the business logic. By making it possible to bind Building Blocks with Resilient Messaging, Ecotone makes it easy to build and connect even the most complex business workflows.

    Having this foundation knowledge and understanding how Ecotone works on the high level, it's good moment to dive into Tutorial section, which will provide hands on experience to deeper understanding.

    Materials

    Ecotone blog provides articles which describes Ecotone's architecture and related features in more details. Therefore if you want to find out more, follow bellow links:

    Links

    • Robust and Developer Friendly Architecture in PHP

    • Practical Domain Driven Design

    • Reactive and Message Driven Systems in PHP

    Works with: Laravel, Symfony, and Standalone PHP

    class UserService
    {
        #[CommandHandler]
        public function register(RegisterUser $command, EventBus $eventBus): void
        {
            // store user
            
            $eventBus->publish(new UserWasRegistered($userId));
        |
    }
    public function registerAction(Request $request, CommandBus $commandBus): Response
    {
        $command = // construct command
        $commandBus->send($command);
    }
    class NotificationSender
    {
        #[EventHandler]
        public function when(UserWasRegistered $event): void
        {
            // send notification
        }
    }
    #[CommandHandler(routingKey: "user.register")]
    public function register(RegisterUser $command, EventBus $eventBus): void
    public function registerAction(Request $request, CommandBus $commandBus): Response
    {
        $commandBus->sendWithRouting(
            routingKey: "user.register", 
            command: $request->getContent(), 
            commandMediaType: "application/json"
        );
    }
    #[Before(pointcut: CommandBus::class)]
    public function validateAccess(
        RegisterUser $command,
        #[Reference] AuthorizationService $authorizationService
    ): void
    {
        if (!$authorizationService->isAdmin) {
            throw new AccessDenied();
        }
    }
    public function registerAction(Request $request, CommandBus $commandBus): Response
    {
        $commandBus->sendWithRouting(
            routingKey: "user.register", 
            command: $request->getContent(), 
            commandMediaType: "application/json",
            metadata: [
                "executorId" => $this->currentUser()->getId()
            ]
        );
    }
    #[CommandHandler(routingKey: "user.register")]
    public function register(
        RegisterUser $command, 
        EventBus $eventBus,
        #[Header("executorId")] string $executorId,
    ): void
    #[Asynchronous("async")]
    #[EventHandler]
    public function when(UserWasRegistered $event): void
    #[Aggregate]
    class User
    {
        use WithEvents;
    
        #[Identifier]
        private UserId     $userId;
        private UserStatus $status;
    
        #[CommandHandler(routingKey: "user.register")]
        public static function register(RegisterUser $command): self
        {
            $user = //create user
            $user->recordThat(new UserWasRegistered($userId));
            
            return $user;
        }
        
        #[CommandHandler(routingKey: "user.block")]
        public function block(): void
        {
            $this->status = UserStatus::blocked;
        }
    }
    public function blockAction(Request $request, CommandBus $commandBus): Response
    {
        $commandBus->sendWithRouting(
            routingKey: "user.block", 
            metadata: [
                "aggregate.id" => $request->get('userId'),
            ]
        );
    }
    class CreditCardApplicationProcess
    {
        #[CommandHandler(
            routingKey: "apply_for_card",
            outputChannelName: "application.verify_identity"
        )]
        public function apply(CardApplication $application): CardApplication
        {
            // store card application
            
            return $application;
        }
    }
    #[Asynchronous("async")]
    #[InternalHandler(
        inputChannelName: "application.verify_identity",
        outputChannelName: "application.send_result"
    )]
    public function verifyIdentity(CardApplication $application): ApplicationResult
    {
        // do the verification
        
        return new ApplicationResult($result);
    }
    #[InternalHandler(
        inputChannelName: "application.send_result"
    )]
    public function sendResult(ApplicationResult $application): void
    {
        // send result
    }

    Command and Events are the sole of higher level Messaging. On the low level everything is Message, yet each Message can either be understood as Command (intention to do), or Event (fact that happened). This make the clear distinction between - what we want to happen vs what actually had happened.

    What is important here is that, Ecotone never forces us to implement or extend Framework specific classes. This means that our Command or Event Messages are POPO (clean PHP objects). In most of the scenarios we will simply mark given method with Attribute and Ecotone will glue the things for us.

    Even so Commands and Events are Messages at the fundamental level, Ecotone distinguish them because they carry different semantics. By design Commands can only have single related Command Handler, yet Events can have multiple subscribing Event Handlers. This makes it easy for Developers to reason about the system and making it much easier to follow, as the difference between Messages is built in into the architecture itself.

    When controllers simply pass through incoming data to Command Bus via routing, there is not much logic left in controllers. We could even have single controller, if we would be able to get routing key. It's really up to us, what work best in context of our system.

    There are multiple different interceptors that can hook at different moments of the flow. We could hook before Message is sent to Asynchronous Channel, or before executing Message Handler. We could also state that we want to hook for all Command Handlers or Event Handlers. And in each step we can decide what we want to do, like modify the messages, stop the flow, enforce security checks.

    Ecotone take care of automatic Metadata propagation, no matter if execution synchronous or asynchronous. Therefore we can easily access any given metadata in targeted Message Handler, and also in any sub-flows like Event Handlers. This make it really easy to carry any additional information, which can not only be used in first executed Message Handler, but also in any flow triggered as a result of that.

    There maybe situations where multiple Asynchronous Event Handlers will be subscribing to same Event. We can easily imagine that one of them may fail and things like retries become problematic (As they may trigger successful Event Handlers for the second time). That's why Ecotone introduces , which deliver a copy of the Message to each related Event Handler separately. As a result each Asynchronous Event Handler is handling it's own Message in full isolation, and in case of failure only that Handler will be retried.

    Ecotone will take care of loading the Aggregate and storing them after the method is called. Therefore all we need to do it to send an Command.

    Ecotone provides ability to pass same object between workflow steps. This simplify the flow a lot, as we are not in need to create custom objects just for the framework needs, therefore we stick what is actually needed from business perspective.

    It's really up to us whatever we want to define Message Handlers in separate classes or not. In general due to declarative configuration in form of Attributes, we could define the whole flow within single class, e.g. "CardApplicationProcess". Workflows can also be started from Command or Event Handlers, and also directly through Business Interfaces. This makes it easy to build and connect different flows, and even reuse steps when needed.

    Workflows in Ecotone are fully under our control defined in PHP. There is no need to use 3rd party, or to define the flows within XMLs or YAMLs. This makes it really maintainable solution, which we can change, modify and test them easily, as we are fully on the ownership of the process from within the code. It's worth to mention that workflows are in general stateless as they pass Messages from one pipe to another. However if we would want to introduce statefull Workflow we could do that using Ecotone's Sagas.

    The flow that Ecotone based on the Messages makes the Application possibile to handle failures at the architecture level. By communicating via Messages we are opening for the way, which allows us to self-heal our application without the need for us intervene, and in case of unrecoverable failures to make system robust enough to not lose any information and quickly recover from the point o failure when the bug is fixed.

    Lesson 3: Converters

    PHP Conversion

    Not having code for Lesson 3? git checkout lesson-3

    Conversion

    Command, queries and events are not always objects. When they travel via different asynchronous channels, they are converted to simplified format, like JSON or XML. At the level of application however we want to deal with PHP format as objects or arrays.

    Moving from one format to another requires conversion. Ecotone does provide extension points in which we can integrate different Media Type Converters to do this type of conversion.

    First Media Type Converter

    Let's build our first converter from JSON to our PHP format. In order to do that, we will need to implement Converter interface and mark it with MediaTypeConverter().

    1. TypeDescriptor - Describes type in PHP format. This can be class, scalar (int, string), array etc.

    2. MediaType - Describes Media type format. This can be application/json, application/xml etc.

    3. $source - is the actual data to be converted.

    Let's start with implementing matches method. Which tells us, if this converter can do conversion from one type to another.

    This will tell Ecotone that in case source media type is JSON and target media type is PHP, then it should use this converter. Now we can implement the convert method. We will do pretty naive solution, just for the proof the concept.

    And let's add fromArray method to RegisterProductCommand and GetProductPriceQuery.

    If we call our testing command now, everything is going fine, but we still send PHP objects instead of JSON, therefore there was not need for Conversion. In order to start sending Commands and Queries in different format, we need to provide our handlers with routing key. This is because we do not deal with Object anymore, therefore we can't do the routing based on them.

    Let's change our Testing class, so we call buses with JSON format.

    We make use of different method now sendWithRouting. It takes as first argument routing key to which we want to send the message. The second argument describes the format of message we send. Third is the data to send itself, in this case command formatted as JSON.

    Ecotone JMS Converter

    Normally we don't want to deal with serialization and deserialization, or we want to make the need for configuration minimal. This is because those are actually time consuming tasks, which are more often than not a copy/paste code, which we need to maintain.

    Ecotone comes with integration with to solve this problem. It introduces a way to write to reuse Converters and write them only, when that's actually needed. Therefore let's replace our own written Converter with JMS one. Let's download the Converter using .

    composer require ecotone/jms-converter

    Let's remove __construct and fromArray methods from RegisterProductCommand GetProductPriceQuery, and the JsonToPHPConverter class completely, as we won't need it anymore.

    Do you wonder, how come, that we just deserialized our Command and Query classes without any additional code? JMS Module reads properties and deserializes according to type hint or docblock for arrays. It's pretty straight forward and logical:

    Let's imagine we found out, that we have bug in our software. Our system users have registered product with negative price, which in result lowered the bill.

    Product should be registered only with positive cost

    We could put constraint in Product, validating the Cost amount. But this would assure us only in that place, that this constraint is met. Instead we want to be sure, that the Cost is correct, whenever we make use of it, so we can avoid potential future bugs. This way we will know, that whenever we will deal with Cost object, we will now it's correct. To achieve that we will create Value Object named Cost that will handle the validation, during the construction.

    Great, but where to convert the integer to the Cost class? We really don't want to burden our business logic with conversions. Ecotone JMS does provide extension points, so we can tell him, how to convert specific classes.

    Let's create class App\Infrastructure\Converter\CostConverter. We will put it in different namespace, to separate it from the domain.

    We mark the methods with Converter attribute, so Ecotone can read parameter type and return type in order to know, how he can convert from scalar/array to specific class and vice versa. Let's change our command and aggregate class, so it can use the Cost directly.

    The $cost class property will be automatically converted from integer to Cost by JMS Module.

    Interceptors (Middlewares)

    PHP Interceptors Middlewares

    The Problem

    You added an admin-only check to one Command Handler. Then to a second. By the fifth, the check has drifted in three places — one uses executorId === 1, another $user->isAdmin(), the third forgot to check at all. The same drift hits transaction wrappers, audit logging, authorization, and tracing. You want one place to declare these cross-cutting rules and target which handlers they apply to.

    How Ecotone Solves It

    If you've used Symfony Messenger middleware or Laravel's pipeline, Interceptors are the same idea — code that wraps a handler — with one extra capability: a pointcut that picks exactly which handlers the interceptor applies to. You declare the rule once, attach it to a class, namespace, or attribute, and Ecotone runs it for every matching message.

    Interceptor

    Before Attribute

    Type of Interceptor more about it

    Precedence

    Precedence defines ordering of called interceptors. The lower the value is, the quicker Interceptor will be called. It's safe to stay with range between -1000 and 1000, as numbers bellow -1000 and higher than 1000 are used by Ecotone. The precedence is done within a specific .

    Pointcut

    Every interceptor has Pointcut attribute, which describes for specific interceptor, which endpoints it should intercept.

    • CLASS_NAME - indicates intercepting specific class or interface or class containing attribute on method or class level

    • CLASS_NAME::METHOD_NAME - indicates intercepting specific method of class

    • NAMESPACE*

    Interceptor Types

    There are four types of interceptors. Each interceptor has it role and possibilities. Interceptors are called in following order:

    • Before

    • Around

    • After

    Before Interceptor

    Before Interceptor is called after message is sent to the channel, before execution of Endpoint.

    - Exceptional Interceptor

    Before interceptor is called before endpoint is executed. Before interceptors can used in order to stop the flow, throw an exception or enrich the To understand it better, let's follow an example, where we will intercept Command Handler with verification if executor is an administrator. Let's start by creating Attribute called RequireAdministrator in new namepace.

    Let's create our first Before Interceptor.

    We are using in here which is looking for #[RequireAdministrator] annotation in each of registered . The void return type is expected in here. It tells Ecotonethat, this Before Interceptor is not modifying the Message and message will be passed through. The message flow however can be interrupted by throwing exception.

    Now we need to annotate our Command Handler:

    Whenever we call our command handler, it will be intercepted by AdminVerificator now.

    - Payload Enriching Interceptor

    If return type is not void new Message will be created from the returned type. Let's follow an example, where we will enrich payload with timestamp.

    - Header Enriching Interceptor

    Suppose we want to add executor Id, but as this is not part of our Command, we want add it to our Headers.

    If return type is not void new modified based on previous Message will be created from the returned type. If we additionally add changeHeaders: true it will tell Ecotone, that we we want to modify Message headers instead of payload.

    - Message Filter Interceptor

    Use Message Filter, to eliminate undesired messages based on a set of criteria. This can be done by returning null from interceptor, if the flow should proceed, then payload should be returned.

    If return type is not void new modified based on previous Message will be created from the returned type. If we additionally add changeHeaders=trueit will tell Ecotone, that we we want to modify Message headers instead of payload.

    Around Interceptor

    The Around Interceptor have access to actual Method Invocation.This does allow for starting action before method invocation is done, and finishing it after.

    Around interceptoris a good place for handling actions like Database Transactions or logic that need to access invoked object.

    As we used Command Bus interface as pointcut, we told Ecotone that it should intercept Command Bus Gateway. Now whenever we will call any method on Command Bus, it will be intercepted with transaction. The other powerful use case for Around Interceptor is intercepting Aggregate. Suppose we want to verify, if executing user has access to the Aggregate.

    We have placed @IsOwnerOfPerson annotation as the top of class. For interceptor pointcut it means, that each endpoint defined in this class should be intercepted. No need to add it on each Command Handler now.

    We've passed the executd Aggregate instance - Person to our Interceptor. This way we can get the context of the executed object in order to perform specific logic.

    After Interceptor

    After interceptor is called after endpoint execution has finished. It does work exactly the same as After interceptor can used to for example to enrich QueryHandler result.

    We will intercept all endpoints within Order\ReadModel namespace, by adding result coming from the endpoint under result key.

    Presend Interceptor

    Presend Interceptor is called before Message is actually send to the channel. In synchronous channel there is no difference between Before and Presend. The difference is seen when the channel is .

    Presend Interceptor can used to verify if data is correct before sending to asynchronous channel, or we may want to check if user has enough permissions to do given action. This will keep our asynchronous channel free of incorrect messages.

    Scaling and Advanced

    PHP Event Sourcing Projection Scaling

    The Problem

    Your projection processes events for 100,000 aggregates through a single global stream and it can't keep up. Or you need to consume events from Kafka instead of the database event store. How do you scale projections horizontally?

    The features described on this page are available as part of Ecotone Enterprise.

    Comparing Projection Types

    Global
    Partitioned
    Streaming

    Transactional Scope: Why Global Projections Can't Scale

    To understand why partitioned projections are necessary for scaling, it helps to see how the transactional scope differs between the two types.

    Globally tracked projections have a single position tracker for the entire projection. When one process is projecting events, it holds a lock on that position. Any other process that wants to project must wait until the first one finishes and releases the lock. This is by design — the global stream must be processed in order, so only one consumer can advance the position at a time.

    This means globally tracked projections are not scalable by nature. Adding more workers doesn't help — they queue up behind each other. Global projections are designed for building read models that need to aggregate data across the entire stream (e.g., a dashboard counting all tickets regardless of which aggregate produced them).

    Partitioned projections have a separate position tracker per aggregate. The transactional scope is per projected aggregate, not per projection. This means Ticket-A and Ticket-B can project at the same time without blocking each other — each holds a lock only on its own partition state.

    This is why partitioned projections are scalable by nature — adding more workers directly increases throughput.

    Migrating from Global to Partitioned

    The upgrade path from a global projection to a partitioned one is simple:

    1. Deploy a second version of your projection with #[Partitioned] alongside the existing global one

    2. Both projections are backed by the same Event Store — no data migration needed

    3. Ecotone takes care of delivery and execution

    You can use to make this transition with zero downtime — the old global projection continues serving traffic while the new partitioned one catches up.

    Partitioned Projections

    A partitioned projection creates one partition per aggregate. Each partition tracks its own position, processes its own events, and can fail independently.

    The Difference in Practice: Sync and Async

    This transactional scope difference affects both execution modes.

    Synchronous example: Two users register tickets at the same time. With a global projection, one request must wait for the other's projection to finish before it can project — adding latency to the API response. With a partitioned projection, both requests project their own aggregate independently and return immediately.

    Asynchronous example: With a global projection, it only makes sense to have a single worker running per projection — adding more workers doesn't help because they block each other waiting for the single position lock. With partitioned projections, each worker picks up a different aggregate's events. If you have 4 workers processing 4 different aggregates in parallel, throughput scales 4x.

    Performance: Why Partitioned Is Faster

    Beyond resilience and scalability, partitioned projections have a significant performance advantage in event loading.

    A globally tracked projection must scan the entire event stream — even events it doesn't care about — because it tracks a single position across all aggregates. It cannot skip events, because skipping would create that need to be tracked and resolved. Even if your projection only handles TicketWasRegistered, it still reads past millions of OrderWasPlaced events to advance its position and maintain gap awareness.

    A partitioned projection tracks position per aggregate. Because event ordering within a single aggregate is guaranteed by the Event Store's optimistic locking (no ), Ecotone can skip directly to the events the projection is interested in — filtering by aggregate type at the database level using indexes. There is no need to read irrelevant events. On a high-volume event stream with millions of events across many aggregate types, this makes a massive difference in loading speed.

    Event-Driven Dispatch

    Partitioned projections also wake up only when there is work to do. Async execution combined with #[Partitioned] triggers a worker for a given partition only when an event arrives for that aggregate. There is no periodic poll wondering whether anything happened; idle partitions consume no cycles. Compared to a timer-based "every N seconds, check everything" loop, this scales to many partitions without scaling cost — only the partitions with real activity ever run.

    Streaming Projections

    Streaming projections consume events from a message channel (such as Kafka or RabbitMQ Streams) instead of reading from the database event store directly:

    When to use:

    • Cross-system integration — events produced by other services via Kafka or RabbitMQ

    • When you want to decouple event reading from the database Event Store

    • Real-time event consumption from external sources

    Feeding a Streaming Channel from the Event Store

    You don't need an external message broker to use streaming projections. Ecotone provides an Event Store Adapter that reads events from your database Event Store and forwards them to a streaming channel. This creates a bridge between the Event Store and the streaming projection:

    Configure the adapter using EventStreamingChannelAdapter:

    This creates a polling endpoint (product_stream_feeder) that continuously reads events from the product_stream in the Event Store and forwards them to the product_stream_channel streaming channel.

    Then your streaming projection consumes from that channel:

    Run both the feeder and the projection:

    Filtering Events in the Adapter

    You can filter which events the adapter forwards using glob patterns:

    Only events matching the patterns will be forwarded to the channel. Events that don't match are skipped.

    Polling Projections

    Polling projections run as a dedicated background process that periodically queries the event store:

    When to use:

    • Heavy projections that need an isolated process

    • Projections that should run independently of the event-driven flow

    Run the poller:

    Custom Extensions

    For advanced use cases, you can provide custom implementations of the projection infrastructure:

    • #[StreamSource] — custom event source (alternative to the built-in Event Store reader)

    • #[StateStorage] — custom state persistence (alternative to the built-in DBAL storage)

    • #[PartitionProvider] — custom partition strategy (alternative to aggregate-based partitioning)

    These are useful when integrating with non-standard event stores or storage backends.

    Multi-Tenant Projections

    Ecotone supports projections in multi-tenant environments where each tenant has its own database connection:

    Events are isolated per tenant, and projections process each tenant's events against their own database. The tenant is identified via message metadata.

    Sagas: Workflows That Remember

    Learn how to build long-running workflows that remember state using Sagas

    While works great for simple workflows, some business processes need to remember what happened before. This is where Sagas come in.

    When Do You Need Sagas?

    Use Sagas when your workflow needs to:

    🌳 Branch and merge: Split into multiple paths that later combine ⏳ Wait for humans: Pause for manual approval or external actions 📊 Track progress: Monitor long-running processes (hours, days, weeks) 🔄

    Blue-green deployment
    Asynchronous event-driven execution
    Lifecycle management
    Multiple event streams
    Projection state
    Event emission
    Sync backfill
    Batch size configuration
    Gap detection
    Self-healing / automatic recovery
    Polling execution
    Partitioned projections
    Streaming projections
    Async backfill
    Rebuild
    Blue-green deployments
    High-performance flush state
    Multi-tenant projections
    Message Handling isolation

    Normally you would inject into Converter class, some kind of serializer used within your application for example JMS Serializer or Symfony Serializer to make the conversion.

    Let's run our testing command:

    You may think of routing key, as a message name used to route the message to specific handler. This is very powerful concept, which allows for high level of decoupling.

    Let's run our testing command:

    JMS creates cache to speed up serialization process. In case of problems with running this test command, try to remove your cache. Let's run our testing command:

    Normally you will like to delegate conversion to Converters, as we want to get our domain classes converted as fast as we can. The business logic should stay clean, so it can focus on the domain problems, not technical problems.

    Let's run our testing command:

    To get more information, read Native Conversion

    In this Lesson we learned how to make use of Converters. The command which we send from outside (to the Command Bus) is still the same, as before. We changed the internals of the domain, without affecting consumers of our API. In next Lesson we will learn and Method Invocation and Metadata

    Great, we just finished Lesson 3!

    JMS Serializer
    Composer
    - Indicating all
    starting with namespace prefix e.g. App\Domain\*
  • expression || expression - Indicating one expression or another e.g. Product\*||Order\*

  • expression && expression - Indicating one expression and another e.g. App\Domain\* && App\Attribute\RequireAdministrator

  • Presend

    The mechanism comes from Aspect Oriented Programming; Interceptors handle cross-cutting concerns like authorization, logging, transactions, and metrics — without modifying the handler code.

    Our Command Handler is using ChangePriceCommandclass and our AdminVerificator interceptor is using array $payload. They are both referencing payload of the Message, yet if we define a class as type hint, Ecotone will do the Conversion for us.

    Presend can't intercept Gateways like (Command/Event/Query) buses, however in context of Gateways using Before Interceptor lead to same behaviour, therefore can be used instead.

    Interceptor Types section
    interceptor type
    Message.
    Pointcut
    Endpoints
    Message
    Message
    Before Interceptor.
    asynchronous
    Presend Interceptor is called exactly before message is sent to the channel.
    Endpoints
    <?php
    
    namespace App\Domain\Product;
    
    use Ecotone\Messaging\Attribute\MediaTypeConverter;
    use Ecotone\Messaging\Conversion\Converter;
    use Ecotone\Messaging\Conversion\MediaType;
    use Ecotone\Messaging\Handler\TypeDescriptor;
    
    #[MediaTypeConverter]
    class JsonToPHPConverter implements Converter
    {
        public function matches(TypeDescriptor $sourceType, MediaType $sourceMediaType, TypeDescriptor $targetType, MediaType $targetMediaType): bool
        {
    
        }
    
        public function convert($source, TypeDescriptor $sourceType, MediaType $sourceMediaType, TypeDescriptor $targetType, MediaType $targetMediaType)
        {
    
        }
    }
    public function matches(TypeDescriptor $sourceType, MediaType $sourceMediaType, TypeDescriptor $targetType, MediaType $targetMediaType): bool
    {
        return $sourceMediaType->isCompatibleWith(MediaType::createApplicationJson()) // if source media type is JSON
            && $targetMediaType->isCompatibleWith(MediaType::createApplicationXPHP()); // and target media type is PHP
    }
    public function convert($source, TypeDescriptor $sourceType, MediaType $sourceMediaType, TypeDescriptor $targetType, MediaType $targetMediaType)
    {
        $data = \json_decode($source, true, 512, JSON_THROW_ON_ERROR);
        // $targetType hold the class, which we will convert to
        switch ($targetType->getTypeHint()) {
            case RegisterProductCommand::class: {
                return RegisterProductCommand::fromArray($data);
            }
            case GetProductPriceQuery::class: {
                return GetProductPriceQuery::fromArray($data);
            }
            default: {
                throw new \InvalidArgumentException("Unknown conversion type");
            }
        }
    }
    class GetProductPriceQuery
    {
        private int $productId;
    
        public function __construct(int $productId)
        {
            $this->productId = $productId;
        }
    
        public static function fromArray(array $data) : self
        {
            return new self($data['productId']);
        }
    class RegisterProductCommand
    {
        private int $productId;
    
        private int $cost;
    
        public function __construct(int $productId, int $cost)
        {
            $this->productId = $productId;
            $this->cost = $cost;
        }
    
        public static function fromArray(array $data) : self
        {
            return new self($data['productId'], $data['cost']);
        }
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    100
    Good job, scenario ran with success!
    #[CommandHandler("product.register")]
    public static function register(RegisterProductCommand $command) : self
    {
        return new self($command->getProductId(), $command->getCost());
    }
    
    #[QueryHandler("product.getCost")] 
    public function getCost(GetProductPriceQuery $query) : int
    {
        return $this->cost;
    }
    (...)
    
    public function run() : void
    {
        $this->commandBus->sendWithRouting("product.register", \json_encode(["productId" => 1, "cost" => 100]), "application/json");
    
        echo $this->queryBus->sendWithRouting("product.getCost", \json_encode(["productId" => 1]), "application/json");
    }
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    100
    Good job, scenario ran with success!
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    100
    Good job, scenario ran with success!
    Conversion Table examples:
    Source => Converts too
    
    private int $productId => int
    
    private string $data => string
    
    private \stdClass $data => \stdClass
     
    /**
    * @var \stdClass[] 
    */
    private array $data => array<\stdClass>
    namespace App\Domain\Product;
    
    class Cost
    {
        private int $amount;
    
        public function __construct(int $amount)
        {
            if ($amount <= 0) {
                throw new \InvalidArgumentException("The cost cannot be negative or zero, {$amount} given.");
            }
            
            $this->amount = $amount;
        }
    
        public function getAmount() : int
        {
            return $this->amount;
        }
        
        public function __toString()
        {
            return (string)$this->amount;
        }
    }
    namespace App\Infrastructure\Converter;
    
    use App\Domain\Product\Cost;
    use Ecotone\Messaging\Attribute\Converter;
    
    class CostConverter
    {
        #[Converter]
        public function convertFrom(Cost $cost) : int
        {
            return $cost->getAmount();
        }
    
        #[Converter]
        public function convertTo(int $amount) : Cost
        {
            return new Cost($amount);
        }
    }
    class RegisterProductCommand
    {
        private int $productId;
    
        private Cost $cost;
    
        public function getProductId() : int
        {
            return $this->productId;
        }
    
        public function getCost() : Cost
        {
            return $this->cost;
        }
    }
    class Product
    {
        use WithAggregateEvents;
    
        #[Identifier]
        private int $productId;
    
        private Cost $cost;
    
        private function __construct(int $productId, Cost $cost)
        {
            $this->productId = $productId;
            $this->cost = $cost;
    
            $this->recordThat(new ProductWasRegisteredEvent($productId));
        }
    
        #[CommandHandler("product.register")]
        public static function register(RegisterProductCommand $command) : self
        {
            return new self($command->getProductId(), $command->getCost());
        }
    
        #[QueryHandler("product.getCost")]
        public function getCost(GetProductPriceQuery $query) : Cost
        {
            return $this->cost;
        }
    }
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    100
    Good job, scenario ran with success!
    class AdminVerificator
    {
        #[Before(precedence: 0, pointcut: "Order\Domain\*")]
        public function isAdmin(array $payload, array $headers) : void
        {
            if ($headers["executorId"] != 1) {
                throw new \InvalidArgumentException("You need to be administrator in order to register new product");
            }
        }
    }
    #[\Attribute]
    class RequireAdministrator {}
    class AdminVerificator
    {
        #[Before(pointcut: RequireAdministrator::class)]
        public function isAdmin(array $payload, array $headers) : void
        {
            if ($headers["executorId"] != 1) {
                throw new \InvalidArgumentException("You need to be administrator in order to register new product");
            }
        }
    }
    #[CommandHandler]
    #[RequireAdministrator] // Our Application level defined Attribute
    public function changePrice(ChangePriceCommand $command) : void
    {
       // do something with $command
    }
    #[\Attribute]
    class AddTimestamp {}
    class TimestampService
    {
        #[Before(pointcut: AddTimestamp::class)] 
        public function add(array $payload) : array
        {
            return array_merge($payload, ["timestamp" => time()]);
        }
    }
    class ChangePriceCommand
    {
        private int $productId;
        
        private int $timestamp;
    }
    
    #[CommandHandler]
    #[AddTimestamp]
    public function changePrice(ChangePriceCommand $command) : void
    {
       // do something with $command and timestamp
    }
    #[\Attribute]
    class AddExecutor {}
    class TimestampService
    {
        #[Before(pointcut: AddExecutor::class, changeHeaders: true)] 
        public function add() : array
        {
            return ["executorId" => 1];
        }
    }
    #[CommandHandler]
    #[AddExecutor] 
    public function changePrice(ChangePriceCommand $command, array $metadata) : void
    {
       // do something with $command and executor id $metadata["executorId"]
    }
    #[\Attribute]
    class SendNotificationOnlyIfInterested {}
    class NotificationFilter
    {
        #[Before(pointcut: SendNotificationOnlyIfInterested::class, changeHeaders: true)] 
        public function filter(PriceWasChanged $event) : ?array
        {
            if ($this->isInterested($event) {
               return $event; // flow proceeds 
            }
            
            return null;  // message is eliminated, flow stops.
        }
    }
    #[EventHandler]
    #[SendNotificationOnlyIfInterested] 
    public function sendNewPriceNotification(ChangePriceCommand $event) : void
    {
       // do something with $event
    }
    class TransactionInterceptor
    {
        #[Around(pointcut: Ecotone\Modelling\CommandBus::class)]
        public function transactional(MethodInvocation $methodInvocation)
        {
            $this->connection->beginTransaction();
            try {
                $result = $methodInvocation->proceed();
    
                $this->connection->commit();
            }catch (\Throwable $exception) {
                $this->connection->rollBack();
    
                throw $exception;
            }
    
            return $result;
        }
    }
    #[Aggregate]
    #[IsOwnedByExecutor] 
    class Person
    {
       private string $personId;
    
       #[CommandHandler]
       public function changeAddress(ChangeAddress $command) : void
       {
          // change address
       }
       
       public function hasPersonId(string $personId) : bool
       {
          return $this->personId === $personId;
       }
    }
    #[\Attribute]
    class IsOwnedByExecutor {}
    class IsOwnerVerificator
    {
        #[Around(pointcut: IsOwnedByExecutor::class)] 
        public function isOwner(MethodInvocation $methodInvocation, Person $person, #[Headers] array $metadata)
        {
            if (!$person->hasPersonId($metadata["executoId"]) {
               throw new \InvalidArgumentException("No access to do this action!");
            }
            return $methodInvocation->proceed();
        }
    }
    namespace Order\ReadModel;
    
    class OrderService
    {
       #[QueryHandler]
       public function getOrderDetails(GetOrderDetailsQuery $query) : array
       {
          return ["orderId" => $query->getOrderId()]
       }
    }   
    class AddResultSet
    {
        #[After(pointcut: "Order\ReadModel\*") 
        public function add(array $payload) : array
        {
            return ["result" => $payload];
        }
    }
    class VerifyIfAuthenticated
    {
        #[Presend(pointcut: Ecotone\Modelling\Attribute\CommandHandler::class)] 
        public function verify(#[Header("executorId")] ?string $executorId) : void
        {
            if (!$executorId) {
                throw new \InvalidArgumentException("User must be logged");
            }
        }
    }
    
    
    
    class IsEventAlreadyHandled
    {
        private Storage $storage;
    
        #[Presend(pointcut: Ecotone\Modelling\Attribute\EventHandler::class)] 
        public function verify($payload, #[Header("messageId")] string $messageId)
        {
            if ($this->storage->isHandled($messageId)) {
                return null;
            }
            
            return $payload;
        }
    }

    Broker-managed offsets

    Failure isolation

    One failure blocks everything

    One failure blocks only that aggregate

    One failure blocks broker partition

    Gap detection

    Required —

    Not needed — ordering guaranteed per partition

    Not needed — broker guarantees delivery

    Event loading

    Scans entire stream sequentially

    Fetches only relevant events per aggregate (indexed)

    Pushed by broker

    Parallel processing

    Sequential, single consumer

    Each partition independent, multiple workers

    Broker-level parallelism

    Best for

    Simple projections, low volume

    Production workloads, high volume

    Cross-system integration, external events

    Licence

    Open source

    Enterprise

    Enterprise

    You just choose the execution model (sync or async)

    Event source

    Database Event Store

    Database Event Store

    Message Broker (Kafka, RabbitMQ)

    Position tracking

    Single global position

    #[ProjectionV2('ticket_details')]
    #[FromAggregateStream(Ticket::class)]
    #[Partitioned]
    class TicketDetailsProjection
    {
        public function __construct(private Connection $connection) {}
    
        #[EventHandler]
        public function onTicketRegistered(TicketWasRegistered $event): void
        {
            $this->connection->insert('ticket_details', [
                'ticket_id' => $event->ticketId,
                'type' => $event->type,
                'status' => 'open',
            ]);
        }
    
        #[EventHandler]
        public function onTicketClosed(TicketWasClosed $event): void
        {
            $this->connection->update(
                'ticket_details',
                ['status' => 'closed'],
                ['ticket_id' => $event->ticketId]
            );
        }
    
        #[ProjectionInitialization]
        public function init(): void { /* CREATE TABLE */ }
    
        #[ProjectionReset]
        public function reset(): void { /* DELETE FROM */ }
    }
    #[ProjectionV2('external_orders')]
    #[Streaming('orders_channel')]
    class ExternalOrdersProjection
    {
        #[EventHandler]
        public function onOrderReceived(OrderReceived $event): void
        {
            // Process events coming from the streaming channel
        }
    }
    #[ServiceContext]
    public function eventStoreFeeder(): EventStreamingChannelAdapter
    {
        return EventStreamingChannelAdapter::create(
            streamChannelName: 'product_stream_channel',
            endpointId: 'product_stream_feeder',
            fromStream: 'product_stream',
        );
    }
    #[ProjectionV2('product_catalog')]
    #[Streaming('product_stream_channel')]
    class ProductCatalogProjection
    {
        #[EventHandler]
        public function onProductRegistered(ProductRegistered $event): void
        {
            // Process events forwarded from the Event Store
        }
    }
    # Start the Event Store feeder (reads events, forwards to channel)
    bin/console ecotone:run product_stream_feeder -vvv
    
    # Start the streaming projection (consumes from channel)
    bin/console ecotone:run product_catalog -vvv
    artisan ecotone:run product_stream_feeder -vvv
    artisan ecotone:run product_catalog -vvv
    EventStreamingChannelAdapter::create(
        streamChannelName: 'ticket_channel',
        endpointId: 'ticket_feeder',
        fromStream: 'ticket_stream',
        eventNames: ['Ticket.*', 'Order.Created'],
    )
    #[ProjectionV2('heavy_analytics')]
    #[FromAggregateStream(Order::class)]
    #[Polling('analytics_poller')]
    class HeavyAnalyticsProjection
    {
        #[EventHandler]
        public function onOrderPlaced(OrderWasPlaced $event): void
        {
            // Heavy processing — runs in dedicated process
        }
    }
    bin/console ecotone:run analytics_poller -vvv
    artisan ecotone:run analytics_poller -vvv
    $messagingSystem->run('analytics_poller');
    MultiTenantConfiguration::create(
        'tenant',
        [
            'tenant_a' => 'tenant_a_connection',
            'tenant_b' => 'tenant_b_connection',
        ]
    )

    For production systems with growing event volumes, partitioned projections are the recommended choice. They are faster (indexed event loading), more resilient (failure isolation per aggregate), and scale horizontally (parallel workers).

    In most cases, what you want to project is the state of a given aggregate — for this, partitioned projections are the right choice. Global projections are meant for the less common case where you need to build a read model across the entire stream (e.g., cross-aggregate reporting).

    Filtering power is a partitioned-only feature. Global projections cannot push filters down to the database — a filtered-out event looks identical to a missing event, and the projection loses the ability to tell a gap apart from "I deliberately didn't want this one." So global projections fetch the firehose and discard irrelevant events at runtime. Partitioned projections push the filter into the query and only load what they actually handle.

    Most systems will never need streaming projections. Partitioned projections backed by the Event Store handle the vast majority of production workloads — they already give you parallelism, failure isolation, and indexed event loading. Streaming projections exist for the cases where the events themselves come from somewhere other than your database: a Kafka topic produced by another service, an external feed, a cross-system integration. The value of having streaming available is not that you reach for it tomorrow — it is that the foundation you build today does not have to be torn down when that boundary appears.

    Streaming projections don't need #[FromAggregateStream] — events come from the message channel directly.

    The Event Store Adapter is useful when you want streaming projection benefits (channel-based consumption, broker-level parallelism) but your events live in the database Event Store. It bridges the two worlds without requiring an external message broker.

    Blue-Green Deployments
    gaps
    gaps possible

    Per aggregate

    Handle complexity
    : Make decisions based on accumulated state

    Examples:

    • Order processing (payment → shipping → delivery)

    • Loan approval (application → verification → decision)

    • User onboarding (signup → verification → welcome)

    Creating Your First Saga

    A Saga is like a persistent coordinator that remembers its state between events. Let's build an order processing saga step by step.

    Step 1: Define the Saga Structure

    Key parts:

    • #[Saga] - Tells Ecotone this is a stateful workflow

    • #[Identifier] - Unique ID to find and update this specific saga instance

    • Private properties - The state that gets remembered between events

    Step 2: Start the Saga

    Sagas begin when something important happens (usually an event):

    What happens:

    1. OrderWasPlaced event occurs

    2. Ecotone creates a new OrderProcess saga instance

    3. The saga is saved to storage (database, etc.)

    4. Now it can react to future events for this order

    Storage and Surviving Crashes

    Sagas are automatically stored using Repositories. You can use Doctrine ORM, Eloquent, or Ecotone's Document Store — no extra configuration needed.

    The saga survives anything that doesn't destroy your database. Every time a saga handles an event, its updated state is committed to the database in the same transaction as the events it published. If the process dies mid-handler, the channel redelivers the message; the saga is reloaded from storage and the handler runs again. Deploys, restarts, OOM kills — none of them lose work, because the work isn't in memory.

    For full replay semantics — every state transition recorded as an event in your own database, queryable and projectable — use #[EventSourcingSaga] (with WithAggregateVersioning + #[EventSourcingHandler] methods that rebuild state from events). The events are in your schema; build any view you need over them. See Durable Execution in PHP for the side-by-side with Temporal.

    Step 3: React to Events and Take Actions

    Now let's make the saga react to events and coordinate the workflow:

    Triggering Commands

    When payment succeeds, we want to start shipping:

    The flow:

    1. PaymentWasSuccessful event arrives

    2. Saga updates its internal state

    3. Saga returns ShipOrder command

    4. Command goes to shipOrder channel

    5. Shipping handler processes the order

    Alternative: Using Command Bus

    You can also send commands directly:

    Step 4: Publishing Events and Timeouts

    Sagas can also publish events to trigger other parts of your system or set up timeouts.

    Publishing Events

    Setting Up Timeouts

    Cancel orders that aren't paid within 24 hours:

    Timeline:

    • ⏰ T+0: Order placed, saga starts, timeout scheduled

    • ⏰ T+24h: If still unpaid, automatically cancel

    Advanced Patterns

    Conditional Event Handling

    Sometimes you only want to handle events if the saga already exists. Use dropMessageOnNotFound:

    How it works:

    • ✅ If saga exists: Event is processed

    • ❌ If saga doesn't exist: Event is ignored (dropped)

    • 🎯 Use case: Features that depend on previous conditions

    Querying Saga State

    Expose saga state to your application (great for status pages, dashboards):

    Using in your controllers:

    Perfect for:

    • Order tracking pages

    • Progress indicators

    • Admin dashboards

    • Customer support tools

    Handling Unordered Events

    Real-world events don't always arrive in order. Ecotone handles this elegantly with method redirection:

    How Ecotone decides:

    • 🎯 Same event, different behavior based on saga state

    • 🆕 Saga doesn't exist → Calls static factory method

    • ✅ Saga exists → Calls action method

    Benefits:

    • No complex if/else logic in your code

    • Handles event ordering issues automatically

    • Clean separation of initialization vs. processing logic

    Saga Identification and Correlation

    Finding the Right Saga Instance

    Every event/command needs to find the correct saga instance. Ecotone uses Identifier Mapping for this:

    Using Correlation IDs

    For complex workflows that branch and merge, use correlation IDs:

    Benefits of correlation IDs:

    • Track workflows across multiple services

    • Handle branching and merging flows

    • Automatic propagation through message chains

    Testing Sagas with Ecotone Lite

    Testing sagas is essential for ensuring your stateful workflows behave correctly. Ecotone Lite makes saga testing straightforward and comprehensive.

    Setting Up Saga Tests

    Testing Saga State Changes

    Test how sagas respond to events and update their state:

    Summary: When to Use Sagas

    ✅ Use Sagas when you need to:

    • Remember state between events

    • Coordinate long-running processes

    • Handle branching/merging workflows

    • Implement timeouts and cancellations

    • Track progress of complex operations

    ❌ Don't use Sagas for:

    • Simple linear workflows (use handler chaining)

    • Stateless transformations

    connecting handlers with channels
    #[Saga]
    final class OrderProcess
    {
        private function __construct(
            #[Identifier] private string $orderId,  // 👈 Unique ID to find this saga
            private OrderStatus $status,            // 👈 Current state
            private bool $isPaid = false,           // 👈 Remember payment status
            private bool $isShipped = false,        // 👈 Remember shipping status
        ) {}
    }
    #[Saga]
    final class OrderProcess
    {
        // ... constructor ...
    
        #[EventHandler]  // 👈 This starts the saga
        public static function startWhen(OrderWasPlaced $event): self
        {
            return new self(
                orderId: $event->orderId,
                status: OrderStatus::PLACED
            );
        }
    }
    #[Saga]
    final class OrderProcess
    {
        // ... previous methods ...
    
        #[EventHandler(outputChannelName: "shipOrder")]  // 👈 Send to shipping
        public function whenPaymentSucceeded(PaymentWasSuccessful $event): ShipOrder
        {
            // Update saga state
            $this->isPaid = true;
            $this->status = OrderStatus::READY_TO_SHIP;
    
            // Return command to trigger shipping
            return new ShipOrder($this->orderId);
        }
    }
    #[EventHandler]
    public function whenPaymentSucceeded(PaymentWasSuccessful $event, CommandBus $commandBus): void
    {
        $this->isPaid = true;
        $this->status = OrderStatus::READY_TO_SHIP;
    
        $commandBus->send(new ShipOrder($this->orderId));
    }
    #[Saga]
    final class OrderProcess
    {
        use WithEvents;  // 👈 Enables event publishing
    
        private function __construct(
            #[Identifier] private string $orderId,
            private OrderStatus $status,
            private bool $isPaid = false,
        ) {
            // Publish event when saga starts
            $this->recordThat(new OrderProcessStarted($this->orderId));
        }
    
        #[EventHandler]
        public function whenShippingCompleted(OrderWasShipped $event): void
        {
            $this->isShipped = true;
            $this->status = OrderStatus::COMPLETED;
    
            // Publish completion event
            $this->recordThat(new OrderProcessCompleted($this->orderId));
        }
    }
    #[Saga]
    final class OrderProcess
    {
        // ... other methods ...
    
        #[Delayed(new TimeSpan(days: 1))]  // 👈 Wait 24 hours
        #[Asynchronous('async')]
        #[EventHandler]
        public function cancelUnpaidOrder(OrderProcessStarted $event): void
        {
            if (!$this->isPaid) {
                $this->status = OrderStatus::CANCELLED;
                $this->recordThat(new OrderWasCancelled($this->orderId, 'Payment timeout'));
            }
        }
    }
    #[Saga]
    class CustomerPromotion
    {
        private function __construct(
            #[Identifier] private string $customerId,
            private bool $hasGreatCustomerBadge = false
        ) {}
    
        #[EventHandler]
        public static function startWhen(ReceivedGreatCustomerBadge $event): self
        {
            return new self($event->customerId, hasGreatCustomerBadge: true);
        }
    
        #[EventHandler(dropMessageOnNotFound: true)]  // 👈 Only if saga exists
        public function whenOrderPlaced(OrderWasPlaced $event, CommandBus $commandBus): void
        {
            // Only send promo if customer has the badge (saga exists)
            $commandBus->send(new SendPromotionCode($this->customerId));
        }
    }
    #[Saga]
    final class OrderProcess
    {
        // ... other methods ...
    
        #[QueryHandler("order.getStatus")]
        public function getStatus(): array
        {
            return [
                'orderId' => $this->orderId,
                'status' => $this->status->value,
                'isPaid' => $this->isPaid,
                'isShipped' => $this->isShipped,
            ];
        }
    
        #[QueryHandler("order.getProgress")]
        public function getProgress(): int
        {
            $steps = 0;
            if ($this->isPaid) $steps++;
            if ($this->isShipped) $steps++;
    
            return ($steps / 2) * 100; // Progress percentage
        }
    }
    class OrderController
    {
        public function __construct(private QueryBus $queryBus) {}
    
        public function getOrderStatus(string $orderId): JsonResponse
        {
            $status = $this->queryBus->sendWithRouting(
                'order.getStatus',
                metadata: ['aggregate.id' => $orderId]  // 👈 Target specific saga
            );
    
            return new JsonResponse($status);
        }
    }
    #[Saga]
    class OrderFulfillment
    {
        private function __construct(
            #[Identifier] private string $orderId,
            private bool $isStarted = false
        ) {}
    
        // 👇 If saga doesn't exist, this creates it
        #[EventHandler]
        public static function startByOrderPlaced(OrderWasPlaced $event): self
        {
            return new self($event->orderId, isStarted: true);
        }
    
        // 👇 If saga exists, this handles the event
        #[EventHandler]
        public function whenOrderWasPlaced(OrderWasPlaced $event): void
        {
            // Handle additional order placed logic
            // (maybe it's a duplicate event, or additional items)
        }
    }
    // Event with orderId property
    class PaymentWasSuccessful
    {
        public function __construct(public string $orderId) {}
    }
    
    // Saga with orderId identifier
    #[Saga]
    class OrderProcess
    {
        public function __construct(
            #[Identifier] private string $orderId  // 👈 Matches event property
        ) {}
    
        #[EventHandler]
        public function whenPaymentSucceeded(PaymentWasSuccessful $event): void
        {
            // Ecotone automatically finds the saga with matching orderId
        }
    }
    #[Saga]
    class OrderProcess
    {
        public function __construct(
            #[Identifier] private string $correlationId  // 👈 Tracks across branches
        ) {}
    
        #[EventHandler]
        public static function startWhen(OrderWasPlaced $event): self
        {
            // Use correlation ID from message headers
            return new self($event->getHeaders()['correlationId']);
        }
    }
    use Ecotone\Lite\EcotoneLite;
    use PHPUnit\Framework\TestCase;
    
    class OrderProcessSagaTest extends TestCase
    {
        private EcotoneLite $ecotoneLite;
    
        protected function setUp(): void
        {
            $this->ecotoneLite = EcotoneLite::bootstrapFlowTesting(
                [OrderProcess::class],
                [],
            );
        }
    
        public function test_saga_initialization(): void
        {
            // Act - Trigger saga creation
            $event = new OrderWasPlaced('order-123');
            $this->ecotoneLite->publishEvent($event);
    
            // Assert - Verify saga was created and can be queried
            $status = $this->ecotoneLite->sendQueryWithRouting(
                'order.getStatus',
                metadata: ['aggregate.id' => 'order-123']
            );
    
            $this->assertEquals('order-123', $status['orderId']);
            $this->assertEquals('PLACED', $status['status']);
            $this->assertFalse($status['isPaid']);
            $this->assertFalse($status['isShipped']);
        }
    }
    public function test_payment_processing_updates_saga_state(): void
    {
        // Arrange - Create saga
        $this->ecotoneLite->publishEvent(new OrderWasPlaced('order-123'));
    
        // Act - Process payment
        $this->ecotoneLite->publishEvent(new PaymentWasSuccessful('order-123'));
    
        // Assert - Verify state changed
        $status = $this->ecotoneLite->sendQueryWithRouting(
            'order.getStatus',
            metadata: ['aggregate.id' => 'order-123']
        );
    
        $this->assertTrue($status['isPaid']);
        $this->assertEquals('READY_TO_SHIP', $status['status']);
    }

    Sagas are durable workflows. A saga's state lives in your own database, persisted per #[Identifier]. When the saga's process crashes, when you deploy mid-flow, when a worker restarts — the saga survives, because its state is a database row, not process memory. On the next event arrival, Ecotone rehydrates the saga and the workflow continues. This is one of Ecotone's primitives — durable workflows on the database and broker you already run.

    Think of Sagas as: A workflow coordinator that remembers what happened and decides what to do next based on that history.

    Prerequisites: Familiarity with and will help you understand Sagas better.

    Saga vs Aggregate: Both can handle events and commands, but use Sagas for business processes (workflows) and Aggregates for business rules (data consistency).

    Timing difference:

    • outputChannelName: Saga state saved first, then command is sent

    • CommandBus

    Pro tip: This pattern is perfect for handling duplicate events or events that can arrive at different workflow stages.

    Correlation IDs are automatically propagated between messages, making them perfect for complex workflows that span multiple services or branches.

    Key insight: Sagas are workflow coordinators that remember what happened and decide what to do next. They're perfect for orchestrating complex business processes that span across time and multiple services.

    Stateless Workflows

    Learn how to connect message handlers using channels to build workflows

    Building business workflows is essential for most applications. Whether you need fully automated processes (like image processing pipelines) or human-interactive flows (like document approval), Ecotone makes it simple by connecting message handlers through channels.

    Understanding the Flow: From Handler to Handler

    The key concept in Ecotone is that every message handler can be connected to other handlers using channels. Think of channels as pipes that carry messages between different parts of your application.

    Core Concept: Each message handler has an input channel (where messages come in) and can have an output channel (where results go out). By connecting these channels, you create workflows.

    Step 1: Understanding Single Handlers

    Let's start with a simple command handler:

    What happens behind the scenes:

    1. Ecotone creates a channel named place.order

    2. Your handler listens to this channel

    3. When you use the Command Bus, it sends messages to this channel

    Step 2: Connecting Handlers Together

    Here's where it gets powerful: any handler can send messages to another handler's channel. This lets you chain handlers together to create workflows.

    Let's add order verification before placing the order:

    The magic happens with outputChannelName:

    How the flow works:

    1. You send PlaceOrder to verify.order channel

    2. The verify() method processes it and returns the command

    3. Ecotone automatically sends the returned command to place.order

    Making Handlers Internal (Private to Workflow)

    Problem: With CommandHandler, anyone can call place.order directly through the Command Bus, bypassing your verification step!

    Solution: Use InternalHandler to make handlers private to your workflow:

    What this means:

    • ✅ verify.order can be called via Command Bus (entry point)

    • ❌ place.order can only be reached through the workflow

    • 🔒 This ensures orders are always verified before being placed

    Adding Asynchronous Processing

    Sometimes you want parts of your workflow to run asynchronously (in the background). This is perfect for:

    • Heavy processing that shouldn't block the user

    • Ensuring messages aren't lost if something goes wrong

    • Scaling parts of your workflow independently

    Example: Keep verification synchronous (fast feedback) but make order placement asynchronous (reliable processing):

    What happens now:

    1. verify() runs immediately and returns a response

    2. The message goes to a queue/background processor

    3. place() runs later in the background

    Adding Delays and Timeouts

    Asynchronous handlers can also be delayed, which is perfect for business scenarios like:

    • Giving customers time to complete actions

    • Implementing timeout behaviors

    • Scheduling follow-up actions

    Example: Give customers 24 hours to pay, then automatically cancel unpaid orders:

    Strategy: Use events to trigger delayed actions (events allow multiple handlers to react):

    Now add the delayed cancellation handler:

    Timeline:

    • ⏰ T+0: Order placed, event published

    • ⏰ T+24h: Cancellation handler runs automatically

    Controlling Workflow Flow

    Stopping the Workflow

    You can stop a workflow from continuing by returning null:

    Enriching Messages in Workflows

    Sometimes you need to add information as messages flow through your workflow. There are two approaches:

    Option 1: Transform the Payload

    Return a new/modified object that contains additional data:

    When to use: When the additional data is core to the next step's logic.

    Option 2: Add Data to Message Headers

    Keep the original payload unchanged and add extra data as headers:

    When to use: When you want to keep the original payload intact and add supplementary data.

    Testing Your Workflows with Ecotone Lite

    Testing workflows is crucial for ensuring your business logic works correctly. Ecotone Lite makes testing handler chains simple and straightforward.

    Setting Up Tests

    Testing Handler Chains

    Test complete workflows from start to finish:

    Testing Asynchronous Workflows

    Test async workflows in synchronous mode for easier testing:

    Summary: What You've Learned

    You now understand the fundamentals of connecting handlers with channels in Ecotone:

    Key Concepts

    • Channels: Every handler has an input channel, and can send to output channels

    • Connection: Use outputChannelName to chain handlers together

    • Privacy: Use InternalHandler to make handlers private to workflows

    Lesson 5: Interceptors

    PHP Middlewares Interceptors

    Ecotone provide us with possibility to handle via Interceptors. Interceptor as name suggest, intercepts the process of handling the message. You may enrich the , stop or modify usual processing cycle, call some shared functionality, add additional behavior to existing code without modifying the code itself.

    Before & After Interceptor

    track-based
    : Command sent first, then saga state saved
    Durable Execution
    connecting handlers
    aggregates
    channel
  • The place() method receives and processes it

  • If place() fails, the message can be retried

    Async Processing: Add #[Asynchronous] for background processing

  • Delays: Use #[Delayed] for time-based workflows

  • Flow Control: Return null to stop workflows

  • Data Enrichment: Transform payloads or add headers

  • Pro Tip: You can use the same command class for multiple handlers in a workflow. This eliminates the need to convert between different classes at each step, making your code simpler and easier to follow.

    Extending Workflows: You can easily add more steps by adding outputChannelName to any handler. To send messages to multiple handlers, use the Router pattern.

    The Beauty of Ecotone: You've built a complete workflow with asynchronous processing without extending any framework classes or complex configuration. Everything is declared through simple attributes!

    Header Propagation: Headers automatically flow through your entire workflow, so any handler can access data added by previous steps.

    A single handler connected to its input channel
    Two handlers connected: verify → place order
    Asynchronous handlers process messages through a queue
    Delayed cancellation after 24 hours
    #[CommandHandler('place.order')]
    public function place(PlaceOrder $command): void
    {
        // Place the order logic here
    }
    class ProcessOrder
    {
        #[CommandHandler(
            'verify.order',
            outputChannelName: 'place.order'  // 👈 This connects the handlers!
        )]
        public function verify(PlaceOrder $command): PlaceOrder
        {
            // Verify the order
            if ($this->isValidOrder($command)) {
                return $command; // Pass it to the next handler
            }
            throw new InvalidOrderException();
        }
    
        #[CommandHandler('place.order')]
        public function place(PlaceOrder $command): void
        {
            // Place the order
            $this->orderRepository->save($command);
        }
    }
    class ProcessOrder
    {
        #[CommandHandler(
            'verify.order',
            outputChannelName: 'place.order'
        )]
        public function verify(PlaceOrder $command): PlaceOrder
        {
            // verify the order
            return $command;
        }
    
        #[InternalHandler('place.order')] // 👈 Can't be called directly!
        public function place(PlaceOrder $command): void
        {
            // place the order
            $this->orderRepository->save($command);
        }
    }
    class ProcessOrder
    {
        #[CommandHandler(
            'verify.order',
            outputChannelName: 'place.order'
        )]
        public function verify(PlaceOrder $command): PlaceOrder
        {
            // This runs immediately (synchronous)
            if (!$this->isValidOrder($command)) {
                throw new InvalidOrderException();
            }
            return $command;
        }
    
        #[Asynchronous('async')]  // 👈 This makes it asynchronous!
        #[InternalHandler('place.order')]
        public function place(PlaceOrder $command): void
        {
            // This runs in the background (asynchronous)
            $this->orderRepository->save($command);
            $this->emailService->sendConfirmation($command);
        }
    }
    class ProcessOrder
    {
        // ... previous methods ...
    
        #[Asynchronous('async')]
        #[InternalHandler('place.order')]
        public function place(PlaceOrder $command, EventBus $eventBus): void
        {
            // Place the order
            $this->orderRepository->save($command);
    
            // Trigger the timeout mechanism
            $eventBus->publish(new OrderWasPlaced($command->orderId));
        }
    }
    class OrderTimeoutHandler
    {
        #[Delayed(new TimeSpan(days: 1))]  // 👈 Wait 24 hours
        #[Asynchronous('async')]
        #[EventHandler]
        public function cancelUnpaidOrder(
            OrderWasPlaced $event,
            OrderRepository $orderRepository
        ): void {
            $order = $orderRepository->get($event->orderId);
    
            if ($order->isNotPaid()) {
                $order->cancel();
                $orderRepository->save($order);
    
                // Could trigger more events here (email notifications, etc.)
            }
        }
    }
    class ProcessOrder
    {
        #[CommandHandler(
            'verify.order',
            outputChannelName: 'place.order'
        )]
        public function verify(PlaceOrder $command): ?PlaceOrder
        {
            if (!$this->isValidOrder($command)) {
                // Log the issue, send notification, etc.
                $this->logger->warning('Invalid order rejected', ['orderId' => $command->orderId]);
    
                // Stop here - don't continue to place.order
                return null;
            }
    
            // Continue the workflow
            return $command;
        }
    
        // This won't be called if verify() returns null
        #[InternalHandler('place.order')]
        public function place(PlaceOrder $command): void
        {
            $this->orderRepository->save($command);
        }
    }
    class CreditCardProcessor
    {
        #[InternalHandler(
            inputChannelName: 'credit_card.add_details',
            outputChannelName: 'credit_card.verify'
        )]
        public function addDetails(CustomerDetails $customer): CustomerDetailsWithHistory
        {
            // Fetch additional data
            $history = $this->creditHistoryService->getHistory($customer->id);
    
            // Return enriched object
            return new CustomerDetailsWithHistory($customer, $history);
        }
    
        #[InternalHandler('credit_card.verify')]
        public function verify(CustomerDetailsWithHistory $enrichedCustomer): void
        {
            // Now we have both customer details and history
            if ($enrichedCustomer->history->isGoodCredit()) {
                $this->approveCard($enrichedCustomer->customer);
            }
        }
    }
    class CreditCardProcessor
    {
        #[InternalHandler(
            inputChannelName: 'credit_card.add_details',
            outputChannelName: 'credit_card.verify',
            changingHeaders: true  // 👈 This tells Ecotone we're modifying headers
        )]
        public function addDetails(CustomerDetails $customer): array
        {
            // Fetch additional data
            $history = $this->creditHistoryService->getHistory($customer->id);
    
            // Return array that becomes headers
            return [
                'creditHistory' => $history,
                'riskScore' => $this->calculateRisk($history)
            ];
        }
    
        #[InternalHandler('credit_card.verify')]
        public function verify(
            CustomerDetails $customer,  // Original payload unchanged
            #[Header('creditHistory')] CreditHistory $history,  // From headers
            #[Header('riskScore')] int $riskScore
        ): void {
            // Use both original data and enriched headers
            if ($riskScore < 50 && $history->hasGoodPaymentRecord()) {
                $this->approveCard($customer);
            }
        }
    }
    use Ecotone\Lite\EcotoneLite;
    use PHPUnit\Framework\TestCase;
    
    class WorkflowTest extends TestCase
    {
        public function test_order_verification_workflow(): void
        {
            // Arrange - Set up your handlers
            $orderProcessor = new ProcessOrder();
    
            $ecotoneLite = EcotoneLite::bootstrapFlowTesting(
                [ProcessOrder::class],           // Classes to register
                [$orderProcessor],               // Service instances
            );
    
            // Act - Send a command to start the workflow
            $command = new PlaceOrder('order-123', 'customer-456');
            $result = $ecotoneLite->sendCommand($command);
    
            // Assert - Verify the workflow executed correctly
            $this->assertNull($result); // Void return from final handler
    
            // Verify side effects (database changes, sent emails, etc.)
            $this->assertTrue($orderProcessor->wasOrderPlaced('order-123'));
        }
    }
    class ProcessOrder
    {
        private array $placedOrders = [];
        private array $verifiedOrders = [];
    
        #[CommandHandler(
            'verify.order',
            outputChannelName: 'place.order'
        )]
        public function verify(PlaceOrder $command): PlaceOrder
        {
            $this->verifiedOrders[] = $command->orderId;
    
            if ($command->orderId === 'invalid-order') {
                throw new InvalidOrderException('Order validation failed');
            }
    
            return $command;
        }
    
        #[InternalHandler('place.order')]
        public function place(PlaceOrder $command): void
        {
            $this->placedOrders[] = $command->orderId;
        }
    
        // In production application you would most likely have some repository
        public function wasOrderVerified(string $orderId): bool
        {
            return in_array($orderId, $this->verifiedOrders);
        }
    
        public function wasOrderPlaced(string $orderId): bool
        {
            return in_array($orderId, $this->placedOrders);
        }
    }
    
    class WorkflowChainTest extends TestCase
    {
        public function test_successful_order_workflow(): void
        {
            $processor = new ProcessOrder();
            $ecotoneLite = EcotoneLite::bootstrapFlowTesting(
                [ProcessOrder::class],
                [$processor]
            );
    
            // Send to the first step of the workflow
            $command = new PlaceOrder('order-123', 'customer-456');
            $ecotoneLite->sendDirectToChannel('verify.order', $command);
    
            // Verify both steps executed
            $this->assertTrue($processor->wasOrderVerified('order-123'));
            $this->assertTrue($processor->wasOrderPlaced('order-123'));
        }
    
        public function test_workflow_stops_on_validation_error(): void
        {
            $processor = new ProcessOrder();
            $ecotoneLite = EcotoneLite::bootstrapFlowTesting(
                [ProcessOrder::class],
                [$processor]
            );
    
            // This should fail validation and not reach the place step
            $this->expectException(InvalidOrderException::class);
    
            $command = new PlaceOrder('invalid-order', 'customer-456');
            $ecotoneLite->sendDirectToChannel('verify.order', $command);
    
            // Verify verification was attempted but placement was not
            $this->assertTrue($processor->wasOrderVerified('invalid-order'));
            $this->assertFalse($processor->wasOrderPlaced('invalid-order'));
        }
    }
    class AsyncProcessor
    {
        private array $processedItems = [];
    
        #[CommandHandler(
            'start.async',
            outputChannelName: 'async.process'
        )]
        public function start(ProcessItem $item): ProcessItem
        {
            return $item;
        }
    
        #[Asynchronous('async')]
        #[InternalHandler('async.process')]
        public function processAsync(ProcessItem $item): void
        {
            $this->processedItems[] = $item->id;
        }
    
        public function getProcessedItems(): array
        {
            return $this->processedItems;
        }
    }
    
    public function test_async_workflow_in_sync_mode(): void
    {
        $processor = new AsyncProcessor();
        $ecotoneLite = EcotoneLite::bootstrapFlowTesting(
            [AsyncProcessor::class],
            [$processor],
            ServiceConfiguration::createWithDefaults()
                ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::CORE_PACKAGE]))
                // This makes async handlers run synchronously for testing
                ->withDefaultSerializationMediaType(MediaType::APPLICATION_X_PHP)
        );
    
        $item = new ProcessItem(id: 'item-123');
        $ecotoneLite->sendCommand($item);
    
        // Verify async processing completed
        $this->assertContains('item-123', $processor->getProcessedItems());
    }
    After one of our administrators went for holiday, the others found out, they can't change cost of the product and this become really problematic for them.

    Administrators should be able to change the cost of a product

    We could copy paste the logic from product.register to product.changePricebut we want to avoid code duplication, especially logic that may happen more often. Let's intercept our Command Handlers.

    Let's start by creating Annotation called RequireAdministrator in new namepace App\Infrastructure\RequireAdministrator

    Let's create our first Before Interceptor. Start by removing old UserService and create new one in different namespace App\Infrastructure\RequireAdministrator. Remember to mark return type as void, we will see why it is so important soon.

    Before- marks method as Interceptor, so it can be be found by Ecotone.

    Pointcut - describes what should be intercepted.

    • CLASS_NAME - indicates what should be intercepted using specific Class Name or Attribute Name annotated at the level of method or class

      pointcut="App\Domain\Product\Product"
    • NAMESPACE* - Indicating all Endpoints starting with namespace e.g. App\Domain\Product\*

    • expression||expression - Indicating one expression or another e.g. Product\*||Order\*

    Now we need to annotate our Command Handlers:

    We told Before Interceptor that it should intercept all endpoints with annotation RequireAdministrator. Now, whenever we will call our command handlers, they will be intercepted by UserService. You can try it out, by providing different userId.

    Enrich Message

    Before and After interceptors are depending on the return type, to decide if they should modify Message or pass it through. If return type is different than void, Message payload or headers can be enriched with data. If return type is void then message will be passed through and the process of message flow can be interrupted by throwing exception only.

    Instead of providing the userId during calling the CommandBus we will enrich Message with it before it will be handled by Command Handler using Interceptor.

    Let's change our testing class to remove metadata and add the Interceptor.

    changeHeaders - Tells Ecotone if this Interceptor modifies payload or headers. The default is payload. If changeHeaders=true thenheaders are picked and associative array must be returned. The returned value is merged with current headers. If changeHeaders=false then payload is picked and current payload is replaced by returned value, the headers stays the same. You may of course inject current payload and headers into the method if needed, as with usual endpoint. &#xNAN;precedence - Tells Ecotone in what order interceptors should be called. The lower the value is the quicker interceptor will be called. The order exists within interceptor type: before/around/after. We want to call AddUserId Interceptor before RequireAdministrator Interceptor as it require userId to exists, in order to verify. AddUserIdService has precedence of 0 as default, so UserService must have at least 1.

    Let's annotate Product aggregate

    If we annotate aggregate on the class level. Then it does work like each of the method would be annotated with specific annotation in this case @AddUserId.

    Breaking the flow

    If during Before or Around you decide to break the flow, return null. Nullindiciates, that there is no message and the current flow ends. Null can not be returned in header changing interceptor, it does work only for payload changing interceptor.

    Around Interceptor

    The Around Interceptor is closet to actual endpoint's method call. Thanks to that, it has access to Method Invocation.This does allow for starting some procedure and ending after the invocation is done.

    Let's start by implementing repository, that will be able to handle any aggregate, by storing it in sqlite database. Before we do that, we need to remove our In Memory implementation class App\Domain\Product\InMemoryProductRepository we will replace it with our new implementation. We will create using new namespace for it App\Infrastructure\Persistence. Besides we are going to use doctrine/dbal, as this is really helpful abstraction over the PDO.

    And the Repository:

    1. Connection to sqlite database using dbal library

    2. Serializer is Gateway registered by Ecotone. Serializer can handle serialization using Converters. It this case it will know how to register Cost class, as we already registered Converter for it. Serializer give us access for conversion from PHP type to specific Media Type or from specific Media Type to PHP type. We will use it to easily serialize our Product model into JSON and store it in database.

    3. This does create database table, if needed. It does create simple table structure containing id of the aggregate, the class type and serialized data in JSON. Take a look at createSharedTableIfNeeded if you want more details.

    4. Deserialize aggregate to PHP

    5. Serialize aggregate to JSON

    We want to intercept Command Bus Gateway with transaction. So whenever we call it, it will invoke our Command Handler within transaction.

    pointcut="Ecotone\Modelling\CommandBus"

    This pointcut will intercept CommandBus.

    We do have two transactions started, because we call the Command Bus twice.

    Parameter Converters for Interceptors

    Each of interceptors, can inject attribute, which was used for pointcut. Just type hint for it in method declaration. Around interceptors can inject intercepted class instance. In above example it would be Command Bus. In case of Command Bus it may seems not needed, but if we would intercept Aggregate, then it really useful as for example you may verify if executing user have access to it. You may read more about interceptors in dedicated section.

    Not having code for Lesson 5?

    git checkout lesson-5

    If you are familiar with Aspect Oriented Programming or Middleware pattern you may find some similarities.

    cross cutting concerns
    message
    namespace App\Infrastructure\RequireAdministrator;
    
    #[\Attribute]
    class RequireAdministrator {}
    namespace App\Infrastructure\RequireAdministrator;
    
    class UserService
    {
        #[Before(pointcut: RequireAdministrator::class)]
        public function isAdmin(#[Header("userId")] ?string $userId) : void
        {
            if ($userId != 1) {
                throw new \InvalidArgumentException("You need to be administrator to perform this action");
            }
        }
    }
    use App\Infrastructure\RequireAdministrator\RequireAdministrator;
    (...)
    
    #[CommandHandler("product.register")]
    #[RequireAdministrator]
    public static function register(RegisterProductCommand $command, array $metadata) : self
    {
        return new self($command->getProductId(), $command->getCost(), $metadata["userId"]);
    }
    
    #[CommandHandler("product.changePrice")]
    #[RequireAdministrator]
    public function changePrice(ChangePriceCommand $command) : void
    {
        $this->cost = $command->getCost();
    }
    public function run() : void
    {
        $this->commandBus->sendWithRouting(
            "product.register",
            \json_encode(["productId" => 1, "cost" => 100]),
            "application/json"
        );
    
        $this->commandBus->sendWithRouting(
            "product.changePrice",
            \json_encode(["productId" => 1, "cost" => 110]),
            "application/json"
        );
    
        echo $this->queryBus->sendWithRouting("product.getCost", \json_encode(["productId" => 1]), "application/json");
    }
    namespace App\Infrastructure\AddUserId;
    
    #[\Attribute]
    class AddUserId {}
    namespace App\Infrastructure\AddUserId;
    
    class AddUserIdService
    {
        #[Before(precedence: 0, pointcut: AddUserId::class, changeHeaders: true)]
        public function add() : array
        {
            return ["userId" => 1];
        }
    }
    class UserService
    {
        #[Before(precedence: 1,pointcut: RequireAdministrator::class)]
        public function isAdmin(#[Header("userId")] ?string $userId) : void
        {
            if ($userId != 1) {
                throw new \InvalidArgumentException("You need to be administrator in order to register new product");
            }
        }
    }
    use App\Infrastructure\AddUserId\AddUserId;
    
    #[Aggregate]
    #[AddUserId]
    class Product
    {
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    110
    Good job, scenario ran with success!
    composer require doctrine/dbal
    namespace App\Infrastructure\Persistence;
    
    use Doctrine\DBAL\Connection;
    use Doctrine\DBAL\DriverManager;
    use Ecotone\Messaging\Gateway\Converter\Serializer;
    use Ecotone\Modelling\Attribute\Repository;
    use Ecotone\Modelling\StandardRepository;
    
    #[Repository]
    class DbalRepository implements StandardRepository
    {
        const TABLE_NAME = "aggregate";
        const CONNECTION_DSN = 'sqlite:////tmp/db.sqlite';
    
        private Connection $connection; // 1
    
        private Serializer $serializer; // 2
    
        public function __construct(Serializer $serializer)
        {
            $this->connection = DriverManager::getConnection(array('url' => self::CONNECTION_DSN));
            $this->serializer = $serializer;
        }
    
        public function canHandle(string $aggregateClassName): bool
        {
            return true;
        }
    
        public function findBy(string $aggregateClassName, array $identifiers): ?object
        {
            $this->createSharedTableIfNeeded(); // 3
    
            $record = $this->connection->executeQuery(<<<SQL
        SELECT * FROM aggregate WHERE id = :id AND class = :class
    SQL, ["id" => $this->getFirstId($identifiers), "class" => $aggregateClassName])->fetch(\PDO::FETCH_ASSOC);
    
            if (!$record) {
                return null;
            }
    
            // 4
            return $this->serializer->convertToPHP($record["data"],  "application/json", $aggregateClassName);
        }
    
        public function save(array $identifiers, object $aggregate, array $metadata, ?int $expectedVersion): void
        {
            $this->createSharedTableIfNeeded();
    
            $aggregateClass = get_class($aggregate);
            // 5
            $data = $this->serializer->convertFromPHP($aggregate, "application/json");
    
            if ($this->findBy($aggregateClass, $identifiers)) {
                $this->connection->update(self::TABLE_NAME,
                    ["data" => $data],
                    ["id" => $this->getFirstId($identifiers), "class" => $aggregateClass]
                );
    
                return;
            }
    
            $this->connection->insert(self::TABLE_NAME, [
                "id" => $this->getFirstId($identifiers),
                "class" => $aggregateClass,
                "data" => $data
            ]);
        }
    
        private function createSharedTableIfNeeded(): void
        {
            $hasTable = $this->connection->executeQuery(<<<SQL
    SELECT name FROM sqlite_master WHERE name=:tableName
    SQL, ["tableName" => self::TABLE_NAME])->fetchColumn();
    
            if (!$hasTable) {
                $this->connection->executeStatement(<<<SQL
    CREATE TABLE aggregate (
        id VARCHAR(255),
        class VARCHAR(255),
        data TEXT,
        PRIMARY KEY (id, class)
    )
    SQL
                );
            }
        }
    
        /**
         * @param array $identifiers
         * @return mixed
         */
        private function getFirstId(array $identifiers)
        {
            return array_values($identifiers)[0];
        }
    }
    namespace App\Infrastructure\Persistence;
    
    use Doctrine\DBAL\Connection;
    use Doctrine\DBAL\DriverManager;
    use Ecotone\Messaging\Attribute\Interceptor\Around;
    use Ecotone\Messaging\Handler\Processor\MethodInvoker\MethodInvocation;
    use Ecotone\Modelling\CommandBus;
    
    class TransactionInterceptor
    {
        private Connection $connection;
    
        public function __construct()
        {
            $this->connection = DriverManager::getConnection(array('url' => DbalRepository::CONNECTION_DSN));
        }
    
        #[Around(pointcut: CommandBus::class)]
        public function transactional(MethodInvocation $methodInvocation)
        {
            echo "Start transaction\n";
            $this->connection->beginTransaction();
            try {
                $result = $methodInvocation->proceed();
    
                $this->connection->commit();
                echo "Commit transaction\n";
            }catch (\Throwable $exception) {
                $this->connection->rollBack();
                echo "Rollback transaction\n";
    
                throw $exception;
            }
    
            return $result;
        }
    }
    bin/console ecotone:quickstart
    Start transaction
    Product with id 1 was registered!
    Commit transaction
    Start transaction
    Commit transaction
    110
    Good job, scenario ran with success!

    Let's run our testing command:

    We will add real database to our example using if you do not have extension installed, then you will need to install it first. Yet if you are using Quickstart's Docker container, then you are ready to go.

    You do not need to focus too much on the Repository implementation, this is just example. In your application, you may implement it using your ORM or whatever fits you best. &#xNAN;This implementation will override aggregate for registerProduct, if one already exists. It will will insert or update if aggregate exists.

    Let's run our testing command:

    Great, we have just finished Lesson 5! Interceptors are very powerful concept. Without extending any classes or interfaces from Ecotone, we can build build up Authorization, Transactions, Delegate duplicated logic, Call some external service, Logging and Tracing before invoking endpoint, the amount of possibilities is endless. In the next chapter, we will learn about scheduling and polling endpoints

    Lesson 1: Messaging Concepts

    PHP Messages

    Key concepts / background\

    Ecotone from the ground is built around messaging to provide a simple model that allows to connects components, modules or even different Applications together, in seamless and easy way. To achieve that fundamental messaging blocks are implemented using On top of what we get support for higher level patterns like CQRS, Events, DDD - which help us build systems that make the business logic explicit and maintainable, even in the long term. In this first lesson, we will learn fundamental blocks in messaging architecture and we will start building back-end for Shopping System using CQRS. Before we will dive into implementation, let's briefly understand main concepts behind Ecotone.

    Lesson 6: Asynchronous Handling

    Asynchronous PHP Workers

    Placing an order triggers four side-effects: charge the card, reserve stock, send the confirmation email, notify the warehouse. Doing them all synchronously means the customer waits four seconds — and one slow third-party times out the whole request. In this lesson we'll push that work onto a queue with one attribute.

    Asynchronous

    We got a new requirement: User should be able to place order for different products.

    We will need to build an

    Event Serialization and PII Data (GDPR)

    Event serialization and GDPR-compliant PII data handling

    Event Serialization

    Ecotone use in order to convert Events into serializable form. This means we can customize process of serializing and deserializing specific Events, to adjust it to our Application.

    So let's assume UserCreated Event:

    If we would want to change how the Event is serialized, we would define Converter

    sqlite
    Message

    A Message is a data record containing of Payload and Message Headers (Metadata). The Payload can be of any PHP type (scalar, object, compound), and the Headers hold commonly required information such as ID, timestamp and framework specific information. Developers can also store any arbitrary key-value pairs in the headers, to pass additional meta information.

    Message Channel

    Message channel abstracts communication between components. It does allow for sending and receiving messages. This decouples components from knowledge about the transport layer, as it's encapsulated within the Message Channel.

    Message Endpoint

    Message Endpoints are consumers and producers of messages. Consumer are not necessary asynchronous, as you may build synchronous flow, compound of multiple endpoints.

    Messaging Gateway

    The Messaging Gateway encapsulates messaging-specific code (The code required to send or receive a Message) and separates it from the rest of the application code. It take your domain specific objects an convert them into a Message that is send via Message channel. To not have dependency on the Messaging Framework Ecotone provides the Gateway as interface and generates proxy class for it.

    Business Logic

    You will not have to implement Messages, Message Channels or Message Endpoints directly, as those are lower level concepts. Instead you will be able to focus on your specific domain logic with an implementation based on plain PHP objects. By providing declarative configuration we will be able to connect domain-specific code to the messaging system.

    To The Code!

    Do you remember this command from Setup part?

    If yes and this command does return above output, then we are ready to go.

    https://github.com/ecotoneframework/quickstart-symfony/blob/lesson-1/src/EcotoneQuickstart.php
    Go to "src/EcotoneQuickstart.php"
    
    # This class is autoregistered using Symfony Autowire
    https://github.com/ecotoneframework/quickstart-laravel/blob/lesson-1/app/EcotoneQuickstart.php
    Go to "app/EcotoneQuickstart.php"
    
    # This class is autoregistered using Laravel Autowire
    https://github.com/ecotoneframework/quickstart-lite/blob/lesson-1/src/EcotoneQuickstart.php
    
    
    Go to "src/EcotoneQuickstart.php"
    
    # This class is autoregistered using PHP-DI

    this method will be run, whenever we executeecotone:quickstart. This class is auto-registered using auto-wire system, both Symfony and Laravel provides this great feature. For Lite clean and easy to use PHP-DI is taken.\

    Thanks to that, we will avoid writing configuration files for service registrations during this tutorial. And we will be able to fully focus on what can Ecotone provides to us.

    Command Handler - Endpoint

    We will start by creating Command Handler. Command Handler is place where we will put our business logic. Let's create namespace App\Domain\Product and inside RegisterProductCommand, command for registering new product:

    Let's register a Command Handler now by creating class App\Domain\Product\ProductService

    First thing worth noticing is #[CommandHandler]. This attribute marks our register method in ProductService as an Endpoint, from that moment it can be found by Ecotone.

    Ecotone will read method declaration and base on the first parameter type hint will know that this CommandHandler is responsible for handling RegisterProductCommand.

    Query Handler - Endpoint

    We also need the possibility to query ProductService for registered products and this is the role of Query Handlers. Let's starts with GetProductPriceQuery class. This query will tell us what is the price of specific product.

    We also need Handler for this query. Let's add Query Handler to the ProductService

    # As default auto wire of Laravel creates new service instance each time 
    # service is requested from Depedency Container, for our examples 
    # we want to register ProductService as singleton.
    
    # Go to bootstrap/QuickStartProvider.php and register our ProductService
    
    namespace Bootstrap;
    
    use App\Domain\Product\ProductService;
    use Illuminate\Support\ServiceProvider;
    
    class QuickStartProvider extends ServiceProvider
    {
        public function register()
        {
            $this->app->singleton(ProductService::class, function(){
                return new ProductService();
            });
        }
    (...)
    Everything is set up by the framework, please continue...
    Everything is set up, please continue...

    Command and Query Bus - Gateways

    It's time to call our Endpoints. You may remember that endpoints need to be connected using Message Channels and we did not do anything like this yet. Thankfully Ecotone does create synchronous channels for us, therefore we don't need to bother about it.

    We need to create Message and send it to correct Message Channel.

    Let's inject and call Query and Command bus into EcotoneQuickstart class.

    1. Gateways are auto registered in Dependency Container and available for auto-wire. Ecotone comes with few Gateways out of the box like Command and Query buses.

    2. We are sending command RegisterProductCommand to the CommandHandler we registered before.

    3. Same as above, but in that case we are sending query GetProductPriceQuery to the QueryHandler

    If you run our testing command now, you should see the result.

    Event Handling

    We want to notify, when new product is registered in the system. In order to do it, we will make use of Event Bus Gateway which can publish events. Let's start by creating ProductWasRegisteredEvent.

    Let's inject EventBus into our CommandHandler in order to publish ProductWasRegisteredEvent after product was registered.

    Ecotone does control method invocation for endpoints, if you have type hinted for specific class, framework will look in Dependency Container for specific service in order to inject it automatically. In this scenario it injects for us Event Bus. If you want to know more, check chapter about Method Invocation.

    Now, when our event is published, whenever new product is registered, we want to subscribe to that Event and send notification. Let's create new class and annotate method with EventHandler.

    1. EventHandler tells Ecotone to handle specific event based on declaration type hint, just like with CommandHandler.

    If you run our testing command now, you should see the result.

    Not having code for Lesson 1?

    git checkout lesson-1

    Enterprise Integration Patterns
    interface Message
    {
        public function getPayload();
    
        public function getHeaders() : MessageHeaders;
    }
    bin/console ecotone:quickstart
    "Running example...
    Hello World
    Good job, scenario ran with success!"
    <?php
    
    namespace App;
    
    class EcotoneQuickstart
    {
        public function run() : void
        {
            echo "Hello World";
        }
    }
    <?php
    
    namespace App\Domain\Product;
    
    class RegisterProductCommand
    {
        private int $productId;
    
        private int $cost;
    
        public function __construct(int $productId, int $cost)
        {
            $this->productId = $productId;
            $this->cost = $cost;
        }
    
        public function getProductId() : int
        {
            return $this->productId;
        }
    
        public function getCost() : int
        {
            return $this->cost;
        }
    }
    <?php
    
    namespace App\Domain\Product;
    
    use Ecotone\Modelling\Attribute\CommandHandler;
    
    class ProductService
    {
        private array $registeredProducts = [];
        
        #[CommandHandler]
        public function register(RegisterProductCommand $command) : void
        {
            $this->registeredProducts[$command->getProductId()] = $command->getCost();
        }
    }
    <?php
    
    namespace App\Domain\Product;
    
    class GetProductPriceQuery
    {
        private int $productId;
    
        public function __construct(int $productId)
        {
            $this->productId = $productId;
        }
    
        public function getProductId() : int
        {
            return $this->productId;
        }
    }
    <?php
    
    namespace App\Domain\Product;
    
    use Ecotone\Modelling\Attribute\CommandHandler;
    use Ecotone\Modelling\Attribute\QueryHandler;
    
    class ProductService
    {
        private array $registeredProducts = [];
    
        #[CommandHandler]
        public function register(RegisterProductCommand $command) : void
        {
            $this->registeredProducts[$command->getProductId()] = $command->getCost();
        }
    
        #[QueryHandler] 
        public function getPrice(GetProductPriceQuery $query) : int
        {
            return $this->registeredProducts[$query->getProductId()];
        }
    }
    <?php
    
    namespace App;
    
    use App\Domain\Product\GetProductPriceQuery;
    use App\Domain\Product\RegisterProductCommand;
    use Ecotone\Modelling\CommandBus;
    use Ecotone\Modelling\QueryBus;
    
    class EcotoneQuickstart
    {
        private CommandBus $commandBus;
        private QueryBus $queryBus;
    
    // 1
        public function __construct(CommandBus $commandBus, QueryBus $queryBus)
        {
            $this->commandBus = $commandBus;
            $this->queryBus = $queryBus;
        }
    
        public function run() : void
        {
    // 2    
            $this->commandBus->send(new RegisterProductCommand(1, 100));
    // 3
            echo $this->queryBus->send(new GetProductPriceQuery(1));
        }
    }
    bin/console ecotone:quickstart
    Running example...
    100
    Good job, scenario ran with success!
    <?php
    
    namespace App\Domain\Product;
    
    class ProductWasRegisteredEvent
    {
        private int $productId;
    
        public function __construct(int $productId)
        {
            $this->productId = $productId;
        }
    
        public function getProductId() : int
        {
            return $this->productId;
        }
    }
    use Ecotone\Modelling\EventBus;
    
     #[CommandHandler]
    public function register(RegisterProductCommand $command, EventBus $eventBus) : void
    {
        $this->registeredProducts[$command->getProductId()] = $command->getCost();
    
        $eventBus->publish(new ProductWasRegisteredEvent($command->getProductId()));
    }
    <?php
    
    namespace App\Domain\Product;
    
    use Ecotone\Modelling\Attribute\EventHandler;
    
    class ProductNotifier
    {
        #[EventHandler] // 1
        public function notifyAbout(ProductWasRegisteredEvent $event) : void
        {
            echo "Product with id {$event->getProductId()} was registered!\n";
        }
    }
    bin/console ecotone:quickstart
    Running example...
    Product with id 1 was registered!
    100
    Good job, scenario ran with success!

    If you are familiar with Symfony Messager/Simplebus, for now you can think of Endpoint as a Message Handler, that can be connected to asynchronous or synchronous transport.

    Command/Query/Event buses are implemented using Messaging Gateway.

    Great, now when we know fundamental blocks of Ecotone and Messaging Architecture, we can start implementing our Shopping System! If you did not understand something, do not worry, we will see how does it apply in practice in next step.

    Describing types, will help us in later lessons with automatic conversion. Just remember right now, that it's worth to keep the types defined.

    Ecotone make use to provide declarative configuration. In most of the scenarios we will be stating "what" we want to achieve with Attributes, and Ecotone will take care of "how". This way our application logic will stay decoupled from the technical concerns.

    #[ClassReference] is a special it informs Ecotonehow this service is registered in Depedency Container. As a default it takes the class name, which is compatible with auto-wiring system. If ProductService would be registered in Dependency Container as "productService", we would use the Attribute this way:

    Some CQRS frameworks expects Handlers be defined as a class, not method. This is somehow limiting and producing a lot of boilerplate. Ecotone does allow for full flexibility, if you want to have only one handler per class, so be it, otherwise just annotate next methods.

    Synchronous channels are created automatically for our Message Handlers. We will learn easily can they be replaced with asynchronous channels in next lessons.

    In order to send Message we will use . Message Gateways are responsible for creating Message from given parameters and send them to the correct channel. Special types of Gateways are Command and Query Buses: - For sending Commands we will use Command Bus. - For sending Queries we will use Query Bus.

    As you can see we have not defined any Message Channels, Messages or Gateways, yet they all being used in this scenario. This is can happen because Ecotone is using high level abstractions so our daily development is focused on the business side of the code, yet under the hood is using powerful Messaging capabilities.

    As you can see Ecotone does not really care what class Command/Query/Event is. It does not require to implement any interfaces neither prefix or suffix the class name. In fact commands, queries and events can be of any type and we will see it in next Lessons. In the tutorial however we use Command/Query/Event suffixes to clarify the distinction.

    Commands are targeting single Handler, Events on other hand can have multiple Handlers subscribing to it.

    Great, we have just finished Lesson 1. In this lesson we have learned basic of Messaging and CQRS. That was the longest lesson, as it had to introduce new concepts. Incoming lessons will be much shorter :)

    We are ready for Lesson 2!

    Lesson 2: Tactical DDD
    Order
    aggregate.

    Let's start by creating PlaceOrderCommand with ordered product Ids

    We will need OrderedProduct value object, which will describe, cost and identifier of ordered product

    And our Order aggregate

    placeOrder - Place order method make use of QueryBus to retrieve cost of each ordered product. You could find out, that we are not using application/json for product.getCost query, ecotone/jms-converter can handle array transformation, so we do not need to use json.

    We want to be sure, that we do not lose any order, so we will register our order.place Command Handler to run asynchronously using RabbitMQ now. Let's start by adding extension to Ecotone, that can handle RabbitMQ:

    We also need to add our ConnectionFactory to our Dependency Container.

    # Add AmqpConnectionFactory in config/services.yaml
    
    services:
        _defaults:
            autowire: true
            autoconfigure: true
        App\:
            resource: '../src/*'
            exclude: '../src/{Kernel.php}'
        Bootstrap\:
            resource: '../bootstrap/*'
            exclude: '../bootstrap/{Kernel.php}'
    
    # You need to have RabbitMQ instance running on your localhost, or change DSN
        Enqueue\AmqpExt\AmqpConnectionFactory:
            class: Enqueue\AmqpExt\AmqpConnectionFactory
            arguments:
                - "amqp+lib://guest:guest@localhost:5672//"
    # Add AmqpConnectionFactory in config/services.yaml
    
    services:
        _defaults:
            autowire: true
            autoconfigure: true
        App\:
            resource: '../src/*'
            exclude: '../src/{Kernel.php}'
        Bootstrap\:
            resource: '../bootstrap/*'
            exclude: '../bootstrap/{Kernel.php}'
    
    # docker-compose.yml has RabbitMQ instance defined. It will be working without
    # addtional configuration
        Enqueue\AmqpExt\AmqpConnectionFactory:
            class: Enqueue\AmqpExt\AmqpConnectionFactory
            arguments:
                - "amqp+lib://guest:guest@rabbitmq:5672//"
    # Add AmqpConnectionFactory in bootstrap/QuickStartProvider.php
    
    namespace Bootstrap;
    
    use Illuminate\Support\ServiceProvider;
    use Enqueue\AmqpExt\AmqpConnectionFactory;
    
    class QuickStartProvider extends ServiceProvider
    {
        public function register()
        {
            $this->app->singleton(AmqpConnectionFactory::class, function () {
                return new AmqpConnectionFactory("amqp+lib://guest:guest@localhost:5672//");
            });
        }
    (...)
    # Add AmqpConnectionFactory in bootstrap/QuickStartProvider.php
    
    namespace Bootstrap;
    
    use Illuminate\Support\ServiceProvider;
    use Enqueue\AmqpExt\AmqpConnectionFactory;
    
    class QuickStartProvider extends ServiceProvider
    {
        public function register()
        {
            $this->app->singleton(AmqpConnectionFactory::class, function () {
                return new AmqpConnectionFactory("amqp+lib://guest:guest@rabbitmq:5672//");
            });
        }
    (...)
    # Add AmqpConnectionFactory in bin/console.php
    
    // add additional service in container
    public function __construct()
    {
       $this->container = new Container();
       $this->container->set(Enqueue\AmqpExt\AmqpConnectionFactory::class, new Enqueue\AmqpExt\AmqpConnectionFactory("amqp+lib://guest:guest@localhost:5672//"));
    }
    
    
    # Add AmqpConnectionFactory in bin/console.php 
    
    // add additional service in container
    public function __construct()
    {
       $this->container = new Container();
       $this->container->set(Enqueue\AmqpExt\AmqpConnectionFactory::class, new Enqueue\AmqpExt\AmqpConnectionFactory("amqp+lib://guest:guest@rabbitmq:5672//"));
    }

    Let's add our first AMQP Backed Channel (RabbitMQ Channel), in order to do it, we need to create our first Service Context. A Service Context is a non-constructor class, responsible for extending Ecotone with extra configurations, that will help the framework act in a specific way. In here we want to tell Ecotone about AMQP Channel with specific name. Let's create new class App\Infrastructure\MessagingConfiguration.

    ServiceContext - Tell that this method returns configuration. It can return array of objects or a single object.

    Now we need to tell our order.place Command Handler, that it should run asynchronously using our neworders channel.

    We do it by adding Asynchronous annotation with channelName used for asynchronous endpoint. Endpoints using Asynchronous are required to have endpointId defined, the name can be anything as long as it's not the same as routing key (order.place).

    We have new asynchronous endpoint available orders. Name comes from the message channel name. You may wonder why it is not place_order_endpoint, it's because via single asynchronous channel we can handle multiple endpoints, if needed. This is further explained in asynchronous section.

    Let's change orderId in our testing command, so we can place new order.

    After running our testing command bin/console ecotone:quickstartwe should get an exception:

    That's fine, we have registered order.place Command Handler to run asynchronously, so we need to run our asynchronous endpoint in order to handle Command Message. If you did not received and exception, it's probably because orderId was not changed and we already registered such order. Let's run our asynchronous endpoint

    Like we can see, it ran our Command Handler and placed the order. We can change our testing command to run only Query Handlerand check, if the order really exists now.

    There is one thing we can change. As in asynchronous scenario we may not have access to the context of executor to enrich the message,, we can change our AddUserIdService Interceptor to perform the action before sending it to asynchronous channel. This Interceptor is registered as Before Interceptor which is before execution of our Command Handler, but what we want to achieve is, to call this interceptor before message will be send to the asynchronous channel. For this there is Presend Interceptor available. Change Before annotation to Presend annotation and we are done.

    Now if non-administrator will try to execute this, exception will be thrown, before the Message will be put to the asynchronous channel. Thanks to Presend interceptor, we can validate messages, before they will go asynchronous, to prevent sending incorrect messages.

    Not having code for Lesson 6? git checkout lesson-6

    namespace App\Domain\Order;
    
    class PlaceOrderCommand
    {
        private int $orderId;
    
        /**
         * @var int[]
         */
        private array $productIds;
    
        /**
         * @return int[]
         */
        public function getProductIds(): array
        {
            return $this->productIds;
        }
    
        public function getOrderId() : int
        {
            return $this->orderId;
        }
    }
    namespace App\Domain\Order;
    
    class OrderedProduct
    {
        private int $productId;
    
        private int $cost;
    
        public function __construct(int $productId, int $cost)
        {
            $this->productId = $productId;
            $this->cost = $cost;
        }
    
        public function getCost(): int
        {
            return $this->cost;
        }
    }
    namespace App\Domain\Order;
    
    use App\Infrastructure\AddUserId\AddUserId;
    use Ecotone\Messaging\Attribute\Asynchronous;
    use Ecotone\Modelling\Attribute\Aggregate;
    use Ecotone\Modelling\Attribute\Identifier;
    use Ecotone\Modelling\Attribute\CommandHandler;
    use Ecotone\Modelling\Attribute\QueryHandler;
    use Ecotone\Modelling\QueryBus;
    
    #[Aggregate]
    #[AddUserId]
    class Order
    {
        #[Identifier]
        private int $orderId;
    
        private int $buyerId;
    
        /**
         * @var OrderedProduct[]
         */
        private array $orderedProducts;
    
        private function __construct(int $orderId, int $buyerId, array $orderedProducts)
        {
            $this->orderId = $orderId;
            $this->buyerId = $buyerId;
            $this->orderedProducts = $orderedProducts;
        }
        
        #[CommandHandler("order.place")]
        public static function placeOrder(PlaceOrderCommand $command, array $metadata, QueryBus $queryBus) : self
        {
            $orderedProducts = [];
            foreach ($command->getProductIds() as $productId) {
                $productCost = $queryBus->sendWithRouting("product.getCost", ["productId" => $productId]);
                $orderedProducts[] = new OrderedProduct($productId, $productCost->getAmount());
            }
    
            return new self($command->getOrderId(), $metadata["userId"], $orderedProducts);
        }
    
        #[QueryHandler("order.getTotalPrice")]
        public function getTotalPrice() : int
        {
            $totalPrice = 0;
            foreach ($this->orderedProducts as $orderedProduct) {
                $totalPrice += $orderedProduct->getCost();
            }
    
            return $totalPrice;
        }
    }
    class EcotoneQuickstart
    {
        private CommandBus $commandBus;
        private QueryBus $queryBus;
    
        public function __construct(CommandBus $commandBus, QueryBus $queryBus)
        {
            $this->commandBus = $commandBus;
            $this->queryBus = $queryBus;
        }
    
        public function run() : void
        {
            $this->commandBus->sendWithRouting(
                "product.register",
                ["productId" => 1, "cost" => 100]
            );
            $this->commandBus->sendWithRouting(
                "product.register",
                ["productId" => 2, "cost" => 300]
            );
    
            $orderId = 100;
            $this->commandBus->sendWithRouting(
                "order.place",
                ["orderId" => $orderId, "productIds" => [1,2]]
            );
    
            echo $this->queryBus->convertAndSend("order.getTotalPrice", MediaType::APPLICATION_X_PHP_ARRAY, ["orderId" => $orderId]);
        }
    }
    bin/console ecotone:quickstart
    Running example...
    Start transaction
    Product with id 1 was registered!
    Commit transaction
    Start transaction
    Product with id 2 was registered!
    Commit transaction
    Start transaction
    Commit transaction
    400
    Good job, scenario ran with success!
    composer require ecotone/amqp
    namespace App\Infrastructure;
    
    class MessagingConfiguration
    {
        #[ServiceContext]
        public function orderChannel()
        {
            return [
                AmqpBackedMessageChannelBuilder::create("orders")
            ];
        }
    }
    use Ecotone\Messaging\Annotation\Asynchronous;
    
    (...)
    
    #[Asynchronous("orders")]
    #[CommandHandler("order.place", endpointId: "place_order_endpoint")]
    public static function placeOrder(PlaceOrderCommand $command, array $metadata, QueryBus $queryBus) : self
    {
        $orderedProducts = [];
        foreach ($command->getProductIds() as $productId) {
            $productCost = $queryBus->sendWithRouting("product.getCost", ["productId" => $productId]);
            $orderedProducts[] = new OrderedProduct($productId, $productCost->getAmount());
        }
    
        return new self($command->getOrderId(), $metadata["userId"], $orderedProducts);
    }
    #[CommandHandler("order.place", endpointId: "place_order_endpoint")]
    bin/console ecotone:list
    +--------------------+
    | Endpoint Names     |
    +--------------------+
    | orders             |
    +--------------------+
    public function run() : void
    {
        $this->commandBus->sendWithRouting(
            "product.register",
            ["productId" => 1, "cost" => 100]
        );
        $this->commandBus->sendWithRouting(
            "product.register",
            ["productId" => 2, "cost" => 300]
        );
    
        $orderId = 990;
        $this->commandBus->sendWithRouting(
            "order.place",
            ["orderId" => $orderId, "productIds" => [1,2]]
        );
    
        echo $this->queryBus->sendWithRouting("order.getTotalPrice", ["orderId" => $orderId]);
    }
    AggregateNotFoundException:
                                                                                   
      Aggregate App\Domain\Order\Order:getTotalPrice was not found for indentifie  
      rs {"orderId":990}  
    bin/console ecotone:run orders --handledMessageLimit=1 --stopOnFailure -vvv
    [info] {"orderId":990,"productIds":[1,2]}
    class EcotoneQuickstart
    {
        private CommandBus $commandBus;
        private QueryBus $queryBus;
    
        public function __construct(CommandBus $commandBus, QueryBus $queryBus)
        {
            $this->commandBus = $commandBus;
            $this->queryBus = $queryBus;
        }
    
        public function run() : void
        {
            $orderId = 990;
    
            echo $this->queryBus->sendWithRouting("order.getTotalPrice", ["orderId" => $orderId]);
        }
    }
    bin/console ecotone:quickstart -vvv
    Running example...
    400
    Good job, scenario ran with success!
    namespace App\Infrastructure\AddUserId;
    
    class AddUserIdService
    {
       #[Presend(0, AddUserId::class, true)]
        public function add() : array
        {
            return ["userId" => 1];
        }
    }

    You could inject service into placeOrder that will hide QueryBus implementation from the domain, or you may get this data from data store directly. We do not want to complicate the solution now, so we will use QueryBus directly.

    We do not need to change or add new Repository, as our exiting one can handle any new aggregate arriving in our system.

    Let's change our testing class and run it!

    We register our AmqpConnectionFactory under the class name Enqueue\AmqpLib\AmqpConnectionFactory. This will help Ecotone resolve it automatically, without any additional configuration.

    You may mark as asynchronous the same way.

    Let's run our command which will tell us what asynchronous endpoints we have defined in our system: ecotone:list

    Ecotone will do it best to handle serialization and deserialization of your headers.

    The final code is available as lesson-7: git checkout lesson-7

    We made it through, Congratulations! We have successfully registered asynchronous Command Handler and safely placed the order. We have finished last lesson. You may now apply the knowledge in real project or check more advanced usages starting here .

    Then the Event Stream would look like above
    User Event Stream with custom serialization

    This basically means we can serialize the Event in the any format we want.

    Advanced Serialization Support with JMS

    When using JMS Converter support, we can even customize how we want to serialize given class, that is used within Events. For example we could have User Created Event which make use of UserName class.

    the UserName would be a simple Class which contains of validation so the name is not empty:

    Now if we would serialize it without telling JMS, how to handle this class we would end up with following JSON in the Event Stream:

    Now this is fine for short-lived applications and testing, however in the long living application this may become a problem. The problem may come from changes, if we would simply change property name in UserName.value to UserName.data it would break deserialization of our previous Events. As data does not exists under name key. Therefore we want to keep take over the serialization of objects, to ensure stability along the time.

    Now with above Converter, whenever we will use UserName class, we will be actually serializing it to simple string type, and then when deserialize back from simple type to UserName class:

    With this, with few lines of code we can ensure consistency across different Events, and keeping our Events bullet proof for code refactor and changes.

    PII Data (GDPR)

    In case of storing sensitive data, we may be forced by law to ensure that data should be forgotten (e.g. GDPR). This basically means, if Customer will ask to us to remove his data, we will be obligated by law to ensure that this will happen.

    However in case of Event Sourced System we rather do not want to delete events, as this is critical operation which is considered dangerous. Deleting Events could affect running Projections, deleting too much may raise inconsistencies in the System, and in some cases we may actually want to drop only part of the data - not everything. Therefore dropping Events from Event Stream is not suitable solution and we need something different.

    Solution that we can use, is to change the way we serialize the Event. We can hook into serialization process just as we did for normal serialization, and then customize the process. Converter in reality is an Service registered in Dependency Container, so we may inject anything we want there in order to modify the serialization process.

    So let's assume that we want to encrypt UserCreated Event:

    So what we do here, is we hook into serialization/deserialization process and pass the data to EncryptionService. As you can see here, we don't store the payload here, we simply store an reference in form o a key. EncryptionService can as simple as storing this data in database table using key as Primary Key, so we can fetch it easily. It can also be stored with encryption in some cryptographic service, yet it may also be stored as plain text. It all depends on our Domain. However what is important is that we've provided the resource id to the EncryptionService

    Now this could be used to delete related Event's data. When Customer comes to us and say, he wants his data deleted, we simply delete by resource:

    That way this Data won't be available in the System anymore. Now we could just allow Converters fails, if those Events are meant to be deserialized, or we could check if given key exists and then return dummy data instead.

    final readonly class UserCreated
    {
        public function __construct(
            public string $userId,
            public string $name,
            public string $surname,
        )
        {
    
        }
    }
    final readonly class UserCreatedConverter
    {
        #[Converter]
        public function toArray(UserCreated $event): array
        {
            return [
                'userId' => $event->userId,
                'userName' => $event->name,
                'userSurname' => $event->surname,
            ];
        }
    
        #[Converter]
        public function fromArray(array $event): UserCreated
        {
            return new UserCreated(
                $event['userId'],
                $event['userName'],
                $event['userSurname'],
            );
        }
    }
    Converters
    User Event Stream
    final readonly class UserCreated
    {
        public function __construct(
            public string $userId,
            public UserName $name,
            public string $surname,
        )
        {
    
        }
    }
    final readonly class UserName
    {
        public function __construct(
            public string $value,
        )
        {
            if ($value === "") {
               throw new \InvalidArgumentException("Name should not be empty");
            }
        }
    }
    {
        "userId": "123",
        "name": {"value": "Johny"},
        "surname": "Bravo"
    }
    class UserNameConverter
    {
        #[Converter]
        public function from(UserName $data): string
        {
            return $data->value;
        }
    
        #[Converter]
        public function to(string $data): UserName
        {
            return new UserName($data);
        }
    }
    {
        "userId": "123",
        "name": "Johny",
        "surname": "Bravo"
    }
    final readonly class UserCreatedConverter
    {
        public function __construct(
            private EncryptingService $encryptingService
        ){}
    
        #[Converter]
        public function toArray(UserCreated $event): array
        {
            $key = Uuid::v4()->toString();
        
            return 
            [
                'key'  => $key,
                'data' => $this->encryptingService->encrypt(
                    key: $key,
                    resource: $event->userId,
                    data: [
                        'userId' => $event->userId,
                        'userName' => $event->name,
                        'userSurname' => $event->surname,
                    ]
                )
            ];
        }
    
        #[Converter]
        public function fromArray(array $event): UserCreated
        {
            $data = $this->encryptingService->decrypt($event['key']);
        
            return new UserCreated(
                $event['userId'],
                $event['userName'],
                $event['userSurname'],
            );
        }
    }
    $this->encryptingService->encrypt(
        key: $key,
        // our resource id, to group related records
        resource: $event->userId,
        data: [
            'userId' => $event->userId,
            'userName' => $event->name,
            'userSurname' => $event->surname,
        ]
    )
    $this->encryptingService->delete(resource: $userId);

    Having customized Converters for specific Events, is also useful when we need to adjust some legacy Events to new format. We can hook into the deserialization process, and modify the payload to match new structure.

    If we allow Converters to fail when Serialization happens, we should ensure that related Projections are using simple arrays instead of classes, and handle those cases during Projecting. If we decide to return dummy data, we can keep deserializing those Events for Projections, as they will be able to use them.

    Durable Execution

    Durable workflows in PHP — sagas, orchestrators, outbox, and retries on the database and broker you already run, no separate workflow runtime

    For a PHP team building durable workflows — order fulfillment, subscription provisioning, payouts, KYC, multi-step onboarding — Ecotone delivers sagas, orchestrators, chained workflows, outbox, retries, and #[Delayed] saga timeouts on the database and broker you already operate, as plain PHP classes with attributes. No separate workflow service, no constrained DSL, no engine-specific event history.

    The market alternative is Temporal. This page walks the trade-offs, the code, and the two specific situations where keeping Temporal alongside Ecotone still makes sense.

    The Problem You Recognize

    A multi-step business process — order fulfillment, subscription provisioning, payment + payout — spans minutes to days. You need it to survive crashes: if the worker dies mid-step, the process picks up where it left off. If a downstream system is briefly unavailable, the step retries. If business state changes, every consumer sees a consistent timeline.

    If you've evaluated Temporal, the answer comes with:

    • A separate runtime to operate. Temporal runs as its own service (Frontend / History / Matching / internal Worker components) with a dedicated workflow database and a separate visibility store — two stateful systems alongside your application's own database. That's a design decision, not a version-specific quirk.

    • Debugging happens on someone else's runtime. Because workflow code executes inside the Temporal worker runtime (RoadRunner + ext-grpc on PHP), the workflow doesn't run where your application runs — different process, different debugger surface, different mental model.

    • Workflow history in an engine-specific format — switching off the platform is a rewrite, and projections or new subscribers can't be built from the workflow's event history later.

    For most PHP teams already running PostgreSQL or MySQL and RabbitMQ / Kafka / SQS / Redis, this is a new runtime, a new programming model, and a new lock-in surface — to solve a problem the existing stack can already solve.

    What the Industry Calls It

    Durable Execution — the umbrella term for multi-step processes that resume correctly across failures. It's a composition of older patterns, not a new one:

    • Sagas — stateful long-running coordinators that remember where they are across events arriving over time.

    • Workflows / Orchestrators — declarative step sequences that hand a message from one handler to the next.

    • Outbox — atomic publication of a message together with the database change that produced it.

    When these compose, processes are durable. The infrastructure underneath them is whatever the application already runs.

    How Ecotone Solves It

    Ecotone delivers durable execution as a Composer package on top of your existing database and broker. No separate runtime, no constrained workflow DSL — workflows are plain PHP classes with attributes, and the durability primitives live in the database you already have.

    Outbox in one transaction, execution on the broker you already run

    The outbox pattern is one configuration line. CombinedMessageChannel writes the message into the database in the same transaction as the business state change, then dispatches the actual handler execution onto your broker (RabbitMQ / Kafka / SQS / Redis / …). One pollers handles outbox draining; many consumers handle broker-side execution — scale workers on the broker, not the database.

    Sagas — stateful processes in plain PHP

    A Saga is a plain class with #[Saga], an #[Identifier], and event handlers. State is persisted per identifier; on the next event arrival, Ecotone reloads the saga from the database. No replay constraints, no Date::now() restrictions, no versioning branches.

    Event-Sourced Sagas — the same replay model, in your own database

    A saga can be event-sourced: every state transition is recorded as an event in your own database, and the saga rebuilds itself by replaying those events. This is the same durability model Temporal uses internally — a recorded history that the runtime replays to reach the current state — with one decisive difference: the events live in your schema. Queryable by SQL, joinable with the rest of your domain, projectable into any view you decide to build later.

    Crash mid-process, deploy mid-process, restart mid-process: on the next event arrival, the saga rehydrates from its event stream and continues from exactly where it left off. Add a new projection a month later — order-throughput-by-region, customer-support timeline, compliance audit log — and it's just another #[ProjectionV2] over the events you already own. No export from a workflow engine, no engine-specific format to decode, no separate visibility store to keep in sync.

    Saga timeouts — #[Delayed] on a saga event handler

    Long-running processes need to time out: verify a phone number within 24 hours or block the user; complete checkout within 15 minutes or release the cart. With Ecotone, a timeout is one extra event handler on the saga, delayed by an attribute. No cron, no scheduled job, no separate timer service.

    #[Delayed] accepts a TimeSpan, an exact \DateTimeImmutable, or an expression — #[Delayed(expression: 'payload.dueDate')] will fire when the dueDate field on the event arrives. The delay is enforced per-handler on the async channel, so the same event can trigger an immediate handler and a 24-hour-delayed handler without colliding.

    Stateless workflows — durable without a persistent saga

    Not every durable flow needs a stateful coordinator. Many multi-step processes are just chained handlers — payment → ship → notify — where nothing needs to be remembered between steps; the message itself carries the state. With Ecotone, you connect handlers through outputChannelName, and run those channels asynchronously. The architecture stays simple and fast: no saga record, no aggregate hydration, no extra database round trips per step.

    Durability comes from the channel, not from saga state. When the async channel runs through an outbox (CombinedMessageChannel writing to the database), each step is committed atomically with its business write before the message advances to the next handler. If a worker crashes mid-step, the broker redelivers the message; the work resumes on the next consumer. Ecotone delivers the same recovery semantics another way: redelivery of the message, idempotency through built-in deduplication, and the outbox table living in your own database — so the durable state and the business state commit together or roll back together.

    For declarative workflows where the step list itself lives in one place — including dynamic step lists chosen from input data — Ecotone Enterprise provides .

    Retry, error channels, dead letter — at the channel, not per handler

    Configure recovery policy once at the channel level. Every handler on that channel inherits retries with exponential backoff, an error channel, and a DBAL-backed dead letter queue. No per-handler boilerplate.

    Event Sourcing — durability you can query

    When the business process is its history (audit trails, regulated domains, reconstructible read models), Event Sourcing gives durable execution as a side effect: every step is a recorded event in your own database, in your own schema, queryable by the rest of your application — not locked inside a workflow engine's internal log.

    Test any flow in isolation with EcotoneLite

    The same programming model runs in production and in your test suite. EcotoneLite::bootstrapFlowTesting boots a real Ecotone application in-process — buses, sagas, projections, async channels, outbox — and runs flows synchronously inside the test. No queue infrastructure, no separate worker process, no flakiness. Just call the bus, run the consumer, assert the result.

    What this buys you:

    • Same model sync and async. A test runs through the outbox, the broker channel, the saga, the projection — the same code path production hits. There's no separate "test mode" that diverges from runtime behaviour.

    • Local equals production, and the runtime is yours. Drop a var_dump, set a breakpoint, step through the saga line by line — everything runs in one PHP process, in your existing debugger. There's no separate workflow runtime sitting between you and your code; the saga executes where your application executes.

    • Time-travel a timeout in seconds.

    What the Code Actually Looks Like

    The clearest difference between Temporal and Ecotone is what a workflow looks like when you write it. The same business process — placing an order, charging payment, shipping, notifying the customer — in both frameworks:

    Temporal PHP SDK — workflow + activity proxies

    Things to notice: the workflow method is a Generator that yields through activity proxies; you can't call any direct service inside it; every external call must go through an activity stub configured up front; signals, queries, and the workflow method are three separate APIs.

    Ecotone — plain PHP saga, no proxies, no DSL

    The handlers are the steps. No activity interfaces, no activity proxies, no generators, no Workflow::now(), no Workflow::timer() — each handler is an ordinary message handler that can call any service, use any clock, do any I/O. Persistence happens automatically per #[Identifier]. Retries and dead-lettering come from the channel the handler runs on. Time delays use #[Delayed], which looks identical to any other message attribute. Testing runs in-process with EcotoneLite::bootstrapFlowTesting.

    Aggregate itself can be combined with Doctrine ORM, Eloquent, so it does use the programming model you know. Saga can also be Event Sourced if you want to persist all the transition changes, or even build Projections around it.

    Both stacks have invisible scaffolding. What you see above isn't all the code either side needs.

    • Temporal also requires: an Activity implementation class for each #[ActivityInterface], a Worker registration file binding workflow + activities to a Task Queue, a RoadRunner config (.rr.yaml), ext-grpc installed everywhere, and the running Temporal Server (Frontend + History + Matching + Worker) with its workflow database and visibility store.

    • Ecotone also requires: a channel registration (one #[ServiceContext] method like the CombinedMessageChannel

    The difference isn't scaffolding count — it's what the programming model lets you write inside the workflow file. Temporal demands a constrained DSL; Ecotone is plain PHP.

    How It Compares to Temporal

    Dimension
    Temporal (PHP SDK)
    Ecotone

    Replace Temporal, or Compose With It

    For most PHP teams, Ecotone replaces Temporal. Durable workflows run on the PostgreSQL or MySQL you already use plus the broker you've already chosen (RabbitMQ, Kafka, SQS, Redis) — no separate runtime, no constrained DSL, no engine-specific event history. The comparison above is the evidence.

    There are two specific situations where keeping Temporal alongside Ecotone still makes sense:

    • Genuinely polyglot workflows — a Go or Java service must call a PHP activity inside the same workflow execution. Ecotone is PHP-on-PHP and doesn't compete on this axis.

    • Auditor-mandated visual workflow timeline today — the auditors require Temporal's Web UI specifically, not the underlying data. The data isn't the gap: event-sourced sagas store every state change as a queryable event in your own database, OpenTelemetry traces capture every handler invocation, and the dead-letter table is queryable from any SQL tool. Ecotone doesn't yet ship a packaged visual timeline. If the UI itself is the requirement, that's a reason to keep Temporal alongside.

    If neither applies, replace Temporal. If either does, Ecotone is designed to compose around the workflow engine and handle everything Temporal doesn't:

    • CQRS and message buses. Command, Event, and Query buses on Laravel or Symfony, registered through PHP attributes — used inside HTTP controllers, console commands, scheduled jobs, and inside Temporal Activities to dispatch work into the rest of the application.

    • Domain event publication. Temporal's Event History is internal to the workflow. Ecotone publishes domain events from your aggregates onto your own broker (RabbitMQ / Kafka / SQS / Redis) so other services and other read models can subscribe — including projections you only realise you need months after the workflow shipped.

    • Read models and projections. Build CQRS read sides — partitioned, streaming, replayable, blue-green deployable — from the events your application emits. Temporal's history is the wrong shape for this; your own event stream is the right shape.

    A reasonable composition: Temporal runs the polyglot or audit-mandated workflows that genuinely need its replay UI; Ecotone runs the rest of the message-driven system around them. Activities call into Ecotone's command bus to keep the workflow body small; aggregates and projections live in Ecotone's event store; the broker is shared.

    But the more common path — and the right default for a Laravel or Symfony team on a database and broker they already own — is to replace Temporal entirely. Sagas, orchestrators, chained workflows, outbox, retries, dead-letter, and event sourcing reach durable execution without the separate runtime, the DSL, or the lock-in.

    Next Steps

    • — atomic message + business write

    • — stateful long-running coordinators

    • — workflows and sagas side by side

    Event Handler
    Modelling Overview
    Retry + Dead Letter — automatic recovery for transient failures, with a quarantine for the rest.
  • Event Sourcing — every state change is a durable event, so rebuilding state on restart is replaying a list of events you already own.

  • Async channels can be driven manually (
    ->run('async')
    ) and clock-based delays can be advanced explicitly, so a 24-hour saga timeout becomes a one-line test, not an integration suite that waits.
  • End-to-end testing of the runtime behaviour. You test against the actual Ecotone runtime, not a stub of it — same buses, sagas, projections, async channels, outbox path in the test as in production.

  • shown earlier), retry/DLQ configuration (one
    ErrorHandlerConfiguration
    ), and the consumer process (
    php bin/console ecotone:run
    or the Laravel artisan equivalent).

    RoadRunner + ext-grpc — workflows execute inside Temporal's worker process

    php bin/console ecotone:run / php artisan ecotone:run — handlers execute inside your application process

    Workflow code

    Replay-deterministic — no Date::now, no random, no direct I/O outside Activities

    Plain PHP classes with attributes

    Replay from recorded history

    Yes — Temporal Event History, inside the Temporal cluster

    Yes — #[EventSourcingSaga] rebuilds state by replaying its events, in your own database

    Data ownership

    Workflow state is owned by the Temporal cluster — queried through Temporal's API / Web UI; exporting it for application use is a separate concern

    Workflow state lives in your application's schema — joinable with the rest of your domain, projectable into any read model, queryable from any SQL tool

    Versioning long-running flows

    getVersion() / patched() branches in workflow code that survive until the last in-flight execution finishes

    Plain code change with schema discipline — add fields with defaults; drain in-flight sagas before breaking changes to persisted state

    Durability primitive

    Per-workflow Event History (engine-specific format)

    Your own database rows / event store you can query

    Outbox (atomic business write + message)

    Design idempotent Activities + manual DB-write-then-publish in a dedicated Activity

    Declarative CombinedMessageChannel in one DBAL transaction

    Multi-tenancy

    Namespace-per-tenant — logical isolation on one cluster; sharding decisions arrive at high tenant counts

    Header-routed channels in one deployment

    Operator surface

    Temporal Web UI — visual workflow timeline tied to the Temporal cluster. Durable Workflow ships Waterline (Horizon-style UI for workflow runs) on the PHP side.

    Event-sourced sagas record every state change as a queryable event in your own database (full forensic timeline; no information loss). OpenTelemetry spans on every handler (Grafana / Jaeger / Datadog / Honeycomb — pick your existing stack), DBAL dead-letter rows queryable from any SQL tool, MCP server for AI-assisted introspection. Packaged visual timeline not shipped yet — the data is open.

    Migration cost off-platform

    Rewrite — engine-specific Event History; in-flight workflows can't be exported

    Handlers and channel config are Ecotone-shaped, but saga state and event stream stay in your own schema, queryable from any tool during a transition

  • Outbox. When a Temporal Activity needs to write to the database and publish an external event atomically, Ecotone's CombinedMessageChannel gives you that in one DBAL transaction. The Activity stays short and idempotent; Ecotone handles the dual-write problem.

  • Sagas that aren't workflows. Plenty of stateful coordination doesn't need full workflow durability — webhook deduplication windows, subscription grace periods, multi-event correlations. Ecotone Sagas handle these on the same database the application already uses.

  • Distributed messaging between services. Ecotone's Distributed Bus and Service Map move commands and events between PHP services over the brokers you operate. Temporal's cross-cluster Nexus is its own thing; for the everyday between-services traffic of a PHP estate, Ecotone is the lighter fit.

  • Multi-tenant routing. Header-routed channels in one deployment, instead of a namespace-per-tenant model that compounds with Temporal's cluster sizing.

  • Resilience on everything that isn't a workflow. Retry, error channels, DBAL dead-letter, idempotency, per-handler failure isolation — applied uniformly to every async event handler in the system, workflow-bound or not.

  • — retry policy at the channel level
  • Error Channel and Dead Letter — failure quarantine

  • Event Sourcing — durability as a recorded history you own

  • Infra you must run

    Temporal Server (Frontend / History / Matching / internal Worker) with its own workflow database and a separate visibility store — alongside your application's database

    Composer package on the database and broker you already run

    How your existing broker fits in

    Reached through Activities; the workflow runtime is Temporal's own routing fabric, not RabbitMQ / Kafka / SQS

    First-class as the workflow runtime itself — RabbitMQ, Kafka, Redis, SQS, Enqueue, Symfony Messenger, Laravel Queue

    As You Scale: Ecotone Enterprise adds Orchestrators — declarative multi-step workflows with dynamic step lists; Command Bus Instant Retries for synchronous commands; and Gateway-Level Deduplication for exactly-once semantics across handlers.

    Orchestrators
    Outbox Pattern
    Sagas
    Complex Business Processes

    Worker runtime

    Retries
    Attributes
    Attribute
    Messaging Gateway
    #[ServiceContext]
    public function outboxToSqs(): CombinedMessageChannel
    {
        return CombinedMessageChannel::create(
            'outbox_sqs',
            ['database_channel', 'amazon_sqs_channel'],
        );
    }
    
    #[Asynchronous(['outbox_sqs'])]
    #[EventHandler]
    public function notifyAboutNewOrder(OrderWasPlaced $event): void
    {
        // Message committed in the same DBAL transaction as the business write.
        // Execution dispatched to SQS where workers scale horizontally.
    }
    #[Saga]
    final class OrderFulfillment
    {
        #[Identifier] private string $orderId;
        private string $status = 'placed';
    
        #[EventHandler]
        public static function start(OrderWasPlaced $event): self
        {
            return new self($event->orderId);
        }
    
        #[EventHandler]
        public function onPaymentReceived(PaymentReceived $event, CommandBus $bus): void
        {
            $this->status = 'paid';
            $bus->send(new ShipOrder($this->orderId));
        }
    }
    #[EventSourcingSaga]
    final class OrderFulfillment
    {
        use WithAggregateVersioning;
    
        #[Identifier] private string $orderId;
        private string $status;
    
        #[EventHandler]
        public static function start(OrderWasPlaced $event): array
        {
            return [new OrderFulfillmentStarted($event->orderId)];
        }
    
        #[EventHandler]
        public function onPaymentCompleted(PaymentCompleted $event): array
        {
            return [new OrderFulfillmentPaid($this->orderId, $event->paymentId)];
        }
    
        #[EventSourcingHandler]
        public function whenStarted(OrderFulfillmentStarted $event): void
        {
            $this->orderId = $event->orderId;
            $this->status = 'placed';
        }
    
        #[EventSourcingHandler]
        public function whenPaid(OrderFulfillmentPaid $event): void
        {
            $this->status = 'paid';
        }
    }
    #[Saga]
    final class VerificationProcess
    {
        #[Identifier] private string $userId;
        private bool $emailVerified = false;
        private bool $phoneVerified = false;
    
        #[EventHandler]
        public static function start(UserWasRegistered $event): self
        {
            // also publishes VerificationProcessStarted which the timeout below listens to
        }
    
        #[Delayed(new TimeSpan(hours: 24))]
        #[Asynchronous('async')]
        #[EventHandler(endpointId: 'verification.timeout')]
        public function timeout(VerificationProcessStarted $event, CommandBus $bus): void
        {
            if ($this->emailVerified && $this->phoneVerified) {
                return;
            }
            $bus->sendWithRouting('user.block', metadata: ['aggregate.id' => $this->userId]);
        }
    }
    #[CommandHandler(routingKey: 'order.place', outputChannelName: 'order.verify_payment')]
    public function placeOrder(PlaceOrder $command): OrderData { /* ... */ }
    
    #[Asynchronous('async')]
    #[InternalHandler(inputChannelName: 'order.verify_payment', outputChannelName: 'order.ship')]
    public function verifyPayment(OrderData $order): OrderData { /* ... */ }
    
    #[Asynchronous('async')]
    #[InternalHandler(inputChannelName: 'order.ship', outputChannelName: 'order.notify')]
    public function ship(OrderData $order): OrderData { /* ... */ }
    
    #[Asynchronous('async')]
    #[InternalHandler(inputChannelName: 'order.notify')]
    public function notifyCustomer(OrderData $order): void { /* ... */ }
    ErrorHandlerConfiguration::createWithDeadLetterChannel(
        'error_channel',
        RetryTemplateBuilder::exponentialBackoff(1000, 2)->maxRetryAttempts(3),
        'dbal_dead_letter',
    )
    $test = EcotoneLite::bootstrapFlowTesting(
        [OrderService::class, NotificationService::class, OrderFulfillment::class],
        [new OrderService(), new NotificationService()],
        enableAsynchronousProcessing: [
            SimpleMessageChannelBuilder::create('async'),
        ],
    );
    
    $test->sendCommandWithRoutingKey('order.place', new PlaceOrder('order-1', amount: 4_200));
    $test->run('async'); // drain the async channel
    
    self::assertSame('paid', $test->sendQueryWithRouting('order.status', metadata: ['aggregate.id' => 'order-1']));
    #[WorkflowInterface]
    class OrderFulfillmentWorkflow
    {
        private $payments;
        private $shipping;
        private $notifications;
        private string $status = 'placed';
    
        public function __construct()
        {
            $options = ActivityOptions::new()
                ->withStartToCloseTimeout(CarbonInterval::seconds(30));
            $this->payments      = Workflow::newActivityStub(PaymentActivity::class, $options);
            $this->shipping      = Workflow::newActivityStub(ShippingActivity::class, $options);
            $this->notifications = Workflow::newActivityStub(NotificationActivity::class, $options);
        }
    
        #[WorkflowMethod]
        public function fulfill(string $orderId, int $amount): \Generator
        {
            // No Date::now() — must use Workflow::now() so replay stays deterministic.
            $paymentId = yield $this->payments->charge($orderId, $amount);
            $this->status = 'paid';
    
            // No sleep() — must use Workflow::timer() so the wait is recorded in history.
            yield Workflow::timer(CarbonInterval::seconds(2));
    
            $shipmentId = yield $this->shipping->ship($orderId);
            $this->status = 'shipped';
            yield $this->notifications->notifyCustomer($orderId, $shipmentId);
            return [$paymentId, $shipmentId];
        }
    
        #[QueryMethod]   public function status(): string { return $this->status; }
        #[SignalMethod]  public function cancel(): void   { /* ... */ }
    }
    use Ecotone\Modelling\Attribute\Saga;
    use Ecotone\Modelling\Attribute\Identifier;
    use Ecotone\Modelling\Attribute\EventHandler;
    use Ecotone\Modelling\Attribute\QueryHandler;
    use Ecotone\Modelling\CommandBus;
    
    #[Saga]
    final class OrderFulfillment
    {
        #[Identifier] private string $orderId;
        private string $status = 'placed';
        private ?string $paymentId = null;
        private ?string $shipmentId = null;
    
        private function __construct(string $orderId)
        {
            $this->orderId = $orderId;
        }
    
        #[EventHandler]
        public static function start(OrderWasPlaced $event, CommandBus $bus): self
        {
            $bus->send(new ChargeOrder($event->orderId, $event->amount));
            return new self($event->orderId);
        }
    
        #[EventHandler]
        public function onPaymentCompleted(PaymentCompleted $event, CommandBus $bus): void
        {
            $this->paymentId = $event->paymentId;
            $this->status = 'paid';
            $bus->send(new ShipOrder($this->orderId));
        }
    
        #[EventHandler]
        public function onShipped(OrderShipped $event, CommandBus $bus): void
        {
            $this->shipmentId = $event->shipmentId;
            $this->status = 'shipped';
            $bus->send(new NotifyCustomer($this->orderId, $event->shipmentId));
        }
    
        #[EventHandler]
        public function onNotified(CustomerNotified $event): void
        {
            $this->status = 'done';
        }
    
        #[QueryHandler('order.status')]
        public function status(): string
        {
            return $this->status;
        }
    }
    private int $productId;
    #[ClassReference("productService")
    class ProductService

    Orchestrators: Declarative Workflow Automation

    Learn how to build predefined and dynamic workflows using Orchestrator

    While connecting handlers with channels works great for linear workflows, and Sagas excel at stateful processes, Orchestrator is perfect when you need predefined workflows where the workflow definition is separate from the individual steps.

    You'll know you need this when:

    • You have multi-step business processes (order fulfillment, payment processing, onboarding flows) and the workflow logic is scattered across event handlers

    • Business stakeholders ask "what are the steps in this process?" and the answer requires reading multiple files

    • You need to add, remove, or reorder steps in a process and it touches code in many places

    • Different inputs should trigger different step sequences (e.g., digital vs. physical product fulfillment)

    • You want each step independently testable and reusable across different workflows

    Think of Orchestrator as: A conductor that knows the entire symphony (workflow) and tells each musician (step) when to play, while the musicians focus only on their part.

    Creating Your First Orchestrator

    An Orchestrator defines a workflow as a sequence of steps (channel names) and implements those steps as internal handlers.

    Step 1: Define the Workflow

    Key parts:

    • #[Orchestrator] - Tells Ecotone this method defines a workflow

    • inputChannelName - Channel that triggers this workflow

    • Return array - List of steps (channel names) to execute in order

    Durability comes from the routing slip. Each step is an #[InternalHandler] running on its own channel, and the remaining step list travels with the message as a routing slip header. If the worker crashes at step N, the broker redelivers the message; the next consumer reads the remaining steps from the slip and resumes at step N — no replay of completed steps, no orchestrator state to restore. The orchestrator method is a plan, executed step-by-step through your channels.

    Step 2: Implement the Steps

    What happens when you trigger the workflow:

    1. Message sent to process.image channel

    2. Orchestrator returns ["resize.image", "add.watermark", "optimize.image", "upload.image"]

    3. Each step executes in sequence, passing data to the next step

    Data Enrichment with Headers

    Sometimes you need to add metadata or context without changing the main payload. Use changingHeaders: true for this:

    Enriching with Additional Data

    Benefits of header enrichment:

    • Keep original payload unchanged

    • Add context data for downstream steps

    • Maintain clean separation of concerns

    Executing Orchestrators

    There are several ways to trigger orchestrator workflows:

    Method 1: Command Handler with Output Channel

    Flow:

    1. UploadImageCommand sent to command handler

    2. Handler processes upload and returns ImageData

    3. Result automatically sent to process.image channel

    Method 2: From Event Handlers (Business Workflows)

    Flow:

    1. OrderPlaced event occurs

    2. Event handler processes it and sends result to process.order

    3. Orchestrator workflow begins automatically

    Method 3: Business Interface Triggering Business Workflow

    Business Interface is simple interface where Ecotone delivers implementation. This way we can easily create and entrypoint with interface that is part of our application level code and execute the workflow:

    Usage in your application:

    Method 4: Custom Orchestrator Gateway

    For dynamic workflows where you want to pass the steps programmatically:

    Gateway benefits:

    • Dynamic workflow construction

    • Runtime step determination

    • Easy integration with web controllers

    • Flexible business rule application

    Asynchronous Orchestration

    Make your workflows asynchronous for better performance and scalability:

    Asynchronous Orchestrator

    Mixed Synchronous/Asynchronous Steps

    Advanced Features

    Dynamic Workflow Building

    The power of Orchestrator shines when you build workflows dynamically based on business rules:

    Conditional Step Execution

    Steps can return null to end the workflow early:

    Nested Orchestrators

    Orchestrators can call other orchestrators as steps:

    Testing Orchestrators

    Testing orchestrators is straightforward with Ecotone Lite. You can test the entire workflow, individual steps, or specific scenarios.

    Testing Individual Steps

    Testing Data Enrichment

    Testing Asynchronous Orchestrators

    Testing Orchestrator Gateways

    Key Benefits of Orchestrator

    🎯 Separation of Concerns

    • Workflow definition is separate from step implementation

    • Easy to understand the entire process at a glance

    • Steps can be reused across different workflows

    🔄 Reusability

    • Same steps can be used in multiple workflows

    • Build libraries of reusable business operations

    • Mix and match steps for different scenarios

    ⚡ Dynamic Workflows

    • Build workflows programmatically based on business rules

    • Adapt to different customer types, regions, or conditions

    • Runtime workflow construction

    🧪 Testability

    • Test entire workflows end-to-end

    • Test individual steps in isolation

    • Easy mocking and stubbing of dependencies

    📈 Scalability

    • Asynchronous execution support

    • Individual steps can be scaled independently

    • Easy to add new steps without changing existing code

    🔍 Observability

    • Clear workflow execution path

    • Easy to monitor and debug

    • Step-by-step execution tracking

    Summary

    Orchestrator is perfect for building predefined workflows where you want to:

    • 🎯 Separate workflow definition from step implementation

    • 🔄 Reuse steps across different workflows

    • ⚡ Build dynamic workflows based on business rules

    The power of Orchestrator lies in its ability to make complex business workflows simple to define, easy to test, and flexible to modify. Whether you're processing orders, onboarding customers, or handling document workflows, Orchestrator provides the structure and flexibility you need to build robust, maintainable business processes.

    Final result is returned

    Orchestrator workflow begins

    🧪 Test workflows and steps independently
  • 📋 Execute consistent, repeatable business processes

  • class ImageProcessingOrchestrator
    {
        #[Orchestrator(inputChannelName: "process.image")]
        public function processImage(): array
        {
            return [
                "resize.image",
                "add.watermark", 
                "optimize.image",
                "upload.image"
            ];
        }
    }
    class ImageProcessingOrchestrator
    {
        // ... workflow definition ...
    
        #[InternalHandler(inputChannelName: "resize.image")]
        public function resizeImage(ImageData $image): ImageData
        {
            // Resize logic here
            return $image->resize(800, 600);
        }
    
        #[InternalHandler(inputChannelName: "add.watermark")]
        public function addWatermark(ImageData $image): ImageData
        {
            // Watermark logic here
            return $image->addWatermark('© Company');
        }
    
        #[InternalHandler(inputChannelName: "optimize.image")]
        public function optimizeImage(ImageData $image): ImageData
        {
            // Optimization logic here
            return $image->optimize();
        }
    
        #[InternalHandler(inputChannelName: "upload.image")]
        public function uploadImage(ImageData $image): string
        {
            // Upload logic here
            $url = $this->storageService->upload($image);
            return $url;
        }
    }
    class OrderProcessingOrchestrator
    {
        #[Orchestrator(inputChannelName: "process.order")]
        public function processOrder(): array
        {
            return [
                "validate.order",
                "enrich.customer.data",
                "calculate.pricing",
                "finalize.order"
            ];
        }
    
        #[InternalHandler(inputChannelName: "validate.order")]
        public function validateOrder(Order $order): Order
        {
            // Validation logic
            if (!$order->isValid()) {
                throw new InvalidOrderException();
            }
            return $order;
        }
    
        #[InternalHandler(
            inputChannelName: "enrich.customer.data",
            changingHeaders: true
        )]
        public function enrichCustomerData(Order $order): array
        {
            // Fetch additional customer data
            $customer = $this->customerService->getCustomer($order->customerId);
            $loyaltyLevel = $this->loyaltyService->getLevel($order->customerId);
            
            // Return data that becomes message headers
            return [
                'customerType' => $customer->type,
                'loyaltyLevel' => $loyaltyLevel,
                'creditScore' => $customer->creditScore
            ];
        }
    
        #[InternalHandler(inputChannelName: "calculate.pricing")]
        public function calculatePricing(
            Order $order,
            #[Header('customerType')] string $customerType,
            #[Header('loyaltyLevel')] string $loyaltyLevel
        ): Order {
            // Use enriched data for pricing
            $discount = $this->getDiscount($customerType, $loyaltyLevel);
            return $order->applyDiscount($discount);
        }
    
        #[InternalHandler(inputChannelName: "finalize.order")]
        public function finalizeOrder(Order $order): OrderConfirmation
        {
            // Final processing
            $this->orderService->finalize($order);
            
            // We don't really need to return anything, we could make the method void
            return new OrderConfirmation($order->id);
        }
    }
    class ImageController
    {
        #[CommandHandler('image.upload', outputChannelName: 'process.image')]
        public function uploadImage(UploadImageCommand $command): ImageData
        {
            // Handle the upload and prepare data for processing
            $imageData = $this->imageService->upload($command->file);
    
            // Return data that will be sent to the orchestrator
            return $imageData;
        }
    }
    class OrderEventHandler
    {
        #[EventHandler(outputChannelName: "process.order")]
        public function whenOrderPlaced(OrderPlaced $event): Order
        {
            // Convert event to order data for processing
            return Order::fromEvent($event);
        }
    }
    interface OrderProcessingService
    {
        #[BusinessMethod(outputChannelName: 'process.order')]
        public function processOrder(Order $order): OrderConfirmation;
    }
    class OrderController
    {
        public function __construct(private OrderProcessingService $orderService) {}
    
        public function processOrder(Request $request): JsonResponse
        {
            $order = Order::fromRequest($request);
    
            // This will trigger the orchestrator workflow
            $confirmation = $this->orderService->processOrder($order);
    
            return new JsonResponse($confirmation);
        }
    }
    interface OrderProcessingGateway
    {
        #[OrchestratorGateway]
        public function processWithSteps(array $steps, Order $order, array $metadata): OrderConfirmation;
    }
    class OrderController
    {
        public function __construct(private OrderProcessingGateway $gateway) {}
    
        public function processOrder(Request $request): Response
        {
            $order = Order::fromRequest($request);
            
            // Dynamically determine steps based on order type
            $steps = $this->determineStepsForOrder($order);
            
            $result = $this->gateway->processWithSteps($steps, $order, []);
            
            return new JsonResponse($result);
        }
    
        private function determineStepsForOrder(Order $order): array
        {
            $steps = ["validate.order"];
            
            if ($order->requiresApproval()) {
                $steps[] = "manual.approval";
            }
            
            $steps[] = "process.payment";
            
            if ($order->isInternational()) {
                $steps[] = "customs.declaration";
            }
            
            $steps[] = "ship.order";
            
            return $steps;
        }
    }
    class AsyncImageProcessingOrchestrator
    {
        #[Asynchronous('async')]
        #[Orchestrator(inputChannelName: "async.process.image")]
        public function processImageAsync(): array
        {
            return [
                "resize.image",
                "add.watermark",
                "optimize.image", 
                "upload.image"
            ];
        }
    
        // Steps can also be asynchronous individually
        #[Asynchronous('async')]
        #[InternalHandler(inputChannelName: "resize.image")]
        public function resizeImage(ImageData $image): ImageData
        {
            // Heavy processing that benefits from async execution
            return $this->heavyResizeOperation($image);
        }
    
        #[InternalHandler(inputChannelName: "add.watermark")]
        public function addWatermark(ImageData $image): ImageData
        {
            // This step runs synchronously
            return $image->addWatermark('© Company');
        }
    }
    class MixedProcessingOrchestrator
    {
        #[Orchestrator(inputChannelName: "mixed.workflow")]
        public function mixedWorkflow(): array
        {
            return [
                "quick.validation",    // Sync - needs immediate feedback
                "heavy.processing",    // Async - can take time
                "send.notification"    // Async - fire and forget
            ];
        }
    
        #[InternalHandler(inputChannelName: "quick.validation")]
        public function quickValidation(Data $data): Data
        {
            // Fast validation that should block if it fails
            if (!$data->isValid()) {
                throw new ValidationException();
            }
            return $data;
        }
    
        #[Asynchronous('async')]
        #[InternalHandler(inputChannelName: "heavy.processing")]
        public function heavyProcessing(Data $data): Data
        {
            // CPU-intensive work that can be done in background
            return $this->performComplexCalculations($data);
        }
    
        #[Asynchronous('notifications')]
        #[InternalHandler(inputChannelName: "send.notification")]
        public function sendNotification(Data $data): void
        {
            // Fire-and-forget notification
            $this->notificationService->send($data);
        }
    }
    class DynamicCustomerOnboardingOrchestrator
    {
        public function __construct(
            private CustomerService $customerService,
            private ComplianceService $complianceService
        ) {}
    
        #[Orchestrator(inputChannelName: "onboard.customer")]
        public function onboardCustomer(Customer $customer): array
        {
            $steps = ["validate.customer"];
    
            // Add steps based on customer type
            if ($customer->isEnterprise()) {
                $steps[] = "enterprise.verification";
                $steps[] = "compliance.check";
            }
    
            // Add steps based on location
            if ($customer->isInternational()) {
                $steps[] = "international.verification";
            }
    
            // Add steps based on business rules
            if ($this->complianceService->requiresKYC($customer)) {
                $steps[] = "kyc.verification";
            }
    
            // Common final steps
            $steps[] = "create.account";
            $steps[] = "send.welcome.email";
    
            return $steps;
        }
    }
    class ConditionalProcessingOrchestrator
    {
        #[Orchestrator(inputChannelName: "conditional.process")]
        public function conditionalProcess(): array
        {
            return [
                "check.eligibility",
                "process.if.eligible",
                "finalize.process"
            ];
        }
    
        #[InternalHandler(inputChannelName: "check.eligibility")]
        public function checkEligibility(Application $application): ?Application
        {
            if (!$application->isEligible()) {
                // Returning null stops the workflow
                return null;
            }
            return $application;
        }
    
        #[InternalHandler(inputChannelName: "process.if.eligible")]
        public function processIfEligible(Application $application): Application
        {
            // This only runs if previous step didn't return null
            return $this->processApplication($application);
        }
    
        #[InternalHandler(inputChannelName: "finalize.process")]
        public function finalizeProcess(Application $application): ApplicationResult
        {
            return new ApplicationResult($application);
        }
    }
    class MasterOrchestrator
    {
        #[Orchestrator(inputChannelName: "master.workflow")]
        public function masterWorkflow(): array
        {
            return [
                "prepare.data",
                "sub.workflow.a",  // This calls another orchestrator
                "sub.workflow.b",  // This calls another orchestrator
                "combine.results"
            ];
        }
    
        #[InternalHandler(inputChannelName: "prepare.data")]
        public function prepareData(RawData $data): ProcessedData
        {
            return new ProcessedData($data);
        }
    
        #[Orchestrator(inputChannelName: "sub.workflow.a")]
        public function subWorkflowA(): array
        {
            return ["step.a1", "step.a2"];
        }
    
        #[Orchestrator(inputChannelName: "sub.workflow.b")]
        public function subWorkflowB(): array
        {
            return ["step.b1", "step.b2"];
        }
    
        #[InternalHandler(inputChannelName: "combine.results")]
        public function combineResults(ProcessedData $data): FinalResult
        {
            return new FinalResult($data);
        }
    }
    use Ecotone\Lite\EcotoneLite;
    use PHPUnit\Framework\TestCase;
    
    class ImageProcessingOrchestratorTest extends TestCase
    {
        private EcotoneLite $ecotoneLite;
    
        protected function setUp(): void
        {
            $this->ecotoneLite = EcotoneLite::bootstrapFlowTesting(
                [ImageProcessingOrchestrator::class],
                [
                    'storageService' => new InMemoryStorageService(),
                    'imageProcessor' => new TestImageProcessor()
                ],
                ServiceConfiguration::createWithDefaults()
                    ->withLicenceKey(VALID_LICENCE)
            );
        }
    
        public function test_complete_image_processing_workflow(): void
        {
            // Arrange
            $imageData = new ImageData('test-image.jpg', 1920, 1080);
    
            // Act
            $result = $this->ecotoneLite->sendDirectToChannel('process.image', $imageData);
    
            // Assert
            $this->assertInstanceOf(ImageData::class, $result);
            $this->assertEquals(800, $result->width);
            $this->assertEquals(600, $result->height);
            $this->assertTrue($result->hasWatermark());
            $this->assertTrue($result->isOptimized());
        }
    }
    public function test_individual_steps(): void
    {
        $imageData = new ImageData('test.jpg', 1920, 1080);
    
        // Test resize step
        $resized = $this->ecotoneLite->sendDirectToChannel('resize.image', $imageData);
        $this->assertEquals(800, $resized->width);
        $this->assertEquals(600, $resized->height);
    
        // Test watermark step
        $watermarked = $this->ecotoneLite->sendDirectToChannel('add.watermark', $resized);
        $this->assertTrue($watermarked->hasWatermark());
    
        // Test optimization step
        $optimized = $this->ecotoneLite->sendDirectToChannel('optimize.image', $watermarked);
        $this->assertTrue($optimized->isOptimized());
    }
    public function test_order_processing_with_header_enrichment(): void
    {
        $order = new Order('123', 'customer-456', [new Item('product', 100)]);
    
        $result = $this->ecotoneLite->sendDirectToChannel('process.order', $order);
    
        $this->assertInstanceOf(OrderConfirmation::class, $result);
        $this->assertTrue($result->hasDiscount()); // Discount applied based on enriched data
    }
    
    public function test_customer_data_enrichment_step(): void
    {
        $order = new Order('123', 'premium-customer', []);
    
        // Test the enrichment step directly
        $enrichedHeaders = $this->ecotoneLite->sendDirectToChannel('enrich.customer.data', $order);
    
        $this->assertEquals('premium', $enrichedHeaders['customerType']);
        $this->assertEquals('gold', $enrichedHeaders['loyaltyLevel']);
        $this->assertGreaterThan(700, $enrichedHeaders['creditScore']);
    }
    public function test_asynchronous_orchestrator(): void
    {
        $ecotoneLite = EcotoneLite::bootstrapFlowTesting(
            [AsyncImageProcessingOrchestrator::class],
            ['imageProcessor' => new TestImageProcessor()],
            ServiceConfiguration::createWithDefaults()
                ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::CORE_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE]))
                ->withLicenceKey(VALID_LICENCE),
            enableAsynchronousProcessing: [
                SimpleMessageChannelBuilder::createQueueChannel('async'),
            ]
        );
    
        $imageData = new ImageData('test.jpg', 1920, 1080);
    
        // Send to async workflow
        $ecotoneLite->sendDirectToChannel('async.process.image', $imageData);
    
        // Run async processing
        $ecotoneLite->run('async');
    
        // Verify processing completed
        $this->assertTrue(true); // Add specific assertions based on your implementation
    }
    public function test_orchestrator_gateway_with_dynamic_steps(): void
    {
        $ecotoneLite = EcotoneLite::bootstrapFlowTesting(
            [OrderProcessingOrchestrator::class, OrderProcessingGateway::class],
            [new OrderProcessingOrchestrator()],
            ServiceConfiguration::createWithDefaults()
                ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::CORE_PACKAGE]))
                ->withLicenceKey(VALID_LICENCE)
        );
    
        /** @var OrderProcessingGateway $gateway */
        $gateway = $ecotoneLite->getGateway(OrderProcessingGateway::class);
    
        $order = new Order('123', 'customer-456', []);
        $steps = ['validate.order', 'process.payment', 'ship.order'];
    
        $result = $gateway->processWithSteps($steps, $order, []);
    
        $this->assertInstanceOf(OrderConfirmation::class, $result);
    }

    Prerequisites: Understanding of and will help you get the most out of Orchestrator.

    Enterprise Feature: Orchestrator is part of Ecotone's Enterprise features.

    Bigger picture: Orchestrators are one of Ecotone's primitives — declarative multi-step workflows that survive crashes on the database and broker you already run, without a separate workflow service or Temporal cluster.

    Key insight: Orchestrator shines when you know the types of workflows you need but want flexibility in how they're constructed and executed. It's the perfect balance between structure and flexibility.

    message handlers
    channels
    Durable Execution

    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 , , and 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.

    Problem 1 — No chaining primitive; every step is a new Command + Handler + transport route. Messenger has no concept of "pass this message through N handlers in sequence." Each step must construct and dispatch the next command:

    A four-step pricing pipeline = 4 command DTOs + 4 handlers + 4 bus injections + 4 transport routes. The official answer for "make B run after A" is DispatchAfterCurrentBusMiddleware + DispatchAfterCurrentBusStamp — which is exactly the dispatch-the-next-command pattern, just with timing semantics. The community has been asking for Bus::chain-style chaining since RFC (still open). Kris Wallsmith (Symfony core) ;

    The Story

    Problem to solve
    Composition pattern
    Orchestration code saved

    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.

    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.

    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.

    Publishing the event. Ecotone auto-publishes every $this->recordThat(...) from the aggregate. Messenger doesn't know about your aggregate — you must dispatch the event yourself from whoever saves the aggregate:

    Subscribing to it. Separate listener class — Messenger can't attach handlers to aggregate methods:

    Plus LoyaltyAccountRepository with findByCustomerId. Plus the listener has to know about persistence. Plus transport routing per listener class for isolation. Plus remembering to call

    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.

    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.

    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.

    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

    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.

    Messenger has no #[Delayed] attribute and no concept of "the same event drives both the immediate path and a delayed path." You dispatch a separate message with DelayStamp from the place that creates the saga, write a separate handler for the timeout-check message, and have it look up the saga to decide whether to act:

    Plus: a transport that supports DelayStamp (AMQP/Doctrine/Redis/SQS/Beanstalkd) routed to the CheckPaymentTimeout message. No cancellation if the gateway responds early — you rely on the saga's status check at delivery time. The "same event drives immediate + delayed" semantics that

    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.

    Publishing the events. Ecotone auto-publishes the saga's own emissions ($saga->recordThat(new PaymentRequested(...))) and OrderPlaced from the Order aggregate. In Messenger, both have to be dispatched by hand — typically from the command handler that saved the aggregate, and from the gateway webhook controller that received the callback. The orderId that Ecotone reads from message metadata becomes a payload field, since Messenger handlers receive the message object, not the envelope's stamps:

    Subscribing — loading the saga and routing events to it. Status column on a Doctrine entity, plus a class with one #[AsMessageHandler]

    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.

    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.

    A pipeline of N steps requires N command classes and N handlers, each handler injecting MessageBusInterface to push the next message:

    Plus four message classes (SubmitOrder, PriceOrder, ApplyDiscounts, PlaceOrder), each potentially with its own normalizer, plus transport routing per command if any of them go async, plus correlation/causation propagation if you want to trace the chain end-to-end.

    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.

    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.

    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.

    There's no built-in dynamic-workflow primitive. Reproducing the Ecotone version requires an if/else ladder that decides what to dispatch next, with each step being its own command class and its own handler that injects MessageBusInterface to push the next message:

    Six possible step combinations × N branches per handler = a state machine smeared across handlers, with the workflow shape encoded implicitly in each step's match expression. Adding a new optional step (say, applyTax only for international orders) means touching every prior step's branching logic

    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.

    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.

    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.

    Wire the event into the splitter with one more subscription:

    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.

    Publishing — both the incoming event and every downstream one. Ecotone auto-publishes OrderPlaced from the Order aggregate and StockReserved from each Stock aggregate after reserve(). Messenger needs both explicitly dispatched:

    Fan-out — a separate handler that loops and dispatches N commands:

    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.

    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.

    Asynchronous delivery in Messenger requires routing the message class to a transport. You configure messenger.yaml:

    But this routes the whole event to the loyalty transport — which means every subscriber of OrderPlaced is async on that queue, sharing its retry policy, sharing its consumer workers, and (most importantly) sharing its failure domain. If the loyalty subscriber fails, stock reservation and the payment saga get re-delivered too, because the transport only knows about the envelope, not which handler owns it.

    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.

    Inside the Order service — publish across the boundary:

    Inside the Payment service — consume across the boundary:

    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:

    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.

    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.

    • — output channels, InternalHandlers, Splitters, Routers

    • — long-running workflows and identifier binding

    • — payload, header, and expression-based identifier resolution

    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.

    phphd/pipeline-bundle
    exists for the same reason.

    Problem 2 — Splitting one event into three handlers doesn't give you isolation either. The natural Messenger shape for "OrderPlaced has three reactions" is three separate handler classes:

    It looks like three independent reactions. It isn't.

    All three handlers run inside the same envelope, on the same worker invocation, in an order you cannot rely on. Pristine Messenger does track succeeded handlers via HandledStamp and skips them on retry — but the moment you enable DoctrineTransactionMiddleware (recommended in the docs, used by almost every Symfony app), it explicitly strips all HandledStamps on failure because the rollback invalidated those handlers' DB writes. From the source: "Remove all HandledStamp from the envelope so the retry will execute all handlers again."

    The consequence: handler A charged a card via Stripe, handler B failed → retry re-executes all three handlers on the same envelope, re-charging the card.

    The shape that actually isolates is splitting into a dispatcher that emits one command per reaction, so each lands as its own transport message:

    Each dispatch travels independently and lands in its own DLQ if it can't recover. Isolation is real; you just recreated Problem 1 — three subscribers cost 1 dispatcher + 3 commands + 3 handlers + 3 routes.

    Problem 3 — No composable building blocks; every pattern is hand-built with seams between them. Messenger ships handlers, the bus, and the envelope. None of them have first-class equivalents to the patterns this page builds with — Aggregate (domain entities with command/event handlers and identifier-based loading), Saga (stateful workflow with timeouts, branching, compensation), Internal Handler + outputChannelName (pipe one message through N steps with real data flow, no new DTOs per step), Router (declarative dispatch by payload, no dispatcher classes wrapping the bus), Splitter (fan one message into N independent messages, each with its own retry and DLQ), Orchestrator (routing slip composed declaratively), identifier mapping (events route to the right Saga/Aggregate instance without hand-rolled repository lookups), Headers as parameter binds (Messenger has stamps, but reading one inside a handler requires custom middleware or a custom argument resolver — there is no #[Header] parameter binding, no #[AddHeader] to enrich on the way out).

    When you do build them, combining them means stitching your hand-rolled implementations to Messenger's primitives — every Saga is a Workflow Component + status column + process-manager-by-convention; every router is a dispatcher class wrapping the bus; every fan-out is a foreach + dispatch. Multi-tenancy is officially out of scope — Messenger, Scheduler, and Cache are designed for single-tenant setups by default. Correlation and causation IDs don't propagate either — community bundles exist solely to add this.

    Ecotone's answer: every pattern above is a composable building block — they join and combine in any shape via attributes, with each step running sync or async as the need dictates. #[EventHandler] methods get independent copies and retries by default — the doctrine transaction middleware trade-off goes away. Ecotone runs on top of Messenger transports — your existing AMQP/Redis/Doctrine config keeps working.

    Problem 1 — The message and the job are fused; there is no metadata layer for messages in flight. A Laravel Job is a class fusing data (constructor args) and behavior (handle()). There is no envelope, no header layer, no notion of "a message moving through handlers and being enriched along the way."

    The consequences are everywhere:

    • Cross-cutting metadata has nowhere to live. Correlation IDs, causation IDs, tenant context, request context — none of these flow across jobs unless you bake them into every job's constructor.

    • Step N+1 cannot see what step N produced. Bus::chain bakes constructor args at dispatch time, before any step runs. If PriceOrder calculated a discount that PlaceOrder needs, you must persist it and re-query — there is no "pass the enriched message to the next step."

    • You cannot enrich or modify the message in flight. Adding a header, switching a routing key, or marking the message as belonging to a tenant mid-pipeline is impossible because there is no message — only a job whose constructor was already called.

    Problem 2 — Bus::chain chains jobs, but doesn't build business workflows. A workflow is a stateful, observable, testable process with branching, time, and compensation. Bus::chain is a fixed linked list of pre-constructed jobs.

    The gap shows up the moment your business actually has a flow:

    • No state ownership. No entity tracks "Order #123 fulfillment: validated ✓, priced ✓, discounts pending, place pending." You're guessing from queue inspection.

    • No business timeouts. "If no PaymentReceived arrives within 30 minutes, cancel the order" requires polling jobs that re-dispatch themselves — a known anti-pattern.

    • No branching or looping. "If discount > X, run an approval step." "Try three payment providers in order, fall through on failure." Both require breaking out of the chain entirely into ad-hoc dispatch logic.

    Problem 3 — No composable building blocks that work the same sync or async. Laravel ships Jobs, Listeners, and Bus::chain. None of them have first-class equivalents to the patterns this page builds with — Aggregate (domain entities with command/event handlers and identifier-based loading), Saga (stateful workflow with timeouts, branching, compensation), Internal Handler + outputChannelName (pipe one message through N steps with real data flow, no new DTOs per step), Router (declarative dispatch by payload, no if/else ladders), Splitter (fan one message into N independent messages, each with its own retry and DLQ), Orchestrator (routing slip composed declaratively), per-handler isolation + identifier mapping (each event subscriber loads its instance and runs in its own failure domain), Headers (#[Header], #[AddHeader], changingHeaders

    And in Laravel, switching a step from sync to async is a different code path per primitive — a Listener has to change interface (ShouldQueue), a Job is dispatched differently, Bus::chain is its own thing — and the primitives don't combine cleanly with each other either.

    Ecotone's answer: every pattern above is a composable building block — they join and combine in any shape via attributes, with each step running sync or async as the need dictates. The chain/route/split/fan-out wiring is identical in either mode — no rewrite, no different abstraction. Ecotone runs on top of Laravel's queue transport — your existing driver config keeps working.

    Problem — you wire together pieces that were never designed to fit. Each capability lives in a separate package with its own configuration, its own lifecycle, and its own assumptions. Standing up a working system means:

    • gluing the queue, serializer, and dispatcher together with adapter code

    • writing the boilerplate each library expects — bootstrap, registration, conversion at every seam

    • repeating the same orchestration patterns (retry, dead-letter, correlation, deduplication, fan-out isolation) in your own code because no library owns the cross-cutting concern

    • accepting that some compositions are not even possible — features that should be one attribute (per-handler isolation, identifier-based routing, distributed buses) require primitives the libraries don't expose

    The integration code is where bugs hide and where every upgrade hurts. None of it is your domain.

    Ecotone's answer: one install — every pattern (Aggregate, Saga, Internal Handler, Router, Splitter, Orchestrator) is a composable building block joining via attributes, with each step running sync or async as the need dictates. Cross-cutting concerns (retry, dead-letter, correlation, deduplication, per-handler isolation) are attributes too. Start with #[CommandHandler] and add #[Asynchronous], #[Deduplicated], #[ErrorChannel] as the need arises.

    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

    $this->bus->dispatch(...)
    every time
    Order::place()
    runs — miss it once and subscribers go silent.

    Publishing the event. Ecotone auto-publishes every $this->recordThat(...) from the aggregate. Laravel has no aggregate model — you emit the event manually:

    $order = Order::create([
        'order_id' => $orderId,
        'customer_id' => $customerId,
    ]);
    
    // Manual event emission — Laravel model events fire for Eloquent hooks,
    // but domain events are on you to publish
    event(new OrderPlaced($order->order_id, $order->customer_id));

    Subscribing to it. ShouldQueue listener for isolation — fires as its own job:

    final class CreditLoyaltyPoints implements ShouldQueue
    {
        public function handle(OrderPlaced $event): void
        {
            $account = LoyaltyAccount::where('customer_id', $event->customerId)
                ->firstOrFail();
    
            $account->increment('points', 10);
        }
    }

    Handler lives outside the aggregate. Lookup is where()->first(). No framework-level correlation between the event's identifier and the aggregate's identifier. Plus the event(...) call has to sit at every site that changes the order — easy to forget in a migration or a controller shortcut.

    , and decides whether to fire
    PaymentTimedOut
    or no-op. State is consulted at execution time, not at scheduling time.
  • 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.

  • #[Delayed]
    gives you for free have to be assembled from a second message class, a second handler, and a second dispatch site.

    Laravel has delay() on jobs, but no concept of attaching a delayed delivery to an event handler. You schedule a separate job from the listener that creates the saga, and the job loads the model to decide whether to act:

    // In the listener that creates the saga
    final class HandleOrderPlacedForPayment implements ShouldQueue
    {
        public function handle(OrderPlaced $event): void
        {
            PaymentSaga::firstOrCreate(
                ['order_id' => $event->orderId],
                ['status' => 'pending']
            );
    
            CheckPaymentTimeout::dispatch($event->orderId)
                ->delay(now()->addMinutes(30));
        }
    }
    
    // Separate job
    final class CheckPaymentTimeout implements ShouldQueue
    {
        use Queueable;
    
        public function __construct(public readonly string $orderId) {}
    
        public function handle(): void
        {
            $saga = PaymentSaga::where('order_id', $this->orderId)->first();
            if ($saga === null || $saga->status !== 'pending') {
                return;
            }
    
            $saga->update(['status' => 'timed_out']);
            event(new PaymentTimedOut($this->orderId));
        }
    }

    Plus: a queue driver that supports delays (database/redis/sqs do; sync doesn't). No cancellation if the gateway responds early — same status-check pattern at delivery. The connection between "saga started" and "saga must time out in 30 minutes" lives in the listener that dispatches the delayed job, not on the saga itself.

    method per event type (a single
    __invoke
    cannot bind to multiple message classes), each doing its own identifier extraction and lookup:

    Plus PaymentSaga entity with status enum, plus PaymentSagaRepository, plus retry/compensation glue for each transition, plus every event dispatch site listed above. Messenger has no first-class Saga.

    Publishing the events. No automatic domain-event emission. Every state change that "recorded" something in the Ecotone aggregate must explicitly event(...) it here:

    // In the Order flow
    event(new OrderPlaced($order->order_id, $order->customer_id));
    
    // In the gateway webhook controller
    event(new GatewayResponded($txnId, $success, orderId: $extractedOrderId));
    
    // In the saga transition
    event(new PaymentRequested($saga->order_id));

    Subscribing — Eloquent model with status column + two ShouldQueue listeners:

    final class HandleOrderPlacedForPayment implements ShouldQueue
    {
        public function handle(OrderPlaced $event): void
        {
            PaymentSaga::firstOrCreate(
                ['order_id' => $event->orderId],
                ['status' => 'pending']
            );
            // plus: dispatch TakePayment job here
        }
    }
    
    final class HandleGatewayResponse implements ShouldQueue
    {
        public function handle(GatewayResponded $event): void
        {
            $saga = PaymentSaga::where('order_id', $event->orderId)->firstOrFail();
            $saga->update([
                'status' => $event->success ? 'settled' : 'failed',
            ]);
        }
    }

    Saga state is a model column. Two listener classes — one per event. Header-based identifier extraction is manual. No framework guarantee that both listeners operate on the same saga instance with consistent versioning. Plus every publishing site — aggregate write, webhook callback, transition — has to remember to call event(...).

    Job chaining ships in Laravel via Bus::chain() — the caller (controller, service) enumerates the steps:

    use Illuminate\Support\Facades\Bus;
    
    Bus::chain([
        new ValidateOrderJob($orderId, $lineItems),
        new PriceOrderJob($orderId),
        new ApplyDiscountsJob($orderId),
        new PlaceOrderJob($orderId),
    ])->dispatch();

    Notice every job takes only $orderId — that's not a stylistic choice, it's a constraint. Jobs cannot pass or modify data between steps. Each job's constructor args are baked in at the moment Bus::chain() is called, before any step has run. So if PriceOrderJob calculates pricing that ApplyDiscountsJob needs, the pricing has to be persisted somewhere — an order_pricing row, an extra column on the order, a side-table — and the next job re-queries it. Every transformation between steps becomes a write/read round-trip, every job loads its inputs again, and the workflow's "message in flight" lives in your schema instead of in the pipeline.

    A change to the pipeline shape lives in whichever caller builds the chain — pricing, discount, and place-order aggregate all stay separate, but the workflow definition is split between the caller and N job classes, with no single declarative source.

    to teach it about the new option.

    Bus::chain requires the chain to be known at dispatch time — you build the array based on conditions in the caller:

    use Illuminate\Support\Facades\Bus;
    
    $jobs = [new ValidateOrderJob($orderId), new ChargeOrderJob($orderId)];
    
    if ($order->requiresFraudCheck()) {
        $jobs[] = new VerifyFraudJob($orderId);
    }
    
    if ($order->hasPhysicalGoods()) {
        $jobs[] = new PackJob($orderId);
        $jobs[] = new ShipJob($orderId);
    }
    
    if ($order->isGift()) {
        $jobs[] = new WrapGiftJob($orderId);
    }
    
    $jobs[] = new NotifyCustomerJob($orderId);
    
    Bus::chain($jobs)->dispatch();

    Plus a job class per step, each doing its own state lookup at the start of handle() because chained jobs don't pass return values forward. The workflow definition lives in whichever caller built the chain — controllers, services, listeners — with no single place that documents "here are the possible step sequences." Adding a new step means hunting down every site that builds a chain.

    Plus a ReserveStock command class, plus ReserveStockHandler as shown, plus transport routing for the ReserveStock queue, plus manual correlation/causation if you want the fan-out traced end-to-end.

    Publishing — both upstream and per-item. event(...) at the write site, then again per successful reservation:

    use Illuminate\Contracts\Queue\ShouldQueue;
    use Illuminate\Foundation\Queue\Queueable;
    
    // Upstream: after Order is created
    event(new OrderPlaced($order->order_id, $order->line_items));
    
    // Per item: inside the job after the reservation succeeds
    final class ReserveStockJob implements ShouldQueue
    {
        use Queueable;
    
        public function __construct(
            public string $productId,
            public int $quantity,
            public string $orderId,
        ) {}
    
        public function handle(): void
        {
            $stock = Stock::where('product_id', $this->productId)->firstOrFail();
            $stock->decrement('available', $this->quantity);
    
            event(new StockReserved($this->orderId, $this->productId));
        }
    }

    Fan-out listener:

    final class FanOutStockReservations implements ShouldQueue
    {
        public function handle(OrderPlaced $event): void
        {
            foreach ($event->lineItems as $item) {
                ReserveStockJob::dispatch(
                    $item->productId,
                    $item->quantity,
                    $event->orderId,
                );
            }
        }
    }

    Same shape: fan-out listener, per-item job, manual repository lookup per item. Plus every reservation has to event(new StockReserved(...)) for downstream handlers to hear about it. No framework binding between the event's line items and the per-item aggregate.

    To get per-handler async, you convert each subscriber into its own command and route each command to its own transport — trading one attribute for N command classes and N transport routes.
    use Illuminate\Contracts\Queue\ShouldQueue;
    
    // Each subscriber needs its own ShouldQueue listener class
    final class CreditLoyaltyPoints implements ShouldQueue
    {
        public string $queue = 'loyalty';
        public int $tries = 3;
        public array $backoff = [30, 60, 120];
    
        public function handle(OrderPlaced $event): void { /* … */ }
    }

    Works, but each listener is a full job class with its own retry + queue config. Four subscribers = four classes, four queue configs. And if you want to move one subscriber from sync to async you restructure the class — ShouldQueue is an interface, not an attribute you toggle.

    Aggregate Event Handlers — aggregate-to-aggregate subscription
  • Asynchronous Handling — making any link asynchronous

  • Microservices PHP — Distributed Bus across bounded contexts

  • Recovering, Tracing, Monitoring — error channels, retries, and OpenTelemetry

  • Saving an aggregate and publishing its events

    Command → Aggregate → Event

    Event publishing service

    Reacting from one aggregate to another's events

    Aggregate → Aggregate via Event

    Coordinating service, listener class

    #[Aggregate]
    final class Order
    {
        use WithEvents;
    
        #[Identifier]
        private string $orderId;
        private string $customerId;
        private OrderStatus $status;
    
        private function __construct(string $orderId, string $customerId)
        {
            $this->orderId = $orderId;
            $this->customerId = $customerId;
            $this->status = OrderStatus::Placed;
    
            $this->recordThat(new OrderPlaced($orderId, $customerId));
        }
    
        #[CommandHandler]
        public static function place(PlaceOrder $command): self
        {
            return new self($command->orderId, $command->customerId);
        }
    }
    #[Aggregate]
    final class LoyaltyAccount
    {
        use WithEvents;
    
        #[Identifier]
        private string $customerId;
        private int $points = 0;
    
        public static function open(OpenAccount $command): self { /* ... */ }
    
        #[EventHandler(identifierMapping: ['customerId' => 'payload.customerId'])]
        public function onOrderPlaced(OrderPlaced $event): void
        {
            $this->points += 10;
            $this->recordThat(new LoyaltyPointsEarned($this->customerId, 10));
        }
    }
    use Symfony\Component\Messenger\Attribute\AsMessageHandler;
    use Symfony\Component\Messenger\MessageBusInterface;
    
    #[AsMessageHandler]
    final class PlaceOrderHandler
    {
        public function __construct(
            private OrderRepository $orders,
            private MessageBusInterface $bus,
        ) {}
    
        public function __invoke(PlaceOrder $command): void
        {
            $order = Order::place($command->orderId, $command->customerId);
            $this->orders->save($order);
    
            // Manual dispatch — otherwise no listener ever hears about it
            $this->bus->dispatch(new OrderPlaced($order->orderId, $order->customerId));
        }
    }
    #[AsMessageHandler]
    final class CreditLoyaltyPointsOnOrderPlaced
    {
        public function __construct(private LoyaltyAccountRepository $repo) {}
    
        public function __invoke(OrderPlaced $event): void
        {
            // Manual identifier lookup — no framework binding
            $account = $this->repo->findByCustomerId($event->customerId)
                ?? throw new LoyaltyAccountNotFound($event->customerId);
    
            $account->creditPoints(10);
    
            $this->repo->save($account);
        }
    }
    #[Saga]
    final class PaymentProcess
    {
        use WithEvents;
    
        #[Identifier]
        private string $orderId;
        private PaymentStatus $status = PaymentStatus::Pending;
    
        private function __construct(string $orderId)
        {
            $this->orderId = $orderId;
        }
    
        #[EventHandler(identifierMapping: ['orderId' => 'payload.orderId'])]
        public static function start(OrderPlaced $event): self
        {
            $saga = new self($event->orderId);
            $saga->recordThat(new PaymentRequested($event->orderId));
            return $saga;
        }
    
        #[EventHandler(identifierMapping: ['orderId' => "headers['orderId']"])]
        public function onGatewayResponse(GatewayResponded $event): void
        {
            $this->status = $event->success
                ? PaymentStatus::Settled
                : PaymentStatus::Failed;
        }
    }
    #[Saga]
    final class PaymentProcess
    {
        // … existing identifier, status, start(), onGatewayResponse() above …
    
        #[Asynchronous('payments')]
        #[Delayed(new TimeSpan(minutes: 30))]
        #[EventHandler(identifierMapping: ['orderId' => 'payload.orderId'])]
        public function onTimeout(PaymentRequested $event): void
        {
            if ($this->status !== PaymentStatus::Pending) {
                return; // gateway already responded — nothing to time out
            }
    
            $this->status = PaymentStatus::TimedOut;
            $this->recordThat(new PaymentTimedOut($this->orderId));
        }
    }
    use Symfony\Component\Messenger\MessageBusInterface;
    use Symfony\Component\Messenger\Stamp\DelayStamp;
    
    // Inside the saga-start handler — schedule the timeout check
    final class HandleOrderPlacedForPayment
    {
        public function __construct(private MessageBusInterface $bus) {}
    
        #[AsMessageHandler]
        public function __invoke(OrderPlaced $event): void
        {
            // … saga creation logic …
    
            $this->bus->dispatch(
                new CheckPaymentTimeout($event->orderId),
                [new DelayStamp(30 * 60 * 1000)] // milliseconds
            );
        }
    }
    
    // Separate message + separate handler
    final class CheckPaymentTimeout
    {
        public function __construct(public readonly string $orderId) {}
    }
    
    final class CheckPaymentTimeoutHandler
    {
        public function __construct(
            private PaymentSagaRepository $repo,
            private MessageBusInterface $bus,
        ) {}
    
        #[AsMessageHandler]
        public function __invoke(CheckPaymentTimeout $cmd): void
        {
            $saga = $this->repo->findByOrderId($cmd->orderId);
            if ($saga === null || $saga->status !== PaymentStatus::Pending) {
                return; // already settled
            }
    
            $saga->markTimedOut();
            $this->repo->save($saga);
            $this->bus->dispatch(new PaymentTimedOut($cmd->orderId));
        }
    }
    // After saving the Order aggregate
    $this->bus->dispatch(new OrderPlaced($order->orderId, $order->customerId));
    
    // In the gateway webhook controller, after parsing the callback —
    // the orderId that was a message header in Ecotone is now a payload field
    $this->bus->dispatch(new GatewayResponded(
        transactionId: $txnId,
        orderId: $extractedOrderId,
        success: $success,
    ));
    
    // After the saga transitions itself
    $this->bus->dispatch(new PaymentRequested($saga->orderId));
    final class OrderPricing
    {
        #[CommandHandler(
            routingKey: 'order.submit',
            outputChannelName: 'order.price'
        )]
        public function submit(SubmitOrder $command): SubmitOrder
        {
            return $command; // validated — passes through
        }
    
        #[InternalHandler(
            inputChannelName: 'order.price',
            outputChannelName: 'order.applyDiscounts'
        )]
        public function price(SubmitOrder $command, PriceList $priceList): PricedOrder
        {
            return $command->withPricesFrom($priceList);
        }
    
        #[InternalHandler(
            inputChannelName: 'order.applyDiscounts',
            outputChannelName: 'order.place'
        )]
        public function applyDiscounts(PricedOrder $order, DiscountRules $rules): PricedOrder
        {
            return $order->withDiscounts($rules);
        }
    }
    #[CommandHandler(routingKey: 'order.place')]
    public static function place(PricedOrder $order): self { /* ... */ }
    use Symfony\Component\Messenger\Attribute\AsMessageHandler;
    use Symfony\Component\Messenger\MessageBusInterface;
    
    // Step 1 — validation pass-through
    #[AsMessageHandler]
    final class SubmitOrderHandler
    {
        public function __construct(private MessageBusInterface $bus) {}
    
        public function __invoke(SubmitOrder $command): void
        {
            // validate…
            $this->bus->dispatch(new PriceOrder($command->orderId, $command->lineItems));
        }
    }
    
    // Step 2 — pricing
    #[AsMessageHandler]
    final class PriceOrderHandler
    {
        public function __construct(
            private PriceList $priceList,
            private MessageBusInterface $bus,
        ) {}
    
        public function __invoke(PriceOrder $command): void
        {
            $priced = /* compute pricing from $this->priceList */;
            $this->bus->dispatch(new ApplyDiscounts($command->orderId, $priced));
        }
    }
    
    // Step 3 — discounts
    #[AsMessageHandler]
    final class ApplyDiscountsHandler
    {
        public function __construct(
            private DiscountRules $rules,
            private MessageBusInterface $bus,
        ) {}
    
        public function __invoke(ApplyDiscounts $command): void
        {
            $final = /* apply $this->rules */;
            $this->bus->dispatch(new PlaceOrder($command->orderId, $final));
        }
    }
    
    // Step 4 — finalize (separate aggregate command)
    #[AsMessageHandler]
    final class PlaceOrderHandler { /* … saves the Order aggregate … */ }
    final class FulfillmentWorkflow
    {
        #[Orchestrator(inputChannelName: 'order.fulfill')]
        public function fulfill(Order $order): array
        {
            $steps = ['fulfill.validate', 'fulfill.charge'];
    
            if ($order->requiresFraudCheck()) {
                $steps[] = 'fulfill.verifyFraud';
            }
    
            if ($order->hasPhysicalGoods()) {
                $steps[] = 'fulfill.pack';
                $steps[] = 'fulfill.ship';
            }
    
            if ($order->isGift()) {
                $steps[] = 'fulfill.wrapGift';
            }
    
            return [...$steps, 'fulfill.notifyCustomer'];
        }
    
        #[InternalHandler('fulfill.validate')]
        public function validate(Order $order): Order { /* ... */ }
    
        #[InternalHandler('fulfill.charge')]
        public function charge(Order $order, Payments $payments): Order { /* ... */ }
    
        #[InternalHandler('fulfill.pack')]
        public function pack(Order $order, Warehouse $wh): Order { /* ... */ }
    
        // ... other step handlers
    }
    use Symfony\Component\Messenger\Attribute\AsMessageHandler;
    use Symfony\Component\Messenger\MessageBusInterface;
    
    // Entry point — picks the first step based on the order shape
    #[AsMessageHandler]
    final class FulfillOrderHandler
    {
        public function __construct(private MessageBusInterface $bus) {}
    
        public function __invoke(FulfillOrder $command): void
        {
            $order = $command->order;
    
            // Always start with validation
            $this->bus->dispatch(new ValidateOrder($order, next: $this->nextAfterValidate($order)));
        }
    
        private function nextAfterValidate(Order $order): string
        {
            if ($order->requiresFraudCheck())   return 'verifyFraud';
            if ($order->hasPhysicalGoods())     return 'pack';
            if ($order->isGift())               return 'wrapGift';
            return 'notifyCustomer';
        }
    }
    
    // Each step has to know "what's next" or look it up in some shared planner
    #[AsMessageHandler]
    final class ValidateOrderHandler
    {
        public function __construct(private MessageBusInterface $bus) {}
    
        public function __invoke(ValidateOrder $command): void
        {
            // validate…
            match ($command->next) {
                'verifyFraud'    => $this->bus->dispatch(new VerifyFraud($command->order, next: /*…*/)),
                'pack'           => $this->bus->dispatch(new Pack($command->order, next: /*…*/)),
                'wrapGift'       => $this->bus->dispatch(new WrapGift($command->order, next: /*…*/)),
                'notifyCustomer' => $this->bus->dispatch(new NotifyCustomer($command->order)),
            };
        }
    }
    
    // …repeat for VerifyFraudHandler, PackHandler, ShipHandler, WrapGiftHandler, NotifyCustomerHandler,
    //    each duplicating the "what's next" decision or carrying it in the message payload
    final class OrderRouter
    {
        #[Router(inputChannelName: 'order.submit')]
        public function route(SubmitOrder $command): string
        {
            return $command->isBusinessCustomer()
                ? 'order.requireApproval'
                : 'order.price';
        }
    }
    
    final class ApprovalGate
    {
        #[InternalHandler(
            inputChannelName: 'order.requireApproval',
            outputChannelName: 'order.price'
        )]
        public function request(SubmitOrder $command, Approvals $approvals): SubmitOrder
        {
            $approvals->request($command->orderId);
            return $command;
        }
    }
    final class StockReservations
    {
        #[Splitter(
            inputChannelName: 'order.placed.reserveStock',
            outputChannelName: 'stock.reserve'
        )]
        public function split(OrderPlaced $event): array
        {
            return array_map(
                fn(LineItem $item) => new ReserveStock(
                    $item->productId,
                    $item->quantity,
                    $event->orderId,
                ),
                $event->lineItems,
            );
        }
    }
    
    #[Aggregate]
    final class Stock
    {
        use WithEvents;
    
        #[Identifier]
        private string $productId;
        private int $available;
    
        #[CommandHandler('stock.reserve')]
        public function reserve(ReserveStock $command): void
        {
            if ($this->available < $command->quantity) {
                $this->recordThat(new ReservationRejected($command->orderId, $command->productId));
                return;
            }
    
            $this->available -= $command->quantity;
            $this->recordThat(new StockReserved($command->orderId, $command->productId));
        }
    }
    #[EventHandler(outputChannelName: 'order.placed.reserveStock')]
    public function fanOut(OrderPlaced $event): OrderPlaced { return $event; }
    use Symfony\Component\Messenger\Attribute\AsMessageHandler;
    use Symfony\Component\Messenger\MessageBusInterface;
    
    // Upstream: after Order is saved
    $this->bus->dispatch(new OrderPlaced($order->orderId, $order->lineItems));
    
    // Downstream: inside the ReserveStock handler after Stock::reserve() runs
    #[AsMessageHandler]
    final class ReserveStockHandler
    {
        public function __construct(
            private StockRepository $stocks,
            private MessageBusInterface $bus,
        ) {}
    
        public function __invoke(ReserveStock $command): void
        {
            $stock = $this->stocks->findByProductId($command->productId)
                ?? throw new StockNotFound($command->productId);
    
            $stock->reserve($command->quantity, $command->orderId);
            $this->stocks->save($stock);
    
            // Manual emission per item — otherwise no downstream listener sees it
            $this->bus->dispatch(new StockReserved($command->orderId, $command->productId));
        }
    }
    #[Aggregate]
    final class LoyaltyAccount
    {
        use WithEvents;
    
        #[Identifier]
        private string $customerId;
        private int $points = 0;
    
        #[Asynchronous('loyalty')]
        #[EventHandler(
            endpointId: 'creditPointsOnOrderPlaced',
            identifierMapping: ['customerId' => 'payload.customerId'],
        )]
        public function onOrderPlaced(OrderPlaced $event): void
        {
            $this->points += 10;
            $this->recordThat(new LoyaltyPointsEarned($this->customerId, 10));
        }
    }
    #[ServiceContext]
    public function loyaltyChannel(): MessageChannelBuilder
    {
        return AmqpBackedMessageChannelBuilder::create('loyalty');
        // or SqsBackedMessageChannelBuilder, KafkaBackedMessageChannelBuilder,
        // DbalBackedMessageChannelBuilder, RedisBackedMessageChannelBuilder
    }
    framework:
        messenger:
            transports:
                loyalty: '%env(MESSENGER_LOYALTY_DSN)%'
            routing:
                App\Order\Event\OrderPlaced: loyalty
    #[EventHandler]
    public function onOrderPlaced(
        OrderPlaced $event,
        #[Reference] DistributedBus $distributedBus
    ): void {
        $distributedBus->convertAndPublishEvent(
            routingKey: 'order.placed',
            event: $event,
        );
    }
    #[Distributed]
    #[EventHandler('order.placed')]
    public function onOrderPlaced(OrderPlaced $event): void
    {
        // Same code as a local subscriber — Ecotone handles transport and conversion
    }
    #[ServiceContext]
    public function emailResiliency(): ErrorHandlerConfiguration
    {
        return ErrorHandlerConfiguration::createWithDeadLetterChannel(
            inputChannelName: 'notifications_error',
            retryTemplate: RetryTemplateBuilder::exponentialBackoff(1000, 10)
                ->maxRetryAttempts(3),
            deadLetterChannelName: 'dbal_dead_letter',
        );
    }

    What Ecotone does for you: finds the aggregate, loads it (or creates it), persists the new state, and publishes every event recorded via recordThat(). You never see an EventBus.

    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.

    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.

    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.

    Same pattern, many uses: trial expirations, abandoned-cart reminders, SLA escalations, cooldowns, retry windows, scheduled cleanups. Anywhere you'd reach for a cron, #[Delayed] (and its dynamic counterpart #[Delayed(expression: '…')] for per-message delays) gets you there with a handler instead of a job runner.

    Pipes and Filters: Each step is testable in isolation. Reorder, insert, or skip steps by changing outputChannelName — no service needs rewriting.

    The workflow definition lives in one place. Reading fulfill() tells you every possible step and what drives the branching. Each step is an InternalHandler — individually testable and reusable across workflows.

    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.

    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.

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

    Same code, different execution semantics. The loyalty aggregate method is unchanged from when we first wrote it. Ecotone moves the delivery boundary for us. Other subscribers of OrderPlaced (the payment saga, the stock fan-out) are unaffected — each gets its own copy of the message; a failure in loyalty crediting never retries the others.

    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.

    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.

    #50462
    published his own gist workaround
    Connecting Handlers with Channels
    Sagas: Workflows That Remember
    Identifier Mapping
    Commands
    Events
    Aggregates
    use Symfony\Component\Messenger\Attribute\AsMessageHandler;
    use Symfony\Component\Messenger\MessageBusInterface;
    
    #[AsMessageHandler]
    final class ValidateOrderHandler
    {
        public function __construct(private MessageBusInterface $bus) {}
    
        public function __invoke(ValidateOrder $cmd): void
        {
            // validate…
            $this->bus->dispatch(new PriceOrder($cmd->orderId));
        }
    }
    // + PriceOrderHandler, ApplyDiscountsHandler, PlaceOrderHandler — each its own DTO, handler, route
    #[AsMessageHandler]
    final class OrderPlacedDispatcher
    {
        public function __construct(private MessageBusInterface $bus) {}
    
        public function __invoke(OrderPlaced $event): void
        {
            $this->bus->dispatch(new SendConfirmationEmail($event->orderId));
            $this->bus->dispatch(new ReserveStock($event->orderId));
            $this->bus->dispatch(new CreditLoyaltyPoints($event->customerId));
        }
    }

    use Symfony\Component\Messenger\Attribute\AsMessageHandler;
    
    final class PaymentSagaHandlers
    {
        public function __construct(private PaymentSagaRepository $repo) {}
    
        #[AsMessageHandler]
        public function onOrderPlaced(OrderPlaced $event): void
        {
            $saga = $this->repo->findByOrderId($event->orderId)
                ?? PaymentSaga::createFor($event->orderId);
    
            $saga->requestPayment();
            $this->repo->save($saga);
        }
    
        #[AsMessageHandler]
        public function onGatewayResponded(GatewayResponded $event): void
        {
            $saga = $this->repo->findByOrderId($event->orderId)
                ?? throw new PaymentSagaNotFound($event->orderId);
    
            $saga->handleGatewayResponse($event->success);
            $this->repo->save($saga);
        }
    }
    #[AsMessageHandler]
    final class FanOutStockReservations
    {
        public function __construct(private MessageBusInterface $bus) {}
    
        public function __invoke(OrderPlaced $event): void
        {
            foreach ($event->lineItems as $item) {
                $this->bus->dispatch(new ReserveStock(
                    $item->productId,
                    $item->quantity,
                    $event->orderId,
                ));
            }
        }
    }
    #[AsMessageHandler]
    final class SendConfirmationOnOrderPlaced {
    
    
  • No compensation primitive. When the last step permanently fails, the work done by earlier steps (Stripe charged, stock reserved) is stranded. You can write a failed() method that calls back to undo earlier steps, but every chain has to invent its own rollback logic — there's no orchestrator owning "what does order-fulfillment compensation look like."

  • You cannot test the workflow as a flow. You can unit-test each job, but "play the order-fulfillment process forward and assert on the resulting state" is not a primitive.

  • to read, enrich, and rewrite metadata as the message moves).
    public function __invoke(OrderPlaced $event): void { /* mailer */ }
    }
    #[AsMessageHandler]
    final class ReserveStockOnOrderPlaced {
    public function __invoke(OrderPlaced $event): void { /* stock */ }
    }
    #[AsMessageHandler]
    final class CreditLoyaltyOnOrderPlaced {
    public function __invoke(OrderPlaced $event): void { /* loyalty */ }
    }
    Coordinating long-running workflows with state and timeouts
    Passing a message through a multi-step pipeline
    Computing the workflow shape from data
    Branching a flow without if/else in domain code
    Fanning out one event to per-item operations
    Moving a handler to async
    Splitting bounded contexts across services
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner
    spinner