Event Driven Microservices with Symfony Messenger
AI generated
SF
{ }
Symfony · Messenger · Microservices · Sagas
Event Driven Microservices with Symfony Messenger
Choreography instead of synchronous coupling between services

Event driven microservices replace synchronous REST calls between services with asynchronous integration events distributed through Symfony Messenger onto a shared broker. This article shows how the transactional outbox pattern solves the dual write problem, how consumers stay idempotent, how event schemas can be versioned, and when a saga orchestrator becomes necessary for distributed business processes.

21 min read Outbox Pattern · Idempotency · Saga · Choreography Symfony 7.x · PHP 8.3+

1. Why synchronous calls between microservices hit their limits

Event driven microservices solve a problem that almost every Symfony microservice landscape eventually runs into: an order service synchronously calls the inventory service over HTTP, which in turn calls the pricing service, which in turn queries an external tax API. If a single service in this chain fails, the whole request fails, even though the original order service itself works correctly. This kind of coupling through synchronous call chains is called cascading failure, and it is one of the most common reasons microservice architectures turn out less robust in practice than promised.

Moving to event driven microservices reverses the dependency direction. Instead of a service actively calling another and waiting for its response, it publishes an event through Symfony Messenger onto a shared broker, and interested services consume that event independently, at their own pace. The order service does not need to know which or how many other services react to an OrderPlacedEvent. This decoupling is the central benefit of event driven microservices, but it introduces new challenges that this article addresses step by step.

2. Separating domain events from integration events

A common mistake when building event driven microservices is publishing an aggregate's internal domain event directly and unchanged onto the broker. That couples the internal model of a service to every consuming service, since any field rename in the aggregate would immediately affect other teams. The more robust solution separates an internal domain event that lives only inside the owning Symfony kernel from a derived integration event that is explicitly designed for external consumption and has its own stable structure.

This separation lets a team freely evolve its internal domain model as long as the translation into the integration event stays stable. In Symfony based event driven microservices, a dedicated event mapper listens for the internal Doctrine lifecycle event or a custom domain event and builds the public integration event with an explicit version number from it, before it is even handed off to Messenger.


<?php

declare(strict_types=1);

namespace App\Order\Application\EventMapping;

use App\Order\Domain\Event\OrderWasPlaced;
use App\Order\Integration\Event\OrderPlacedIntegrationEvent;
use Symfony\Component\Messenger\MessageBusInterface;

// Translates an internal domain event into a stable, versioned
// integration event before it reaches Messenger's async transport.
final readonly class OrderIntegrationEventMapper
{
    public function __construct(
        private MessageBusInterface $eventBus,
    ) {}

    public function onOrderWasPlaced(OrderWasPlaced $domainEvent): void
    {
        $integrationEvent = new OrderPlacedIntegrationEvent(
            eventVersion: '1.1',
            orderId: $domainEvent->orderId->value,
            customerId: $domainEvent->customerId->value,
            totalAmountInCents: $domainEvent->totalAmount->cents,
            currency: $domainEvent->totalAmount->currency,
            occurredAt: $domainEvent->occurredAt,
        );

        $this->eventBus->dispatch($integrationEvent);
    }
}

3. Symfony Messenger as an integration layer between services

For event driven microservices to actually communicate across service boundaries, the Messenger transport must point to a broker that several independent Symfony applications can share, typically RabbitMQ with a topic exchange or Kafka. Every producing service configures a sender for its integration events, and every consuming service subscribes to the routing keys relevant to it through its own independent queue. This per consumer queue is essential: if a consuming service is down for an hour, its messages accumulate in its own queue without affecting other consumers.

The configuration in messenger.yaml barely differs from a single service configuration, the crucial difference lies in deployment: each service is its own codebase, its own deployment, yet all of them share the same exchange name as a contract. If a team renames the exchange without coordination, every consuming service breaks at once, which is why the exchange name in event driven microservices belongs under the same governance as the event schema itself.


# config/packages/messenger.yaml
framework:
  messenger:
    transports:
      order_events_publish:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: 'order.events'
            type: 'topic'
          delivery_persistent: true

      order_events_consume:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: 'order.events'
            type: 'topic'
          queues:
            inventory_service.order_events:
              binding_keys: ['order.placed', 'order.cancelled']

    routing:
      'App\Order\Integration\Event\OrderPlacedIntegrationEvent': order_events_publish

4. Transactional outbox pattern against the dual write problem

As soon as a service wants to save a database change and write an integration event onto the broker within the same transaction, the so called dual write problem appears: the database commit and the broker publish are two separate systems that cannot be joined in a single atomic transaction. If the broker publish fails after a successful database commit, the event is lost forever even though the change was already saved. This exact risk makes event driven microservices without safeguards more fragile than synchronous calls.

