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
nginx HTTP/2 Server Push
An often overlooked feature: nginx can automatically use HTTP/2 server push in combination with PHP-FPM. This allows the server to proactively send CSS and JavaScript files to the browser -- before it can even request them.
The nginx configuration:
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
http2_push_preload on;
}
http2_push_preload on automatically evaluates Link: <...>; rel=preload HTTP headers sent by PHP. In Symfony, you can set these headers via the WebLink component:
<?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: HTTP/2 Push was not carried over to HTTP/3 (QUIC), and Chrome has removed support since version 106. For new projects, I recommend 103 Early Hints instead -- the concept is similar but is supported by all modern browsers. The Link: rel=preload header remains useful regardless, 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.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.