Symfony Messenger: Writing Custom Bus Middleware for Cross-Cutting Concerns
AI generated
SF
{ }
Symfony · Messenger Component · CQRS
Symfony Messenger: Writing Custom Bus Middleware
Cross-cutting concerns like logging, metrics and transaction wrapping, without touching handler code

Once a project grows past a handful of message handlers, it quickly becomes clear that concerns like logging, execution time measurement, or wrapping work inside a database transaction shouldn't be duplicated inside every single handler. The Symfony Messenger bus solves exactly this problem with a middleware chain that every message passes through before and after reaching its handler, similar in spirit to an HTTP kernel event, but specific to the messaging layer. This article walks through implementing custom middleware, registering it at the right spot in the chain, and where the line to classic event listeners sits.

14 min read MiddlewareInterface Envelope Stamps · Logging

1. What middleware in the Messenger bus actually does

Every message dispatched through $bus->dispatch() doesn't go straight to its handler, but instead passes through a chain of middleware objects called one after another. Each middleware receives the current Envelope, which carries the message itself plus arbitrary metadata in the form of stamps, and a stack parameter through which it explicitly calls the next middleware in the chain. Only the very last middleware in the chain actually invokes the registered handler, meaning every middleware before it can run code both before and after the actual processing happens.

This pattern is deliberately modeled on HTTP middleware from other frameworks, because it solves the same structural problem: cross-cutting concerns that are relevant to practically every message should live in one central place instead of being repeated inside every handler. Symfony already ships a handful of built-in middleware, say for Doctrine transactions or error handling, and custom middleware slots seamlessly into the same chain.

2. Implementing MiddlewareInterface: the basic shape

Custom middleware implements MiddlewareInterface with exactly one method, handle(Envelope $envelope, StackInterface $stack): Envelope. Inside that method you decide what happens before calling the next middleware, then call $stack->next()->handle($envelope, $stack) to continue processing, and can still run code afterward that only executes once every downstream middleware and the handler have finished. If $stack->next()->handle() is never called, the entire processing chain stops right there, which is occasionally exactly what you want, say for deduplication.

The example below measures the execution time of every message and logs it in a structured way, including the message's class name. The try/finally block matters here: the timing should still get logged correctly even when the handler throws, since failed runs are often the most interesting case for performance analysis in the first place.


<?php

declare(strict_types=1);

namespace App\Messenger\Middleware;

use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;

final class ExecutionTimeMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
    }

    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        $messageClass = $envelope->getMessage()::class;
        $startedAt = microtime(true);

        try {
            return $stack->next()->handle($envelope, $stack);
        } finally {
            $durationMs = (int) round((microtime(true) - $startedAt) * 1000);

            $this->logger->info('Message processed', [
                'message' => $messageClass,
                'duration_ms' => $durationMs,
            ]);
        }
    }
}

3. Configuring the middleware chain order in Messenger

The middleware chain is configured per bus in messenger.yaml under the middleware key as an ordered list, where the order in the config exactly matches the order of execution. A middleware placed early in the list runs early before the handler and late afterward, while a middleware near the end of the list sits closest to the actual handler call. That ordering isn't a formality, it has direct functional consequences.

A logging middleware should typically sit very early in the chain, so it also captures errors triggered by middleware further down the line. A transaction middleware, on the other hand, should sit as late as possible, right before the handler, so the transaction wraps only the actual business-logic call and doesn't burn extra time on logging or metrics inside an open transaction. Symfony lets you place custom middleware, say App\Messenger\Middleware\ExecutionTimeMiddleware as a fully qualified class name, right between the built-in ones.

4. Middleware vs. event listeners: which one to use

The Messenger bus also fires events like WorkerMessageReceivedEvent or WorkerMessageHandledEvent in addition to running the middleware chain, which at first glance looks like an overlap. The key difference is control over the execution flow: middleware can actively pause, modify, or completely stop processing simply by skipping the call to the next element in the stack, whereas an event listener only reacts after something has already happened, without directly steering the actual flow.

Middleware is therefore the right tool for concerns that need to influence the processing flow itself, say a transaction that rolls back on failure, or deduplication that never even hands an already-processed message to its handler. Event listeners fit better for purely observational purposes that don't depend on success or failure of the processing, say updating a dashboard with the number of messages processed per minute, without touching the actual processing flow.

5. Practical example: execution time measurement as the basis for metrics

The ExecutionTimeMiddleware shown above can easily be extended to forward the measured duration not just to a log, but to a metrics system like Prometheus or StatsD, by feeding a histogram with the message class name as a label inside the finally block. That lets a dashboard answer questions directly like 'which message type takes the longest at the 95th percentile' or 'did the average processing time go up after the last deployment', without a single handler ever needing to know that metrics code exists.

For asynchronously processed messages routed through a transport like Doctrine or AMQP, it also matters whether the timing should include the wait time spent in the queue or only the raw processing duration at the worker. Since the middleware chain runs both at dispatch time and when the worker actually receives the message, it's worth storing the dispatch timestamp in a dedicated envelope stamp and reporting queue wait time separately from actual processing time.

6. Middleware for transaction wrapping

A common use case for custom middleware is wrapping handler execution in a database transaction, so that either every change made inside the handler gets persisted, or none of them do in case of failure. Symfony already ships DoctrineTransactionMiddleware, which covers exactly this behavior for the standard case, but for more complex scenarios with multiple entity managers or extra logic around commit and rollback, a custom variant is worth writing.

