Main features of Ecotone

Ecotone is not only a Framework, it's an Ecosystem of tools from which you can choose features that suite your System best.

Features
Grid
  • Ensure Resiliency and Consistency
    Ensure Resiliency and Consistency

    Ecotone provides failure isolation, self-healing capabilities and data consistency patterns. Ensuring that the System remains resilient and no data is lost in case of failure.

  • Make complex problems simple
    Make complex problems simple

    By providing high level abstractions which are built on top of message-driven architecture, Ecotone makes it easy to implement even the most complex logic and workflows.

  • Improve Delivery time
    Improve Delivery time

    Ecotone enables declarative configuration which speeds up daily development significantly. It enables Developers to state their intention clearly, and build systems that are easy to maintain and extend.

  • Monitor and Trace
    Monitor and Trace

    Ecotone provides tracking mechanisms, which can follow synchronous and asynchronous communication. It provides details about each Message correlation, origination, and provides in-built integration with OpenTelemetry for tracing.

  • Scale by design
    Scale by design

    As Ecotone's communication happens over Messages, Systems becomes scalable at the Architecture level by design. It becomes trivial to move execution from synchronous to asynchronous, delay given action, or parallel the work.

  • Simply install and use
    Simply install and use

    By providing seamless integration into existing stacks (Symfony or Laravel or any other framework using Ecotone Lite), and by making all the configuration intuitive, Ecotone makes it easy to start for everyone from day one.

Core Messaging

// Pure PHP class - no framework dependencies
class OrderService
{
    #[CommandHandler]
    public function placeOrder(PlaceOrder $command): void
    {
        // Just your business logic
        $this->orders[$command->orderId] = $command->productName;
    }

    #[QueryHandler]
    public function getOrder(GetOrder $query): string
    {
        return $this->orders[$query->orderId];
    }
}

Clean Architecture

  • Check
    Keep Application level Classes pure

    Ecotone promotes POPO (Plain PHP Objects) development, and will never force to extend or implement any Framework related classes.

  • Check
    Use Advanced Features, yet stay Decoupled

    All configuration happens via Declarative programming using Attributes. Therefore even most advanced features can be added without changing application level logic.

  • Check
    Avoid time consuming upgrades

    As Ecotone is decoupled from the Application level code, changes to the Framework do not affect End-Users.

// Command and Query Handlers - explicit and clean
class TicketService
{
    #[CommandHandler]
    public function create(CreateTicket $command): void
    {
        // Handle command - change state
    }

    #[QueryHandler]
    public function get(GetTicket $query): Ticket
    {
        // Handle query - return data
    }

    #[EventHandler]
    public function when(TicketCreated $event): void
    {
        // React to events
    }
}

Message Bus with CQRS

  • Check
    Power Application with Messaging

    In Ecotone communication happens over Messages. This creates scalable and reliable foundation for all other higher level features.

  • Check
    Make related logic explicit

    Make related logic encapsulated and explicit, by stating clearly whatever given Message Handler is meant to handle Command, Event or Query.

  • Check
    Use Inbuilt Message Buses

    After installing Ecotone - Command, Event and Query Buses are available in Dependency Container and ready to use.

// Run asynchronously - just add one attribute
#[Asynchronous('notifications')]
#[EventHandler]
public function sendEmail(OrderPlaced $event): void
{
    $this->mailer->send($event->customerEmail);
}

// Configure any Message Broker with single line
#[ServiceContext]
public function messageChannel(): array
{
    return [
        // Switch easily: Rabbit, SQS, Redis, Kafka, Database
        AmqpBackedMessageChannelBuilder::create('notifications')
    ];
}

Asynchronous Messaging

  • Check
    Run code asynchronously

    Simply mark Message Handler with Asynchronous Attribute to execute given method asynchronously.

  • Check
    Support for different Message Brokers

    Ecotone supports RabbitMQ, Amazon SQS, Kafka, Redis and Database to execute Message Handlers asynchronously.

  • Check
    Switch providers easily

    Business code is fully decoupled from the Message Broker, therefore we can switch providers easily.

// Delay message execution by 1 hour
#[Asynchronous('orders')]
#[Delayed(new TimeSpan(hours: 1))]
#[EventHandler]
public function sendReminder(OrderPlaced $event): void
{
    $this->reminder->send($event->orderId);
}

// Or delay dynamically when sending
$eventBus->publish(
    new OrderPlaced($orderId),
    metadata: ['deliveryDelay' => new TimeSpan(hours: 1)]
);

Managing Message Execution

  • Check
    Delay execution

    Messages can be delayed dynamically while sending through Command / Event Buses, or statically using Attributes.

  • Check
    Expire Messages

    Each Message can have expiration time, so it will be discarded if not processed within specific time.

  • Check
    Prioritize Messages

    Message and Message Handlers can be marked with priority, to indicate processing order.

// Publish events from any service
#[CommandHandler]
public function shipOrder(ShipOrder $command, EventBus $eventBus): void
{
    // handle shipping logic

    $eventBus->publish(new OrderWasShipped($command->orderId));
}

