Two-Sided Marketplaces on Ecotone

Buyers and sellers run on different clocks, different SLAs, and different incident channels — but they share one event backbone. A slow B2B partner integration can never pull the order pipeline down with it, and a matched lead has to reach the right provider before it expires.

Composer package · Laravel or Symfony · PostgreSQL or MySQL · RabbitMQ, Kafka, SQS, Redis, or DBAL outbox

In production

Challenges and how Ecotone solves them

"Customer orders should fan out to multiple provider-side consumers without one slow consumer breaking the others"

Many marketplace events fan out: a customer's OrderPlaced reaches the matching subsystem, the analytics pipeline, the email notifier, and the partner-feed exporter. A single-envelope dispatch model (one envelope, many handlers) lets a single failing handler take down the dispatch, and worse — on retry, every sibling handler re-runs. Ecotone's per-handler isolation puts a copy of the message in front of every subscriber so each retries (and fails) independently.

OrderPlacedSubscribers.php
// Three independent subscribers to OrderPlaced — each on its own channel,
// each fails and retries independently of the others.

namespace App\Customer\Notification;

final class CustomerNotificationHandler
{
    #[Asynchronous('customer_notifications')]
    #[EventHandler]
    public function emailReceipt(OrderPlaced $e): void { /* ... */ }
}

namespace App\Provider\Matching;

final class ProviderMatchingHandler
{
    #[Distributed]
    #[Asynchronous('provider_matching')]
    #[EventHandler]
    public function startMatching(OrderPlaced $e, CommandBus $bus): void
    {
        $bus->send(new StartLeadMatching($e->orderId, $e->items));
    }
}

namespace App\Analytics;

final class AnalyticsRecorder
{
    #[Asynchronous('analytics')]
    #[EventHandler]
    public function recordOrder(OrderPlaced $e): void { /* ... */ }
}
AnalyticsRecorder fails on a malformed metric writeOnly that handler retries on its own channel; CustomerNotificationHandler and ProviderMatchingHandler are unaffected.
ProviderMatchingHandler runs slow and back-pressuresIts channel backs up, but the other channels keep flowing.
A new subscriber (e.g. fraud detection) is addedDeclare a new handler with #[EventHandler] on the same event; no change to the producer; no change to the other subscribers.

"Lead distribution is a routing-slip workflow — match, notify candidates, accept the first that confirms, fall through if none does"

Lead distribution is a multi-step coordination problem with branching and timeouts. Chained handlers via outputChannelName work for linear flows; a saga works for state-bearing reactive coordination. The fit for lead distribution is an Orchestrator — declarative routing-slip semantics, each step a reusable #[InternalHandler], no saga state to persist beyond the routing slip.

LeadDistributionOrchestrator.php
// LeadDistributionOrchestrator.php — routing slip as a return value
// (Ecotone Enterprise — Orchestrators)

use Ecotone\Modelling\Attribute\Orchestrator;
use Ecotone\Modelling\Attribute\InternalHandler;

final class LeadDistributionOrchestrator
{
    #[Orchestrator(inputChannelName: 'lead.start')]
    public function plan(StartLeadMatching $cmd): array
    {
        return ['lead.match', 'lead.notifyCandidates', 'lead.awaitAcceptance'];
    }

    #[InternalHandler(inputChannelName: 'lead.match')]
    public function match(StartLeadMatching $cmd): MatchedCandidates { /* ... */ }

    #[InternalHandler(inputChannelName: 'lead.notifyCandidates')]
    public function notify(MatchedCandidates $c, CommandBus $bus): MatchedCandidates
    {
        foreach ($c->providerIds as $providerId) {
            $bus->send(new NotifyProviderOfLead($c->orderId, $providerId));
        }
        return $c;
    }

    #[InternalHandler(inputChannelName: 'lead.awaitAcceptance')]
    public function awaitAcceptance(MatchedCandidates $c): MatchedCandidates
    {
        // Hand off to a Saga for the timeout / acceptance reactive part
        return $c;
    }
}
Routing slip is dynamicThe plan method returns the channel list, so the steps can be chosen from input data (e.g. priority leads skip a step).
A step is reused across orchestrators#[InternalHandler]s are addressable by channel name and independently testable; the orchestration is the routing slip, not the handler implementations.
Acceptance side is reactiveThe awaitAcceptance step hands off to a #[Saga] waiting on ProviderAcceptedLead; if no acceptance within the timeout, #[Delayed] fires the fallback (escalate, fan out wider, expire the lead).

"B2B partner integrations need outbound messages on a channel that retries and falls back without affecting the rest of the system"

Enterprise B2B partnerships want their own integration channel — sometimes a partner-specific webhook, sometimes a partner-specific message queue. Coupling that integration to the producer creates failure-leak: a partner outage stops the marketplace's internal flow. The fix is a dedicated outbound channel via MessagePublisher, with its own resilience policy.

PartnerPublishersConfiguration.php
// PartnerPublishersConfiguration.php — one named publisher per partner topic

use Ecotone\Kafka\Configuration\KafkaPublisherConfiguration;
use Ecotone\Messaging\Attribute\ServiceContext;

final class PartnerPublishersConfiguration
{
    #[ServiceContext]
    public function partnerPublishers(): array
    {
        return [
            // Each partner gets its own topic AND its own DI reference name.
            KafkaPublisherConfiguration::createWithDefaults(
                topicName:     'partner_x.lead.accepted.v1',
                referenceName: 'partner_x_publisher',
            ),
            KafkaPublisherConfiguration::createWithDefaults(
                topicName:     'partner_y.lead.accepted.v1',
                referenceName: 'partner_y_publisher',
            ),
        ];
    }
}
PartnerFeedHandler.php
// PartnerFeedHandler.php — inject by reference name; one handler per partner

use Ecotone\Messaging\MessagePublisher;
use Ecotone\Messaging\Attribute\Parameter\Reference;
use Ecotone\Modelling\Attribute\EventHandler;

final class PartnerFeedHandler
{
    #[EventHandler]
    public function publishToPartnerX(
        LeadAccepted $event,
        #[Reference('partner_x_publisher')] MessagePublisher $publisher,
    ): void {
        $publisher->convertAndSend($event);
    }
}
Partner X's Kafka cluster is unreachableFailed publishes route through the partner-specific error channel; the marketplace's internal flow is untouched. The buffered messages replay when the partner is back.
A new B2B partner is onboardedAdd a new KafkaPublisherConfiguration with a fresh referenceName; add the handler method that injects it. No existing partner code changes.
A partner integration is retiredDrop the configuration entry and the matching handler method. Nothing else depends on the publisher reference.
One partner's handler fails on a single messagePer-handler isolation: only that subscriber retries. Other partners' publishes on the same LeadAccepted event are unaffected.

Frequently asked questions

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

Sagas remember state across events arriving over time — use them where you don't know up-front when the next event arrives (provider acceptance, payment confirmation). Orchestrators define the steps up-front as a routing slip — use them where the flow is a sequence you control (match → notify → await).
They live as separate bounded contexts, communicating via well-defined domain events on the Distributed Bus. Translators on each side (#[Distributed] #[EventHandler]) explicitly map events between contexts — no shared model, no shared schema.
Yes. The Service Map carries the topology: customer services on RabbitMQ, provider services on Kafka, partner integrations on SQS — all coordinated through one map.
Endpoint-ID routing is the contract — the endpoint id is on the wire, not the FQCN. Class renames, namespace moves, and handler relocations do not change routing.

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