The transactional outbox pattern solves this by first storing the integration event in a dedicated outbox table within the same database transaction as the business change. A separate worker process periodically reads unprocessed rows from the outbox table, publishes them through Messenger onto the broker and then marks them as processed. Because both writes, the business change and the outbox entry, sit in the same transaction, there is no longer a state where the database change exists but the event is missing.


<?php

declare(strict_types=1);

namespace App\Order\Infrastructure\Outbox;

use Doctrine\DBAL\Connection;

// Writes the domain change and the outbox row inside one DB transaction,
// eliminating the dual write problem between database and broker.
final readonly class OutboxAwareOrderRepository
{
    public function __construct(
        private Connection $connection,
    ) {}

    public function placeOrderAndRecordEvent(string $orderId, string $payloadJson): void
    {
        $this->connection->transactional(function () use ($orderId, $payloadJson): void {
            $this->connection->executeStatement(
                'UPDATE orders SET status = :status WHERE id = :id',
                ['status' => 'placed', 'id' => $orderId]
            );

            $this->connection->executeStatement(
                'INSERT INTO outbox (id, event_type, payload, created_at, processed_at)
                 VALUES (:id, :type, :payload, NOW(), NULL)',
                ['id' => bin2hex(random_bytes(16)), 'type' => 'order.placed', 'payload' => $payloadJson]
            );
        });
    }
}

5. Choreography: services react independently to events

In event driven microservices built with choreography, no single service knows the full sequence of a business process. The order service publishes an OrderPlacedIntegrationEvent without knowing that the inventory service reserves stock in response, the notification service sends a confirmation email, and the analytics service updates a report. Every consumer independently decides how it reacts to the event. This pattern fits well for simple, largely independent reactions where no service needs to wait for another's result.

The downside of choreography shows up once a business process has several sequential steps with dependencies, for example checking payment first, then reserving stock, then triggering shipping. With pure choreography, this sequencing logic spreads implicitly across many event handlers in different services, which makes the overall process hard to follow. For such multi step processes, the saga approach from section eight is the more robust choice, while choreography remains the right solution for simple, parallel reactions in event driven microservices.

6. Idempotent consumers for redelivered events

Message brokers in practice mostly guarantee at least once delivery, not exactly once. A network glitch between consumer and broker, a timeout on acknowledgement, or a worker restart can cause the same integration event to arrive twice at the same consumer. A consumer in event driven microservices that does not catch this repetition may process the same order twice and deduct stock twice or send the same email twice.

The solution is an idempotent consumer that logs every processed event by a unique event id in its own table before the actual business processing runs. If the same event id arrives a second time, the consumer recognizes the already existing row and skips the processing, but still acknowledges the message as successfully handled to the broker. This safeguard in event driven microservices is not optional, it is a baseline requirement for any consumer that triggers side effects such as payments, stock bookings, or notifications.


<?php

declare(strict_types=1);

namespace App\Inventory\Application\Handler;

use App\Order\Integration\Event\OrderPlacedIntegrationEvent;
use App\Inventory\Infrastructure\ProcessedEventLog;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

// Idempotent consumer — skips reprocessing when the same event
// arrives twice due to at-least-once delivery guarantees.
#[AsMessageHandler]
final readonly class ReserveInventoryOnOrderPlaced
{
    public function __construct(
        private ProcessedEventLog $processedEvents,
    ) {}

    public function __invoke(OrderPlacedIntegrationEvent $event): void
    {
        $eventId = $event->orderId . ':' . $event->eventVersion;

        if ($this->processedEvents->wasAlreadyProcessed($eventId)) {
            return; // already handled, acknowledge without side effects
        }

        // ... reserve stock for the order here ...

        $this->processedEvents->markAsProcessed($eventId);
    }
}

7. Event schema versioning without breaking consumers

In a growing landscape of event driven microservices, the structure of an integration event sooner or later changes, for example because a new mandatory field is added or an existing field needs to be renamed. Because producing and consuming services are deployed independently, it cannot be assumed that every consumer understands the new version at the same time. Additive changes such as a new optional field are uncritical as long as consumers ignore unknown fields. Removed or renamed fields, on the other hand, are breaking changes and break every consumer that relies on the old field.

A proven pattern for event driven microservices is to carry the event version explicitly in the payload and, for breaking changes, publish both versions in parallel until every known consumer has migrated to the new version. An upcaster inside the consumer can automatically transform older event versions into the current internal structure, so the actual handler logic only ever needs to know a single, current structure, regardless of which version of the event actually arrived on the broker.

8. Sagas: distributed business processes across services

For multi step business processes that involve several services in a specific order, event driven microservices benefit from a saga orchestrator, implementable with the Symfony Workflow component combined with Messenger. The orchestrator holds the state of a single order process, publishes commands to the respective next services, and reacts to their response events to trigger the next step. Unlike choreography, the entire flow is visible and traceable in one place in the code, instead of spreading implicitly across many distributed handlers.