// Multiple handlers react independently
#[Asynchronous('notifications')]
#[EventHandler]
public function sendShippingEmail(OrderWasShipped $event): void {}

#[Asynchronous('analytics')]
#[EventHandler]
public function updateMetrics(OrderWasShipped $event): void {}

#[Asynchronous('inventory')]
#[EventHandler]
public function releaseReservation(OrderWasShipped $event): void {}

Event-Driven Architecture

  • Check
    Publish-Subscribe pattern

    Publish events once, let multiple handlers subscribe independently. Handlers can be synchronous or asynchronous on different channels.

  • Check
    Loose coupling between components

    Event publishers don't know about subscribers. Add new reactions without modifying existing code - true Open/Closed principle.

  • Check
    Scale event processing independently

    Each event handler can run on separate async channels with independent scaling, retry policies, and error handling.

// Split batch into individual messages
#[Splitter(inputChannelName: 'processBatch')]
public function split(OrderBatch $batch): array
{
    return $batch->getOrders(); // Each order processed separately
}

// Process each split item
#[InternalHandler(inputChannelName: 'processBatch')]
public function processOrder(Order $order): void
{
    // Called once per order from the batch
    $this->orderService->process($order);
}

Message Splitter

  • Check
    Split collections for parallel processing

    Automatically split arrays or collections into individual messages for separate processing.

  • Check
    Combine with workflows

    Splitters integrate seamlessly with Orchestrators and Workflows for complex batch processing.

  • Check
    Each item processed independently

    Failed items don't affect others - each split message has independent retry and error handling.

// Multiple services consume same event stream
#[ServiceContext]
public function streamChannel(): array
{
    return [
        // RabbitMQ Streams or Kafka
        AmqpStreamChannelBuilder::create(
            channelName: "user_events",
            messageGroupId: "ticket-service" // Unique per service
        )->withCommitInterval(100),
    ];
}

// Events published once, consumed by many services independently
#[Distributed]
#[EventHandler("user.registered")]
public function onUserRegistered(UserRegistered $event): void {}

Streaming Channels

  • Check
    Shared event log for multiple consumers

    Publish events once, let multiple services consume independently with their own position tracking.

  • Check
    Event replay capability

    Services can replay events from any point in the stream for recovery or new projections.

  • Check
    Kafka and RabbitMQ Streams support

    Use Kafka topics or RabbitMQ Streams for high-throughput, persistent event streaming.

// Run periodic tasks with simple attributes
class NotificationService
{
    #[Scheduled(endpointId: "notificationSender")]
    #[Poller(fixedRateInMilliseconds: 1000)]
    public function sendNotifications(): void
    {
        // Runs every second
    }

    #[Scheduled(requestChannelName: "exchange", endpointId: "currencyExchanger")]
    #[Poller(cron: "0 * * * *")]  // Every hour
    public function fetchCurrencyRates(): array
    {
        return ["currency" => "EUR", "ratio" => 1.23];
    }
}

Scheduling Support

  • Check
    Run periodic tasks easily

    Use Scheduled attribute to run tasks at fixed intervals or cron expressions without external dependencies.

  • Check
    Connect to Message Handlers

    Scheduled methods can send results to Message Handlers, creating automated workflows.

  • Check
    Dynamic timing configuration

    Use expression language to configure timing dynamically based on environment or services.

Metadata & Tracing

// Pass metadata with your messages
$commandBus->send(
    new CreateOrder($productId),
    metadata: [
        'userId' => $currentUserId,
        'correlationId' => $traceId
    ]
);

// Access metadata in handler
#[CommandHandler]
public function create(
    CreateOrder $command,
    #[Header('userId')] string $userId
): void {
    // $userId available from metadata
}

Metadata Support

  • Check
    Add Metadata to Messages

    All Ecotone's Messages comes with Headers (Metadata) to easily carry any additional details.

  • Check
    Keep code clean of passing Metadata

    Ecotone automatically propagates metadata to next Messages. This works in synchronous and asynchronous scenarios.

  • Check
    Keep code clean of Transformations

    Ecotone can automatically convert Metadata to different types.

// Pass metadata once - available throughout entire flow
$commandBus->send(
    new BlockUser($userId),
    metadata: ['executorId' => $currentUserId, 'requestId' => $traceId]
);

// Command Handler - no need to forward metadata
#[CommandHandler]
public function block(BlockUser $cmd, EventBus $eventBus): void
{
    $eventBus->publish(new UserWasBlocked($cmd->userId));
    // executorId and requestId automatically propagated!
}