What matters with a custom transaction middleware is opening the transaction only right before $stack->next()->handle() and closing it inside a finally block or explicitly in the catch branch, so it stays open for as short a time as possible and doesn't accidentally wrap code from upstream middleware that has no idea it's happening. A transaction left open too long meaningfully increases the risk of deadlocks under concurrent workers, especially at high message throughput.

7. Middleware for structured logging

Beyond pure timing, middleware is a great fit for logging every processed message in a structured way, including relevant metadata like the message ID, the transport name pulled from a TransportMessageIdStamp, and, on failure, the full exception chain. This central spot ensures every message gets logged consistently regardless of which developer wrote its handler, instead of every handler bringing its own, slightly different logging format.

A common mistake with logging middleware is logging sensitive data like passwords or payment details unfiltered, just because it happens to be part of the message object. It's worth defining an explicit allowlist of fields permitted in the log context, or having messages that carry sensitive data implement their own interface requiring a toLogContext() method, which keeps control over what gets logged with the message's own author.

8. Envelope stamps as a communication channel between middleware

Since several middleware components work on the same envelope one after another, they need a way to exchange information with each other without abusing global variables or request attributes. That's exactly what stamps are for: small, immutable value objects attached to the envelope via $envelope->with(new MyStamp(...)) and read back by any downstream middleware via $envelope->last(MyStamp::class).

A practical example is a custom DispatchedAtStamp that records the current timestamp at initial dispatch, so a later middleware, at actual processing time, can compute the pure queue wait time by taking the difference against the current timestamp. Since envelopes are immutable, with() always returns a new instance that has to be consistently passed along, and a common beginner mistake is discarding the result of with() and continuing to work with the old envelope instead.

9. Testing middleware without a real transport

Middleware can be tested in isolation by instantiating it directly and calling it with a hand-built envelope plus a test double for StackInterface that either simulates a simple success handler or throws an exception. That lets a PHPUnit test check whether the middleware logs correctly on a successful run, whether it re-throws an exception unchanged instead of swallowing it, and whether it returns the envelope with exactly the stamps expected.

For integration tests that need to exercise the entire chain, including custom middleware, the InMemoryTransport is a good fit, since it doesn't actually send messages to an external broker but keeps them locally in memory instead. That lets you run the full dispatch process, including every registered middleware, end to end, without needing a real message queue infrastructure like RabbitMQ or Redis for the test.

Building block Purpose Control over flow Typical example
Custom middleware Cross-cutting concern for every message Can pause or stop processing Timing, transaction wrapping
Built-in middleware Symfony's default behavior Same as custom middleware DoctrineTransactionMiddleware
Event listener Pure observation after processing No influence on the flow Dashboard metrics, notification
Envelope stamp Data exchange between middleware layers Not applicable, pure data carrier DispatchedAtStamp

Mironsoft

Symfony architecture, clean domain logic, and legacy modernization

Symfony applications that stay maintainable two years down the line?

We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.

Architecture Review

Checking bundle structure, dependency injection, and service abstractions for maintainability.

Legacy Modernization

Incrementally migrating outdated Symfony versions without a full rewrite.

Testing and Quality Assurance

Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.

10. Summary

Symfony Messenger Middleware: Key Takeaways

MiddlewareInterface

A single handle() method running code before and after passing to the stack.

Order matters

Logging early in the chain, transactions as late as possible, right before the handler.

Middleware vs listener

Middleware actively steers the flow, event listeners only observe passively.

Envelope stamps

Immutable value objects for exchanging data between middleware layers.

11. FAQ: Symfony Messenger Middleware: Key Takeaways

1How do I register custom middleware in Messenger?
Through the fully qualified class name in the middleware list under the relevant bus in messenger.yaml, placed at the position matching the desired execution order.
2What happens if I forget to call stack->next()->handle()?
The processing chain stops right there, the handler is never invoked, and every downstream middleware is skipped too. That can be intentional for cases like deduplication, but is usually a bug otherwise.
3Where should a logging middleware sit in the chain?
As early as possible, so it also captures errors triggered by middleware further down the line, and so its timing covers the entire processing including downstream middleware.
4Where should a transaction middleware sit in the chain?
As late as possible, right before the actual handler call, so the transaction stays open for as short a time as possible and doesn't unnecessarily wrap code from upstream middleware.
5When should I use middleware instead of an event listener?
Whenever the processing flow itself needs to be influenced, say by stopping it, rolling back a transaction, or deduplicating. For purely observational purposes with no influence on the flow, an event listener is enough.
6How do I share data between several middleware components?
Through envelope stamps, small immutable value objects attached to the envelope via with() and read back by downstream middleware via last().
7Why does with() on the envelope return a new instance?
Because envelopes are immutable. The result of with() must be consistently passed along, otherwise you end up accidentally working with the old envelope that's missing the new stamp.
8How do I test middleware without a real message broker?
For isolated unit tests, a test double for StackInterface is enough. For integration tests of the full chain, InMemoryTransport works well, since it keeps messages locally in memory instead of sending them to an external broker.
9Can middleware catch and suppress an exception from the handler?
Technically yes, but in most cases that's an antipattern, since errors can then disappear unnoticed. It's better to log the exception in the finally block and then re-throw it unchanged.
10Does the middleware chain also affect asynchronously processed messages?
Yes, the chain runs both at initial dispatch and when the worker actually receives the message, which is why a stamp like DispatchedAtStamp lets you separate queue wait time from pure processing time.