Your free-tier customer signs up in seconds; your enterprise customer asks for a dedicated database and a queue nobody else can clog. Order → payment → fulfillment → notification has to survive a crash, time out cleanly when a payment never confirms, and refund automatically when shipping falls through.
Composer package · Laravel, Symfony, or Tempest · PostgreSQL or MySQL · RabbitMQ, Kafka, SQS, Redis, or DBAL outbox
A pure shared-database model hits noisy-neighbor problems and audit/encryption scope explosion at scale. A pure dedicated-database model is expensive and ops-heavy for the long tail. The real production model is mixed: standard plan on a shared database, premium plan on a dedicated database — and the application code must not branch on tenant plan.
// MultiTenantConfiguration.php — default + per-tenant overrides
use Ecotone\Dbal\MultiTenantConnection\Configuration\MultiTenantConfiguration;
use Ecotone\Messaging\Attribute\ServiceContext;
final class TenantConfiguration
{
#[ServiceContext]
public function tenants(): MultiTenantConfiguration
{
return MultiTenantConfiguration::createWithDefaultConnection(
tenantHeaderName: 'tenant',
tenantToConnectionMapping: [
'big_corp' => 'big_corp_connection',
'enterprise_x' => 'enterprise_x_connection',
],
// every other tenant lands on the shared default
defaultConnectionReferenceName: 'shared_tenants_connection',
);
}
}// PlaceOrderController.php — the tenant arrives on the message metadata
final class PlaceOrderController
{
public function __construct(private CommandBus $commands) {}
public function __invoke(PlaceOrder $command, string $tenant): Response
{
// The 'tenant' header drives every downstream decision:
// which database connection, which channel, which workers.
$this->commands->send($command, metadata: ['tenant' => $tenant]);
return new Response('', 202);
}
}// PlaceOrderHandler.php — handler is tenant-agnostic
#[Asynchronous('orders')]
#[CommandHandler]
public function placeOrder(PlaceOrder $cmd): void
{
// Ecotone resolved the database connection from the 'tenant' header
// before this body runs — no plan check, no branching.
}A single shared queue makes free-tier and premium-tier customers compete for the same consumer pool. Latency for premium tenants becomes a function of free-tier load — which is the opposite of the value proposition. The fix is to route premium tenants to their own channel and scale workers per channel.
// QueueTopology.php — dynamic channel selects per-tenant routing at runtime
// (Ecotone Enterprise — Dynamic Message Channels)
use Ecotone\Messaging\Channel\DynamicChannel\DynamicMessageChannelBuilder;
use Ecotone\Dbal\DbalBackedMessageChannelBuilder;
use Ecotone\Amqp\AmqpBackedMessageChannelBuilder;
#[ServiceContext]
public function queueTopology(): array
{
return [
DbalBackedMessageChannelBuilder::create('orders_shared'),
AmqpBackedMessageChannelBuilder::create('orders_premium'),
DynamicMessageChannelBuilder::createWithHeaderBasedStrategy(
thisMessageChannelName: 'orders', // handler code stays #[Asynchronous('orders')]
headerName: 'tenant',
headerMapping: [
'big_corp' => 'orders_premium',
'enterprise_x' => 'orders_premium',
// every other tenant falls through to the default
],
defaultChannelName: 'orders_shared',
),
];
}A four-step process orchestrated by manual chained dispatches loses state on a crash and has no compensation primitive — every team eventually writes ad-hoc state tables and timers, and gets the recovery wrong on the second incident. A #[Saga] makes the coordinator declarative, persists its state by identifier, and uses #[Delayed] for timeouts without cron.
// OrderSaga.php — stateful coordinator, persisted by orderId
use Ecotone\Modelling\Attribute\Saga;
use Ecotone\Modelling\Attribute\EventHandler;
use Ecotone\Modelling\Attribute\Identifier;
use Ecotone\Messaging\Scheduling\Delayed;
#[Saga]
final class OrderSaga
{
#[Identifier] public string $orderId;
private OrderStage $stage = OrderStage::PlacedAwaitingPayment;
#[EventHandler]
public static function start(OrderPlaced $e): self
{
$self = new self();
$self->orderId = $e->orderId;
return $self;
}
#[EventHandler]
public function onPaymentCaptured(PaymentCaptured $e, CommandBus $bus): void
{
$this->stage = OrderStage::PaidAwaitingFulfillment;
$bus->send(new FulfillOrder($this->orderId));
}
// Cron-free 30-minute timeout for payment
#[Delayed(thirtyMinutes: 'PT30M')]
#[EventHandler]
public function paymentTimeout(OrderPlaced $e, CommandBus $bus): void
{
if ($this->stage === OrderStage::PlacedAwaitingPayment) {
$bus->send(new CancelOrder($this->orderId, reason: 'Payment timed out'));
}
}
#[EventHandler]
public function onFulfillmentFailed(FulfillmentFailed $e, CommandBus $bus): void
{
// Compensation: refund the captured payment
$bus->send(new RefundPayment($this->orderId, $e->reason));
$this->stage = OrderStage::CompensatedRefund;
}
}Tenant-isolated event streams, tenant-routed message channels, priority routing by customer status. Multi-tenancy as a topology property, not a WHERE clause.
Stateful long-running processes with compensation. Handler-chaining workflows for stateless pipelines. Saga timeouts in one #[Delayed] attribute. All declarative, all attribute-driven.
RabbitMQ, Kafka, SQS, Redis, DBAL outbox. Move handlers to async with one attribute. CombinedMessageChannel writes the message in the same DBAL transaction as the business state, then forwards it to the broker for consumers to handle — an atomic outbox, so a broker outage can't lose it. Delivery is at-least-once, and built-in deduplication makes redelivery safe. Transports swap without code changes.
A copy of the message is dispatched to every handler. Each retries independently, fails independently — no shared envelope, no sibling re-runs. One failing subscriber doesn't abort the others.
First-party packages for RabbitMQ, Kafka, SQS, Redis, and DBAL. Ecotone fits your existing stack — no new cluster to operate, no migration of your message infrastructure.
Haven’t found what you’re looking for? Contact us
Unleash the power of Messaging in PHP
and push productivity to the higher level