// Event Handler - metadata still available
#[EventHandler]
public function audit(UserWasBlocked $e, #[Header('executorId')] string $executor): void
{
    $this->auditLog->record($e->userId, $executor);
}

Automatic Metadata Propagation

  • Check
    Zero-effort context propagation

    All message headers automatically flow through Command Handlers, Event Handlers, and async processing.

  • Check
    Works across async boundaries

    Metadata preserved when messages go through queues - correlation IDs, user context always available.

  • Check
    Foundation for tracing and audit

    Build audit logs and tracing without passing context manually through every method.

// OpenTelemetry support - zero code changes needed
// Just install and configure:

composer ecotone/open-telemetry

// All message handlers automatically traced:
// - Command handlers
// - Event handlers
// - Query handlers
// - Async message processing

Tracing and Monitoring

  • Check
    Track Messages without additional code

    All Messages are automatically assigned identifiers: Message Id, Parent Id, Correlation Id.

  • Check
    Make use OpenTelemetry support

    Ecotone comes with OpenTelemetry standard support. Traces can be pushed to Jaeger, Zipkin, or DataDog.

  • Check
    Track failed Messages from one place

    Ecotone comes with centralized monitoring system for all failed Messages across Applications.

Domain Modeling

// Send Command directly to Aggregate - no orchestration code
$commandBus->send(new AddItemToOrder($orderId, $productId, $quantity));

// Aggregate handles command, returns events, gets persisted - automatically
#[Aggregate]
class Order
{
    use WithAggregateEvents;

    #[Identifier]
    private OrderId $orderId;

    #[CommandHandler]
    public function addItem(
        AddItemToOrder $command,
        PricingService $pricing // Inject domain services
    ): void {
        $price = $pricing->calculatePrice($command->productId);
        $this->items[] = new OrderItem($command->productId, $price);

        $this->recordThat(new ItemWasAddedToOrder(
            $this->orderId,
            $command->productId
        ));
    }
}

// Domain Event automatically published after Aggregate is saved
#[EventHandler]
public function onItemAdded(ItemWasAddedToOrder $event): void {}

Domain-Driven Design Building Blocks

  • Check
    No orchestration layer needed

    Commands route directly to Aggregates. Ecotone handles loading, method invocation, and persistence - eliminating application service boilerplate.

  • Check
    Inject Domain Services into Aggregates

    Aggregate command handlers can receive domain services via dependency injection. Keep complex domain logic where it belongs.

  • Check
    Automatic Domain Event publishing

    Events recorded with recordThat() are automatically published after aggregate persistence. No manual EventBus calls needed.

// Subscribe to events from other aggregates - no boilerplate
#[Aggregate]
class CustomerPromotion
{
    #[Identifier]
    private string $customerId;
    private bool $isActive = true;

    #[EventHandler]
    public function onAccountClosed(AccountWasClosed $event): void
    {
        // React to event from Account aggregate
        $this->isActive = false;
    }

    #[EventHandler]
    public function onOrderPlaced(OrderPlaced $event): void
    {
        // React to event from Order aggregate
        $this->points += $event->amount * 0.1;
    }
}

Aggregate Event Handlers

  • Check
    Cross-aggregate reactions without services

    Aggregates can directly subscribe to events from other aggregates - no orchestrating service needed.

  • Check
    Automatic aggregate loading

    Ecotone loads the target aggregate using event correlation, updates it, and saves automatically.

  • Check
    Keep related logic together

    Business reactions stay in the aggregate where they belong, not scattered in event handler services.

// Automatic version tracking for Event Sourced Aggregates
#[EventSourcingAggregate]
class Wallet
{
    use WithAggregateVersioning; // Adds version tracking

    #[Identifier]
    private string $walletId;

    #[CommandHandler]
    public function withdraw(WithdrawMoney $cmd): array
    {
        // Concurrent modifications detected automatically
        return [new MoneyWithdrawn($cmd->amount)];
    }
}

// Configure instant retry on concurrency conflicts
#[ServiceContext]
public function retryConfig(): InstantRetryConfiguration
{
    return InstantRetryConfiguration::createWithDefaults()
        ->withCommandBusRetry(true, 3, [OptimisticLockingException::class]);
}

Optimistic Locking

  • Check
    Automatic version management

    Use WithAggregateVersioning trait for automatic version tracking and concurrency detection.

  • Check
    Self-healing with instant retries

    Configure automatic retries on OptimisticLockingException to handle concurrent modifications.

  • Check
    No lost updates

    Concurrent command executions are detected and handled, preventing silent data overwrites.

// Saga for long-running processes
#[Saga]
class OrderFulfillment
{
    #[Identifier]
    private string $orderId;

    #[EventHandler]
    public static function start(OrderPlaced $event): self
    {
        return new self($event->orderId);
    }

    #[EventHandler]
    public function onPayment(PaymentReceived $event): void
    {
        // Continue saga after payment
    }

    #[EventHandler]
    public function onShipped(OrderShipped $event): void
    {
        // Mark saga as complete
    }
}

Saga Support

  • Check
    Use for long running processes

    Saga is great way to handle long running processes, where each step can be persisted and resumed later.

  • Check
    Bind Events seamlessly

    Sagas can be bound to any Events published from Event Bus or Aggregates.

  • Check
    Connect with standard Workflows

    We can connect Saga with standard Workflows using input / output channels.

// Connect handlers with output channels
#[CommandHandler(outputChannelName: 'validate')]
public function placeOrder(PlaceOrder $cmd): Order
{
    return new Order($cmd->orderId);
}

#[InternalHandler('validate', outputChannelName: 'ship')]
public function validate(Order $order): Order
{
    // Validate order
    return $order;
}

#[Asynchronous('shipping')]  // Make this step async
#[InternalHandler('ship')]
public function ship(Order $order): void
{
    // Ship order
}

Workflow Support

  • Check
    Build reliable Workflows under your control

    Workflows are defined using Declarative Configuration, fully under your control within PHP.

  • Check
    Pipe and Filters like architecture

    All Message Handlers can easily be connected using input and output channels.

  • Check
    Make given step Asynchronous easily

    Change given step in Workflow to Asynchronous when needed, simply by adding Asynchronous attribute.

// Define workflow as sequence of steps
class OrderProcessingOrchestrator
{
    #[Orchestrator(inputChannelName: "process.order")]
    public function processOrder(Order $order): array
    {
        $steps = ["validate.order", "calculate.pricing"];

        if ($order->requiresApproval()) {
            $steps[] = "manual.approval";
        }

        return [...$steps, "finalize.order"];
    }

    #[InternalHandler(inputChannelName: "validate.order")]
    public function validate(Order $order): Order
    {
        return $order->validate();
    }
}

Orchestrator Support

  • Check
    Separate workflow from steps

    Define workflows independently from step implementation. Easy to understand the entire process.

  • Check
    Build dynamic workflows

    Construct workflows programmatically based on business rules at runtime.

  • Check
    Reuse steps across workflows

    Same steps can be used in multiple workflows. Mix and match for different scenarios.

Event Sourcing & Projections

// Event Sourced Aggregate - events stored automatically
#[EventSourcingAggregate]
class Wallet
{
    #[Identifier]
    private string $walletId;
    private int $balance = 0;

    #[CommandHandler]
    public function deposit(DepositMoney $cmd): array
    {
        return [new MoneyDeposited($cmd->amount)];
    }

    #[EventSourcingHandler]
    public function onDeposit(MoneyDeposited $event): void
    {
        $this->balance += $event->amount;
    }
}

Event Sourcing Support

  • Check
    Use inbuilt Event Store

    Ecotone comes with support for storing Events in databases like MySQL, PostgreSQL, MariaDB.

  • Check
    Store Aggregates as Stream of Events

    Event Store is integrated with Aggregates and Sagas, so we can easily store them as series of Events.

  • Check
    Use Persistence Strategies

    Ecotone provides different persistence strategies, which helps in achieving consistency.

// Mark events with revision for schema evolution
#[Revision(2)]
class OrderPlaced
{
    public function __construct(
        public string $orderId,
        public string $customerId,
        public Money $totalAmount // Added in revision 2
    ) {}
}

// Access revision in handlers for migration logic
#[EventSourcingHandler]
public function onOrderPlaced(
    OrderPlaced $event,
    #[Header('revision')] int $revision
): void {
    if ($revision < 2) {
        // Handle legacy events without totalAmount
    }
    $this->orderId = $event->orderId;
}

Event Versioning

  • Check
    Safe schema evolution

    Track event versions with Revision attribute to handle schema changes over time.

  • Check
    Access revision in handlers

    Event handlers can check revision to apply different logic for legacy vs new events.

  • Check
    Upcasting support

    Transform old event formats to new ones during deserialization using custom converters.

// Configure snapshots for long-lived aggregates
#[ServiceContext]
public function snapshots(): EventSourcingConfiguration
{
    return EventSourcingConfiguration::createWithDefaults()
        // Snapshot every 1000 events
        ->withSnapshotsFor(Ticket::class, 1000)
        // Different threshold per aggregate
        ->withSnapshotsFor(Order::class, 500);
}

// Aggregate code unchanged - snapshots are transparent
#[EventSourcingAggregate]
class Ticket
{
    // Loaded from snapshot + events since snapshot
    // No code changes needed
}

Event Sourcing Snapshots

  • Check
    Optimize loading of long-lived aggregates

    Automatically create snapshots to avoid replaying thousands of events on every load.

  • Check
    Transparent to aggregate code

    Snapshots work automatically - no changes needed in your aggregate implementation.

  • Check
    Configurable per aggregate

    Set different snapshot intervals for different aggregates based on their event frequency.

// Decouple modules with named events
#[NamedEvent('order.placed')]
class OrderWasPlaced
{
    public function __construct(
        public string $orderId,
        public string $productId
    ) {}
}

// Subscribe by name - no class dependency needed
#[EventHandler(listenTo: "order.placed")]
public function onOrderPlaced(array $payload): void
{
    // Handle event without importing OrderWasPlaced class
    $orderId = $payload['orderId'];
}

// Or with your own DTO
#[EventHandler(listenTo: "order.placed")]
public function notify(OrderPlacedNotification $event): void {}

Named Events

  • Check
    Module decoupling

    Subscribe to events by routing key instead of class - modules don't need to share event classes.

  • Check
    Flexible deserialization

    Receive events as arrays, your own DTOs, or any type - Ecotone handles conversion.

  • Check
    Perfect for distributed systems

    Services can subscribe to events without sharing code - essential for microservices.

// Create read models from events
#[Projection('order_list', Order::class)]
class OrderListProjection
{
    #[EventHandler]
    public function onOrderPlaced(OrderPlaced $event): void
    {
        $this->repository->save([
            'order_id' => $event->orderId,
            'status' => 'placed',
            'created_at' => $event->occurredAt
        ]);
    }

    #[EventHandler]
    public function onOrderShipped(OrderShipped $event): void
    {
        $this->repository->updateStatus(
            $event->orderId,
            'shipped'
        );
    }
}

Event Projecting System

  • Check
    Build quickly different views

    Ecotone provides a way to project Events to read models, so we can quickly build different views for our application.

  • Check
    Synchronous and Asynchronous Projections

    Projections can be executed synchronously for immediate consistency, or asynchronously to optimize for performance.

  • Check
    Rebuild or delete

    Ecotone comes with inbuilt mechanism for rebuilding Projections and deleting them when not needed.

// Stateful projection - state persisted automatically
#[Projection('ticket_counter', Ticket::class)]
class TicketCounterProjection
{
    #[EventHandler]
    public function when(
        TicketRegistered $event,
        #[ProjectionState] CounterState $state
    ): CounterState {
        return $state->increment(); // New state persisted
    }
}

// Fetch projection state via Gateway
interface TicketCounterGateway
{
    #[ProjectionStateGateway('ticket_counter')]
    public function getCounter(): CounterState;
}

// Use in your code
$count = $this->ticketCounterGateway->getCounter()->value;

Projection State Management

  • Check
    State without external storage

    Projections can maintain state internally - no need to set up separate database tables.

  • Check
    Automatic serialization

    State is automatically serialized/deserialized between executions - use arrays or classes.

  • Check
    Gateway access pattern

    Define interfaces to fetch projection state - Ecotone provides the implementation.

// Emit events after projection updates - solve eventual consistency
#[Projection('wallet_balance', Wallet::class)]
class WalletBalanceProjection
{
    private int $balance = 0;

    #[EventHandler]
    public function when(
        MoneyDeposited $event,
        EventStreamEmitter $emitter
    ): void {
        $this->balance += $event->amount;
        $this->repository->updateBalance($event->walletId, $this->balance);

        // Emit event AFTER projection is updated
        $emitter->emit([
            new WalletBalanceChanged($event->walletId, $this->balance)
        ]);
    }
}

// Downstream handlers see consistent state
#[EventHandler]
public function notify(WalletBalanceChanged $event): void {
    // Balance in DB already updated when this runs
}

Event Emitting from Projections

  • Check
    Solve eventual consistency problems

    Emit events after projection updates - downstream handlers always see consistent read model state.

  • Check
    Notify after data is ready

    Perfect for sending notifications or triggering actions that depend on updated projections.

  • Check
    Build throw-away projections

    Create projections that calculate results, emit events, then delete themselves.

Resiliency

// Automatic retries with exponential backoff
#[Asynchronous('orders')]
#[EventHandler]
public function notify(OrderPlaced $event): void
{
    $this->notification->send($event);
}

// Configure retry strategy
#[ServiceContext]
public function errorHandling(): array
{
    return
        // 3 retries for notifications
        ErrorHandlerConfiguration::createWithDeadLetterChannel(
            'errorChannel',
            RetryTemplateBuilder::exponentialBackoff(1000, 10)
                ->maxRetryAttempts(3),
        // if retry strategy will not recover, then store in database
            'dbal_dead_letter'
        );
}

Resiliency Support

  • Check
    Make System self-heal

    Ecotone handles automatic retries in synchronous and asynchronous scenarios, moving failures to Dead Letter.

  • Check
    Keep failures isolated out of the box

    When multiple Handlers subscribe to the same Event, Ecotone ensures processing isolation for each.

  • Check
    Avoid infrastructure failures

    Ecotone maintains connections to Brokers and Database, and self-heals automatically when connection is lost.

// Outbox pattern - no message lost
#[ServiceContext]
public function outbox(): array
{
    return [
        // Messages committed with your database transaction, then sent to RabbitMQ
        CombinedMessageChannel::create(
            'outbox',
            ['db_channel', 'rabbit_channel']
        ),
    ];
}

Consistency Support

  • Check
    Ensure no Message is lost

    Pass Messages through Database Message Channel to ensure Messages are committed together with database changes (Outbox pattern).

  • Check
    Handle sending failures automatically

    In case of failure while sending, Ecotone retries and can store unrecoverable errors in Dead Letter.

  • Check
    Avoid processing duplicates

    Ecotone provides deduplication of Messages, so if a Message was already handled, it will not process it again.

// Each handler gets its own copy of the event
#[Asynchronous("notifications")]
#[EventHandler(endpointId: "sendConfirmation")]
public function sendConfirmation(OrderPlaced $event): void
{
    $this->mailer->send($event->email);  // Safe to retry
}

#[Asynchronous("inventory")]
#[EventHandler(endpointId: "reserveStock")]
public function reserveStock(OrderPlaced $event): void
{
    $this->stock->reserve($event->productId);  // Isolated retry
}

// If reserveStock fails, only it gets retried - not sendConfirmation

Message Handling Isolation

  • Check
    Safe retries without side effects

    Each Event Handler processes in complete isolation. Only failed handlers are retried.

  • Check
    Foundation-level isolation

    Ecotone delivers a copy of each Message to each Handler, solving isolation at the core.

  • Check
    Independent handler configuration

    Each handler can have different async channels, delays, or priorities independently.

Distributed Systems

// Publish events to other services
#[EventHandler]
public function onOrderPlaced(OrderPlaced $event, DistributedBus $distributedBus): void
{
    $distributedBus->convertAndPublishEvent(
        routingKey: 'order.placed',
        event: $event
    );
}

// Receive events from other services
#[Distributed]
#[EventHandler('order.placed')]
public function handleExternalOrder(OrderPlaced $event): void
{
    // Handle event from order service
    $this->inventory->reserve($event->productId);
}

Distributed Bus Support

  • Check
    Connect Applications easily

    Ecotone provides support for sending Commands and Events between different Applications.

  • Check
    Make it clear what is Distributed

    Ecotone makes it clear what Messages are distributed, to make the System easier to follow and understand.

  • Check
    Keep Applications decoupled

    No need to share classes between Applications. Ecotone will deserialize Messages to whatever type is defined.

// Business code stays clean - no tenant logic needed
#[CommandHandler]
public function placeOrder(PlaceOrder $command): void
{
    // Same code works for all tenants
    $this->repository->save($command->orderId);
}

// Configure tenant routing separately
#[ServiceContext]
public function multiTenantConfig(): MultiTenantConfiguration
{
    return MultiTenantConfiguration::create(
        tenantHeaderName: 'tenant',
        tenantToConnectionMapping: [
            'tenant_a' => 'tenant_a_connection',
            'tenant_b' => 'tenant_b_connection',
        ],
    );
}

Multi-Tenancy Support

  • Check
    Keep business code clean

    Application code remains agnostic to multi-tenancy. Ecotone handles tenant switching automatically.

  • Check
    Automatic context propagation

    Tenant context is automatically propagated through all Message flows, both sync and async.

  • Check
    Different connections per tenant

    Configure separate database connections, message channels, or any resource per tenant.

Dev Experience and Extensibility

// Dynamic delay based on payload
#[Delayed(expression: 'payload.getDeliveryDate()')]
#[Asynchronous('notifications')]
#[EventHandler]
public function sendReminder(OrderPlaced $event): void {}

// Dynamic deduplication key
#[Deduplicated(expression: 'payload.paymentId ~ payload.amount')]
#[CommandHandler]
public function receivePayment(ReceivePayment $cmd): void {}

// Access DI services in expressions
#[TimeToLive(expression: 'reference("configService").getTTL()')]
#[Asynchronous('orders')]
#[CommandHandler]
public function process(ProcessOrder $cmd): void {}

Expression Language Support

  • Check
    Dynamic configuration at runtime

    Use expressions to compute delays, TTL, deduplication keys dynamically based on message content.

  • Check
    Access payload, headers and services

    Expressions can access payload properties, message headers, and any service from DI container.

  • Check
    Symfony Expression Language

    Uses familiar Symfony Expression Language syntax for powerful runtime configuration.

// Intercept any message handler
#[Before(pointcut: CommandHandler::class)]
public function authorize(
    #[Header('userId')] string $userId,
    MethodInvocation $invocation
): mixed {
    if (!$this->auth->canAccess($userId)) {
        throw new AccessDenied();
    }
    return $invocation->proceed();
}

// Target by attribute, namespace, or class name
#[Before(pointcut: RequiresAdmin::class)]
public function verifyAdmin(RequiresAdmin $attribute): void
{
    return $this->auth->hasRole($attribute->role);
}

Easy Extension

  • Check
    Intercept Flows

    Ecotone comes with Interceptors (more powerful Middlewares), which allow to hook into any Message flow.

  • Check
    Easy hooking mechanism

    Interceptors can hook into any Message Handler using namespace regex, class names or even targeting attributes.

  • Check
    Use different moments of interception

    Ecotone allows to intercept Messages at different point of time: before queue, before/after handling.

// Your Application Core - zero infrastructure dependencies
#[CommandHandler]
public function placeOrder(PlaceOrder $command): void
{
    // Pure business logic - no framework, no infrastructure
}

// Ecotone IS your Adapters Layer - swap with one line
#[ServiceContext]
public function messagingConfig(): array
{
    return [
        // Switch message broker without touching business code
        AmqpBackedMessageChannelBuilder::create('orders'),
        // Or SqsBackedMessageChannelBuilder::create('orders'),
        // Or KafkaBackedMessageChannelBuilder::create('orders'),
        // Or DbalBackedMessageChannelBuilder::create('orders'),
    ];
}

// Define interface - Ecotone provides infrastructure adapter
interface OrderRepository
{
    #[Repository]
    public function save(Order $order): void;

    #[Repository]
    public function findBy(OrderId $orderId): ?Order;
}
// No implementation needed - Ecotone generates it

Hexagonal Architecture Support

  • Check
    Message Buses are your Driving Adapters

    Command, Query, and Event Buses act as ports to your application. Controllers, CLI, or external services all use the same entry points.

  • Check
    Infrastructure as Configuration

    Message brokers, repositories, and event stores are configured declaratively. Swap RabbitMQ for Kafka or PostgreSQL for MongoDB without code changes.

  • Check
    Auto-generated Repository Adapters

    Define repository interfaces with #[Repository] - Ecotone generates implementations for Doctrine, Eloquent, Document Store, or Event Sourcing.

// Route commands by name - no direct class reference needed
$commandBus->sendWithRouting(
    'order.place',
    ['productId' => 123, 'quantity' => 2]
);

// Handler registers routing key
#[CommandHandler('order.place')]
public function place(PlaceOrder $data): void
{
    // Ecotone handles conversion automatically
}

// Even simpler - generic controller for all commands
public function handleCommand(Request $request): Response
{
    return $this->commandBus->sendWithRouting(
        $request->get('command'),
        $request->getContent()
    );
}

Keep Application Code Decoupled

  • Check
    Use Command Bus routing

    Ecotone comes with Command Bus which allows to route Commands / Events by routing keys.

  • Check
    Delegate conversion to Ecotone

    Ecotone can take over conversion, so our Application level code can simply pass data as it comes from outside world.

  • Check
    Write less code

    By using Message routing and delegating conversion to Ecotone, we can build really simple Controllers.

// Define converters with simple attributes
class CoordinatesConverter
{
    #[Converter]
    public function fromArray(array $data): Coordinates
    {
        return new Coordinates($data["lat"], $data["lng"]);
    }

    #[Converter]
    public function toArray(Coordinates $coords): array
    {
        return ["lat" => $coords->lat, "lng" => $coords->lng];
    }
}

// Ecotone converts automatically when needed
#[CommandHandler("location.update")]
public function update(Coordinates $coords): void {}

Automatic Conversion

  • Check
    Lazy conversion

    Conversion happens only when needed, at the point of method invocation.

  • Check
    Support for any format

    Convert between JSON, XML, arrays, and PHP objects using Media Type Converters.

  • Check
    Collection conversion

    Array of objects are automatically converted using registered converters for single items.

// Define interface - Ecotone delivers implementation
interface TicketApi
{
    #[BusinessMethod('ticket.create')]
    public function create(CreateTicketCommand $command): void;

    #[BusinessMethod('ticket.get')]
    public function get(GetTicketQuery $query): TicketDTO;
}

// Use it directly - no Command Bus needed
class TicketController
{
    public function __construct(private TicketApi $ticketApi) {}

    public function create(Request $request): Response
    {
        $this->ticketApi->create(new CreateTicketCommand(...));
    }
}

Business Interface

  • Check
    Skip boilerplate code

    Define interfaces and let Ecotone provide implementations. No delegation code needed.

  • Check
    Bypass Bus middlewares

    Call handlers directly without going through Command/Query Bus interceptors when needed.

  • Check
    Automatic type conversion

    Ecotone handles conversion between interface types and handler types automatically.

// Define SQL in interface - Ecotone handles the rest
interface PersonApi
{
    #[DbalWrite("INSERT INTO persons VALUES (:personId, :name)")]
    public function register(int $personId, string $name): void;

    #[DbalQuery(
        "SELECT * FROM persons WHERE person_id = :personId",
        fetchMode: FetchMode::FIRST_ROW
    )]
    public function getPerson(int $personId): ?PersonDTO;

    #[DbalQuery("SELECT COUNT(*) FROM persons", fetchMode: FetchMode::FIRST_COLUMN_OF_FIRST_ROW)]
    public function count(): int;
}

