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


// 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];
}
}Ecotone promotes POPO (Plain PHP Objects) development, and will never force to extend or implement any Framework related classes.
All configuration happens via Declarative programming using Attributes. Therefore even most advanced features can be added without changing application level logic.
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
}
}In Ecotone communication happens over Messages. This creates scalable and reliable foundation for all other higher level features.
Make related logic encapsulated and explicit, by stating clearly whatever given Message Handler is meant to handle Command, Event or Query.
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')
];
}Simply mark Message Handler with Asynchronous Attribute to execute given method asynchronously.
Ecotone supports RabbitMQ, Amazon SQS, Kafka, Redis and Database to execute Message Handlers asynchronously.
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)]
);Messages can be delayed dynamically while sending through Command / Event Buses, or statically using Attributes.
Each Message can have expiration time, so it will be discarded if not processed within specific time.
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 {}Publish events once, let multiple handlers subscribe independently. Handlers can be synchronous or asynchronous on different channels.
Event publishers don't know about subscribers. Add new reactions without modifying existing code - true Open/Closed principle.
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);
}Automatically split arrays or collections into individual messages for separate processing.
Splitters integrate seamlessly with Orchestrators and Workflows for complex batch processing.
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 {}Publish events once, let multiple services consume independently with their own position tracking.
Services can replay events from any point in the stream for recovery or new projections.
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];
}
}Use Scheduled attribute to run tasks at fixed intervals or cron expressions without external dependencies.
Scheduled methods can send results to Message Handlers, creating automated workflows.
Use expression language to configure timing dynamically based on environment or services.
// 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
}All Ecotone's Messages comes with Headers (Metadata) to easily carry any additional details.
Ecotone automatically propagates metadata to next Messages. This works in synchronous and asynchronous scenarios.
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);
}All message headers automatically flow through Command Handlers, Event Handlers, and async processing.
Metadata preserved when messages go through queues - correlation IDs, user context always available.
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 processingAll Messages are automatically assigned identifiers: Message Id, Parent Id, Correlation Id.
Ecotone comes with OpenTelemetry standard support. Traces can be pushed to Jaeger, Zipkin, or DataDog.
Ecotone comes with centralized monitoring system for all failed Messages across Applications.
// 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 {}Commands route directly to Aggregates. Ecotone handles loading, method invocation, and persistence - eliminating application service boilerplate.
Aggregate command handlers can receive domain services via dependency injection. Keep complex domain logic where it belongs.
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;
}
}Aggregates can directly subscribe to events from other aggregates - no orchestrating service needed.
Ecotone loads the target aggregate using event correlation, updates it, and saves automatically.
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]);
}Use WithAggregateVersioning trait for automatic version tracking and concurrency detection.
Configure automatic retries on OptimisticLockingException to handle concurrent modifications.
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 is great way to handle long running processes, where each step can be persisted and resumed later.
Sagas can be bound to any Events published from Event Bus or Aggregates.
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
}Workflows are defined using Declarative Configuration, fully under your control within PHP.
All Message Handlers can easily be connected using input and output channels.
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();
}
}Define workflows independently from step implementation. Easy to understand the entire process.
Construct workflows programmatically based on business rules at runtime.
Same steps can be used in multiple workflows. Mix and match for different scenarios.
// 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;
}
}Ecotone comes with support for storing Events in databases like MySQL, PostgreSQL, MariaDB.
Event Store is integrated with Aggregates and Sagas, so we can easily store them as series of Events.
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;
}Track event versions with Revision attribute to handle schema changes over time.
Event handlers can check revision to apply different logic for legacy vs new events.
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
}Automatically create snapshots to avoid replaying thousands of events on every load.
Snapshots work automatically - no changes needed in your aggregate implementation.
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 {}Subscribe to events by routing key instead of class - modules don't need to share event classes.
Receive events as arrays, your own DTOs, or any type - Ecotone handles conversion.
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'
);
}
}Ecotone provides a way to project Events to read models, so we can quickly build different views for our application.
Projections can be executed synchronously for immediate consistency, or asynchronously to optimize for performance.
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;Projections can maintain state internally - no need to set up separate database tables.
State is automatically serialized/deserialized between executions - use arrays or classes.
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
}Emit events after projection updates - downstream handlers always see consistent read model state.
Perfect for sending notifications or triggering actions that depend on updated projections.
Create projections that calculate results, emit events, then delete themselves.
// 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'
);
}Ecotone handles automatic retries in synchronous and asynchronous scenarios, moving failures to Dead Letter.
When multiple Handlers subscribe to the same Event, Ecotone ensures processing isolation for each.
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']
),
];
}Pass Messages through Database Message Channel to ensure Messages are committed together with database changes (Outbox pattern).
In case of failure while sending, Ecotone retries and can store unrecoverable errors in Dead Letter.
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 sendConfirmationEach Event Handler processes in complete isolation. Only failed handlers are retried.
Ecotone delivers a copy of each Message to each Handler, solving isolation at the core.
Each handler can have different async channels, delays, or priorities independently.
// 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);
}Ecotone provides support for sending Commands and Events between different Applications.
Ecotone makes it clear what Messages are distributed, to make the System easier to follow and understand.
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',
],
);
}Application code remains agnostic to multi-tenancy. Ecotone handles tenant switching automatically.
Tenant context is automatically propagated through all Message flows, both sync and async.
Configure separate database connections, message channels, or any resource per tenant.
// 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 {}Use expressions to compute delays, TTL, deduplication keys dynamically based on message content.
Expressions can access payload properties, message headers, and any service from DI container.
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);
}Ecotone comes with Interceptors (more powerful Middlewares), which allow to hook into any Message flow.
Interceptors can hook into any Message Handler using namespace regex, class names or even targeting attributes.
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 itCommand, Query, and Event Buses act as ports to your application. Controllers, CLI, or external services all use the same entry points.
Message brokers, repositories, and event stores are configured declaratively. Swap RabbitMQ for Kafka or PostgreSQL for MongoDB without code changes.
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()
);
}Ecotone comes with Command Bus which allows to route Commands / Events by routing keys.
Ecotone can take over conversion, so our Application level code can simply pass data as it comes from outside world.
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 {}Conversion happens only when needed, at the point of method invocation.
Convert between JSON, XML, arrays, and PHP objects using Media Type Converters.
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(...));
}
}Define interfaces and let Ecotone provide implementations. No delegation code needed.
Call handlers directly without going through Command/Query Bus interceptors when needed.
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;
}Define SQL queries in interface methods. No repository implementation code needed.
Method parameters are automatically bound to SQL named parameters.
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);
}
}Store JSON, arrays, objects without knowledge about storage and serialization details.
Ecotone handles conversion to JSON for storage and back to objects when fetching.
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
),
];
}Single consumer can poll from multiple channels using round-robin or custom strategies.
Distribute messages to different queues based on headers like tenant ID.
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"Create commands without extending framework classes. Place them wherever makes sense.
Method parameters become command arguments. Options marked with ConsoleParameterOption.
Inject any service from Dependency Container directly into command method using Reference.
// Test your handlers in isolation
$result = EcotoneLite::bootstrapFlowTesting([
OrderService::class,
])
->sendCommand(new PlaceOrder($orderId, $productId))
->getRecordedEvents();
self::assertEquals(
[new OrderPlaced($orderId)],
$result
);Run in isolation whatever is needed for given test scenario. Simply add the Class name to bootstrap phase.
Asynchronous code can be triggered from within test, just like any other code.
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));All Ecotone Modules will provide sensible defaults and auto-configuration, so we can build things from day one.
It works out of the box with Symfony, Laravel or any other PHP Framework using Ecotone Lite.
Ecotone provides compressive documentation, code examples, articles, tutorials and workshops.
Haven’t found what you’re looking for? Contact us
Unleash the power of Messaging in PHP
and push productivity to the higher level
