Zum Inhalt springen

Symfony Messenger in Production: systemd Service, RabbitMQ and nginx HTTP/2 Push

Veröffentlicht am Nov 5, 2025 | ca. 1 Min. Lesezeit |

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.

Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.