Database Business Methods

  • Check
    Reduce data access boilerplate

    Define SQL queries in interface methods. No repository implementation code needed.

  • Check
    Automatic parameter binding

    Method parameters are automatically bound to SQL named parameters.

  • Check
    Flexible fetch modes

    Return arrays, single rows, first column, or iterate over large result sets.

// Store and retrieve objects without boilerplate
class UserStore
{
    public function __construct(private DocumentStore $documentStore) {}

    public function store(User $user): void
    {
        // Automatically serializes to JSON and stores
        $this->documentStore->addDocument("users", $user->getId(), $user);
    }

    public function getUser(int $userId): User
    {
        // Automatically deserializes back to User object
        return $this->documentStore->getDocument("users", $userId);
    }
}

Document Store

  • Check
    Simple document storage API

    Store JSON, arrays, objects without knowledge about storage and serialization details.

  • Check
    Automatic serialization

    Ecotone handles conversion to JSON for storage and back to objects when fetching.

  • Check
    Use for Aggregate storage

    Document Store can be used as State-Stored Aggregate Repository with DBAL support.

// Combine multiple channels into one consumer
#[ServiceContext]
public function dynamicChannel(): array
{
    return [
        DynamicMessageChannelBuilder::createRoundRobin(
            'orders_and_notifications',
            ['orders', 'notifications']
        ),

        // Or route by tenant
        DynamicMessageChannelBuilder::createWithHeaderBasedStrategy(
            'orders',
            'tenant',
            ['tenant_a' => 'tenant_a_channel', 'tenant_b' => 'tenant_b_channel'],
            'shared_channel' // default
        ),
    ];
}