The crucial difference from a classic database transaction: a saga in event driven microservices cannot simply roll back when a later step fails. Instead, every participating service defines a compensating action, for example releasing a stock reservation again if the subsequent payment fails. On failure, the saga orchestrator calls the compensating actions of the already completed steps in reverse order to bring about a consistent end state across all involved services.


<?php

declare(strict_types=1);

namespace App\OrderSaga\Application;

use App\OrderSaga\Integration\Command\ReserveInventoryCommand;
use App\OrderSaga\Integration\Command\ReleaseInventoryCommand;
use App\OrderSaga\Integration\Event\InventoryReservationFailed;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;

// Saga orchestrator step — reacts to a failed step and issues
// compensating commands to already completed steps of the process.
#[AsMessageHandler]
final readonly class CompensateOnInventoryFailure
{
    public function __construct(
        private MessageBusInterface $commandBus,
        private SagaStateRepository $sagaState,
    ) {}

    public function __invoke(InventoryReservationFailed $event): void
    {
        $saga = $this->sagaState->findByOrderId($event->orderId);

        if ($saga->hasCompletedStep('payment_reserved')) {
            $this->commandBus->dispatch(new ReleaseInventoryCommand($event->orderId));
        }

        $saga->markAsFailed('inventory_reservation');
        $this->sagaState->save($saga);
    }
}

9. Event driven microservices compared to synchronous calls

Neither event driven microservices nor synchronous REST calls are the universal solution. The table below compares both approaches for typical decision situations.

Criterion Synchronous REST call Event driven microservices
Coupling High, caller knows the target service directly Low, producer does not know consumers
Behavior on outage Cascading failure along the chain Messages wait in the queue
Response time Immediate result for the caller Eventual consistency, no immediate answer
Traceability of the flow Directly visible in the code Needs tracing or a saga orchestrator
Best fit for Requests needing an immediate answer Decoupled reactions and notifications

In practice, most Symfony microservice landscapes combine both approaches: synchronous calls for requests that need an immediate answer, such as a price calculation during checkout, and event driven microservices for everything that follows and does not require an immediate response, such as stock reservation, notifications, and reporting.

Mironsoft

Symfony microservices, Messenger integrations and distributed processes

Are your services still communicating synchronously and fragile?

We build a Symfony Messenger integration layer with the outbox pattern, idempotent consumers and saga orchestration, so your services stay decoupled and individual outages do not cascade.

Outbox introduction

Implementing a transactional outbox against dual write problems

Idempotency audit

Hardening existing consumers against redelivered events

Saga design

Modeling distributed business processes with compensating actions

10. Summary

Event driven microservices with Symfony Messenger solve the cascading failures of synchronous call chains by letting services communicate through integration events instead of direct calls. The clean separation of internal domain events and public integration events, the transactional outbox pattern against the dual write problem, idempotent consumers against duplicate delivery, and explicit event versioning together form the technical foundation without which such an architecture becomes unreliable in production.

For simple, independent reactions, choreography remains the right pattern in event driven microservices. Once a business process needs several steps with clear dependencies and compensation logic, a saga orchestrator is the more robust choice, because it keeps the flow visible in one place instead of spreading implicitly across many distributed handlers. Combining these building blocks consistently results in a microservice landscape where individual outages stay local instead of propagating through the entire call chain.

Event Driven Microservices with Symfony Messenger — Key takeaways

Event separation

Internal domain events stay inside the service, public integration events are stable and versioned.

Outbox pattern

Business change and outbox entry in one transaction, a worker then publishes reliably.

Idempotency

Every consumer logs processed event ids against redelivery from the broker.

Choreography vs. saga

Simple reactions through choreography, multi step processes with compensation through a saga orchestrator.

11. FAQ: Event Driven Microservices with Symfony Messenger

1Difference from synchronous REST calls?
Synchronous calls know the target directly and wait. Event driven microservices publish decoupled, without knowing consumers.
2Why not publish the domain event directly?
That couples consumers to the internal model. A separate integration event stays stable even as the domain model changes.
3What is the dual write problem?
DB commit and broker publish run without a shared transaction, a failed publish makes the event disappear.
4How does outbox solve the dual write problem?
Change and outbox entry in one transaction, a worker then reliably publishes from the outbox table.
5Why must consumers be idempotent?
At least once delivery can deliver events twice. Without idempotency, duplicate side effects such as double bookings occur.
6How do you version events safely?
Additive fields are uncritical. For breaking changes, run both versions in parallel and translate in consumers via an upcaster.
7Choreography or saga orchestrator?
Choreography for simple independent reactions, saga orchestrator for multi step processes with compensation logic.
8What is a compensating action?
It undoes an already successful step when a later step of the saga fails.
9Which broker fits best?
Mostly RabbitMQ with a topic exchange, alternatively Kafka. The exchange name is a shared contract between all services.
10Does everything need to become asynchronous?
No, immediately needed responses stay synchronous, event driven microservices complement that for decoupled follow up reactions.