Multi-Tenant E-commerce on Ecotone

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

In production

Challenges and how Ecotone solves them

"Standard tenants share a database; premium tenants get a dedicated one"

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
// 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
// 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
// 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 new standard tenant signs upNo mapping entry needed; the tenant lands on shared_tenants_connection automatically.
A standard tenant upgrades to premium and gets a dedicated databaseAdd 'tenant_id' => 'tenant_id_connection' to the mapping; redeploy. No handler change. Existing data migrates on the schedule the operator decides.
Tenant id propagates through eventsBuilt-in propagation forwards the tenant header from the inbound command to every event emitted by the handler, so downstream subscribers run against the correct database.

"Free-tier tenant fired 10 000 messages and blocked our premium customers' queue"

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
// 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',
        ),
    ];
}
Free-tier traffic surgesOnly orders_shared backs up; orders_premium is unaffected.
Operations scale premium workers independentlybin/console ecotone:run orders_premium -vvv with a higher replica count; orders_shared runs with a lower replica count.
A new premium customer comes onlineAdd the tenant to headerMapping, redeploy. Handler code unchanged.
Blue-green rollout of a new premium tierIntroduce a third channel and shift the mapping; messages in flight on the old channel drain naturally.

"Order → payment → fulfillment → notification must survive crashes and compensate"

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
// 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;
    }
}
Worker crashes between PaymentCaptured and FulfillOrderThe broker re-delivers the event; the saga loads from the database by orderId; the handler runs again; built-in deduplication on the message id tolerates the duplicate delivery; the outbox channel ensures the saga state and the outbound command commit together.
Payment provider never confirms#[Delayed] fires the cancellation 30 minutes after OrderPlaced; no cron, no separate timer service. The saga's own stage check prevents cancelling an order that has since been paid.
Fulfillment provider returns a permanent errorFulfillmentFailed triggers the compensating RefundPayment command; the saga state advances to CompensatedRefund.

Frequently asked questions

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

Yes. Add the tenant to the mapping pointing at the new dedicated connection, run a one-time data migration, and the application picks up the new mapping on next message. The default connection remains the fallback for unmapped tenants.
No. One deployment runs every tenant — the database connection and the message channel are selected at runtime from the tenant header. Per-tenant workers (e.g. premium-only workers) are a process-scaling decision, not a deployment-topology decision.
Configure the saga's outbound channel as a CombinedMessageChannel: the command is written to the outbox in the same transaction as the saga's state. The poller drains to the broker when it recovers.
A priority queue still has one consumer pool — a backed-up high-priority stream still consumes the worker capacity. Dedicated channels (Dynamic Message Channels) give the premium tenant its own consumer pool, which is what protects latency under load.

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