Dynamic Message Channels

  • Check
    Optimize resource usage

    Single consumer can poll from multiple channels using round-robin or custom strategies.

  • Check
    Route by tenant or context

    Distribute messages to different queues based on headers like tenant ID.

  • Check
    Throttling and rate limiting

    Implement per-client rate limiting by skipping consumption when limits are reached.

// Create console commands with attributes
class EmailSender
{
    #[ConsoleCommand('sendEmail')]
    public function execute(
        string $email,
        #[ConsoleParameterOption] string $type = 'normal',
        #[Reference] Mailer $mailer
    ): void {
        $mailer->send($email, $type);
    }
}

// Run with: bin/console sendEmail "test@example.com" --type="welcome"

Console Commands

  • Check
    Decoupled console commands

    Create commands without extending framework classes. Place them wherever makes sense.

  • Check
    Automatic argument registration

    Method parameters become command arguments. Options marked with ConsoleParameterOption.

  • Check
    Service injection support

    Inject any service from Dependency Container directly into command method using Reference.

Testing & Integration

// Test your handlers in isolation
$result = EcotoneLite::bootstrapFlowTesting([
    OrderService::class,
])
    ->sendCommand(new PlaceOrder($orderId, $productId))
    ->getRecordedEvents();

self::assertEquals(
    [new OrderPlaced($orderId)],
    $result
);

