In every web application, there are tasks that do not need to be completed immediately: sending emails, generating PDFs, API synchronization. The Symfony Messenger component offers an elegant solution -- but the devil is in the production setup. In this article, I show the complete setup that I use on my own server.
The Concept: Messages and Handlers
Messenger is based on a simple principle: You create a message class (a plain DTO) and a corresponding handler.
<?php
declare(strict_types=1);
namespace App\Message;
class SendNotificationMessage
{
public function __construct(
private readonly int $userId,
private readonly string $subject,
private readonly string $content,
) {
}
public function getUserId(): int
{
return $this->userId;
}
public function getSubject(): string
{
return $this->subject;
}
public function getContent(): string
{
return $this->content;
}
}
The handler processes the message:
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Message\SendNotificationMessage;
use App\Service\MailerService;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class SendNotificationHandler
{
public function __construct(
private readonly MailerService $mailer,
) {
}
public function __invoke(SendNotificationMessage $message): void
{
$this->mailer->sendNotification(
$message->getUserId(),
$message->getSubject(),
$message->getContent()
);
}
}
RabbitMQ as Transport
For production use, I recommend RabbitMQ over the Doctrine-based transport variant. Reasons:
- No database locks: Doctrine transport burdens the main database with polling queries
- Real queuing: RabbitMQ uses AMQP blocking consume -- the consumer waits without CPU load
- Scaling: Multiple consumers possible in parallel with automatic load distribution
# config/packages/messenger.yaml
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 60000
routing:
App\Message\SendNotificationMessage: async
App\Message\GeneratePdfMessage: async
In the .env (production):
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
The systemd Service: Automatic, Robust, Monitored
The most important part for production operation: A systemd service ensures that the Messenger consumer starts automatically, restarts on errors, and shuts down cleanly.
Here is the service I use on my openSUSE server:
# /etc/systemd/system/app-messenger.service
[Unit]
Description=App Messenger Consumer
After=network.target nginx.service rabbitmq-server.service
Wants=nginx.service
Requires=rabbitmq-server.service
[Service]
Type=simple
User=nginx
Group=nginx
WorkingDirectory=/srv/www/vhosts/example.de/httpdocs
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=7200 --memory-limit=256M
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
Explanation of the Key Parameters
--time-limit=7200: The consumer restarts every 2 hours. Why? PHP processes can develop memory leaks over long runtimes -- especially with the Doctrine EntityManager, which keeps references to all loaded entities in the UnitOfWork. Through regular restarts, memory is cleanly released.
--memory-limit=256M: A safety net -- if a single message handler consumes unexpectedly large amounts of memory, the consumer stops gracefully instead of endangering the server.
Restart=always + RestartSec=5: After --time-limit or --memory-limit, the consumer exits with exit code 0. systemd restarts it after 5 seconds immediately. Effectively, there is no downtime.
KillSignal=SIGTERM + TimeoutStopSec=30: On systemctl stop, systemd sends SIGTERM. The Messenger consumer catches this signal and cleanly finishes the current message before stopping. After 30 seconds, SIGKILL is sent -- only as a last resort.
User=nginx: Same user as PHP-FPM. This prevents file permission issues with cache files or logs.
Requires=rabbitmq-server.service: The consumer only starts when RabbitMQ is running. Without this line, connection errors would occur in the first seconds after booting.
Installation and Management
sudo systemctl daemon-reload
sudo systemctl enable app-messenger
sudo systemctl start app-messenger
# Logs in real-time
sudo journalctl -u app-messenger -f
# Check status
sudo systemctl status app-messenger
Error Handling and Failure Queue
A major advantage of Messenger is the built-in error handling. Failed messages end up in the failure queue after the configured retry attempts:
framework:
messenger:
failure_transport: failed
transports:
failed:
dsn: 'doctrine://default?queue_name=failed'
# Show failed messages
php bin/console messenger:failed:show
# Details of a specific message
php bin/console messenger:failed:show 42
# Retry
php bin/console messenger:failed:retry 42
# Retry all
php bin/console messenger:failed:retry --force
Preload Headers Instead of HTTP/2 Server Push
HTTP/2 server push used to be a way to proactively send CSS and JavaScript files to the browser. For new projects, do not plan around it anymore: Chrome removed support, HTTP/3 did not carry the feature forward, and current nginx versions no longer continue the old push configuration. What remains useful is the Link: rel=preload header: it tells the browser early which resources matter.
The old nginx configuration looked like this and is only relevant as legacy context:
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
http2_push_preload on;
}
In current setups, set Link: <...>; rel=preload HTTP headers directly instead. In Symfony, you can set these headers via the WebLink component or directly on the response:
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\WebLink\GenericLinkProvider;
use Symfony\Component\WebLink\Link;
class PreloadSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => 'onResponse'];
}
public function onResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$response = $event->getResponse();
$linkProvider = new GenericLinkProvider();
$linkProvider = $linkProvider
->withLink(new Link('preload', '/assets/css/all.css', ['as' => 'style']))
->withLink(new Link('preload', '/assets/js/app.js', ['as' => 'script']));
$response->headers->set(
'Link',
'<' . '/assets/css/all.css' . '>; rel=preload; as=style, '
. '<' . '/assets/js/app.js' . '>; rel=preload; as=script'
);
}
}
Note: 103 Early Hints can send preload headers to the browser even earlier. The Link: rel=preload header remains useful even without Early Hints, as the browser will then prioritize loading those resources.
Stamps and Middleware
Messenger can be extended via middleware. For example, you can add logging before a message is processed:
<?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;
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
class LoggingMiddleware implements MiddlewareInterface
{
public function __construct(private readonly LoggerInterface $logger)
{
}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
if ($envelope->last(ReceivedStamp::class) !== null) {
$this->logger->info('Processing message: {class}', [
'class' => get_class($envelope->getMessage()),
]);
}
return $stack->next()->handle($envelope, $stack);
}
}
Symfony Messenger has proven indispensable in my projects. The clean separation of synchronous and asynchronous logic makes applications more robust and scalable -- and with a properly configured systemd service, the whole thing runs reliably in production.
Comments
Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.