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.
Table of Contents
- 1. What middleware in the Messenger bus actually does
- 2. Implementing MiddlewareInterface: the basic shape
- 3. Configuring the middleware chain order in Messenger
- 4. Middleware vs. event listeners: which one to use
- 5. Practical example: execution time measurement as the basis for metrics
- 6. Middleware for transaction wrapping
- 7. Middleware for structured logging
- 8. Envelope stamps as a communication channel between middleware
- 9. Testing middleware without a real transport
- 10. Summary
- 11. FAQ
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.