Testing support

  • Check
    Test in isolation

    Run in isolation whatever is needed for given test scenario. Simply add the Class name to bootstrap phase.

  • Check
    Support for Asynchronous testing

    Asynchronous code can be triggered from within test, just like any other code.

  • Check
    Test any Building Block

    Any Ecotone building blocks like Event Sourcing Aggregates, Projections, Sagas can be tested.

// Works out of the box with any PHP Framework

// Symfony - just install and go
// composer require ecotone/symfony-bundle

// Laravel - just install and go
// composer require ecotone/laravel

// Standalone / Other frameworks
// composer require ecotone/ecotone

EcotoneLite::bootstrap([OrderService::class])
    ->getCommandBus()
    ->send(new PlaceOrder($orderId));

Seamless Integration

  • Check
    Build from day one

    All Ecotone Modules will provide sensible defaults and auto-configuration, so we can build things from day one.

  • Check
    Run with any PHP Framework

    It works out of the box with Symfony, Laravel or any other PHP Framework using Ecotone Lite.

  • Check
    Access knowledge easily

    Ecotone provides compressive documentation, code examples, articles, tutorials and workshops.

Frequently asked questions

Haven’t found what you’re looking for? Contact us

Development of Ecotone have began in middle of 2017, and initially involved a lot of research and knowledge crunching on how to build decoupled abstraction for Message Driven Systems. The implementation was based on Enterprise Integration Patterns and was heavily inspired by Java's Spring Integration (foundation for Netflix's Spring Cloud). Ecotone started to be used in production environments at the beginning of 2019, initially as private project. It was open sourced in May 2021, and since then a lot of high level features have been added.
Ecotone is designed to be a lightweight, easy-to-use, and flexible framework for building message-driven systems. It helps developers build robust, reliable, and scalable applications quickly and easily. On the application level promotes POPO development, which means that application level Classes does not need to implement framework specific interfaces or abstractions. By providing declarative configuration system are meant to be build without the need for complex configuration or boilerplate code, so Developers no matter of experience can start using Ecotone from day one.
Ecotone provides excellent documentation that covers all aspects of the framework, including its architecture, features, and how to use it. The best place to start is going through Introduction section on documentation page..
Yes, Ecotone provided dedicated packages for integration with both Laravel and Symfony, that make the integration seamless. After installation components like Command/Event/Query Buses are available out of the box in Dependency Container.
Yes, Ecotone can be used with any PHP framework through Ecotone Lite. Ecotone Lite is a stand-alone version of Ecotone and allows for seamless integration into your Dependency Container if needed.
No, you don't need to rewrite your application to use Ecotone. Ecotone is designed to work seamlessly with existing applications, allowing to leverage the power of messaging and event-driven architecture without having to make major changes to the codebase. We can easily start using Ecotone only for the parts of the application where it makes sense, gradually transitioning over time.
It does not affect performance of our Applications due how Ecotone have adapted the configuration scanning. Ecotone works in phases, it firstly reads all the configuration and then compiles it to the optimized version. This means attributes are read only once, cached, and reused between requests.
Ecotone scans the code base and compiles the configuration, therefore all the configuration is read only once and compiled, optimized version is created. Compiled configuration is then reused between requests, therefore no additional scanning is done. When Ecotone's code is executed, it does load only the part of the compiled configuration which is related to given flow (e.g. single Command Handler). Thanks to that Ecotone works very efficient in large scale code bases.

Be part of the change with EcotoneCurve

Unleash the power of Messaging in PHP
and push productivity to the higher level

Get started
Gradient
Shapes 1
Shapes 2
DiscordTwitterSupport and ContactTelegram