In jeder Webanwendung gibt es Aufgaben, die nicht sofort erledigt werden müssen: E-Mail-Versand, PDF-Generierung, API-Synchronisation. Die Symfony Messenger-Komponente bietet eine elegante Lösung — doch der Teufel steckt im Produktionsbetrieb. In diesem Artikel zeige ich das komplette Setup, das ich auf meinem eigenen Server einsetze.
Das Konzept: Messages und Handler
Messenger basiert auf einem einfachen Prinzip: Sie erstellen eine Message-Klasse (ein reines DTO) und einen zugehörigen 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;
}
}
Der Handler verarbeitet die 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 als Transport
Für den Produktionsbetrieb empfehle ich RabbitMQ statt der Doctrine-basierten Transport-Variante. Gründe:
- Keine Datenbank-Locks: Doctrine-Transport belastet die Hauptdatenbank mit Polling-Queries
- Echtes Queuing: RabbitMQ nutzt AMQP Blocking Consume — der Consumer wartet ohne CPU-Last
- Skalierung: Mehrere Consumer parallel möglich, automatische Lastverteilung
# 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 der .env (Produktion):
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
Der systemd-Service: Automatisch, robust, überwacht
Der wichtigste Teil für den Produktionsbetrieb: Ein systemd-Service sorgt dafür, dass der Messenger-Consumer automatisch startet, bei Fehlern neu startet und sauber herunterfährt.
Hier ist der Service, den ich auf meinem openSUSE-Server einsetze:
# /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
Erklärung der entscheidenden Parameter
--time-limit=7200: Der Consumer startet alle 2 Stunden neu. Warum? PHP-Prozesse können über lange Laufzeiten Memory Leaks entwickeln — insbesondere bei Doctrine-EntityManager, der Referenzen auf alle geladenen Entities im UnitOfWork behält. Durch den regelmäßigen Neustart wird der Speicher sauber freigegeben.
--memory-limit=256M: Sicherheitsnetz — falls ein einzelner Message-Handler unerwartet viel Speicher verbraucht, stoppt der Consumer kontrolliert statt den Server zu gefährden.
Restart=always + RestartSec=5: Nach --time-limit oder --memory-limit beendet sich der Consumer mit Exit-Code 0. systemd startet ihn nach 5 Sekunden sofort neu. Effektiv gibt es keinen Downtime.
KillSignal=SIGTERM + TimeoutStopSec=30: Bei systemctl stop sendet systemd SIGTERM. Der Messenger-Consumer fängt dieses Signal ab und beendet die aktuelle Message sauber, bevor er stoppt. Nach 30 Sekunden wird SIGKILL gesendet — nur als letzter Ausweg.
User=nginx: Gleicher User wie PHP-FPM. Damit gibt es keine Dateirechte-Probleme bei Cache-Dateien oder Logs.
Requires=rabbitmq-server.service: Der Consumer startet erst, wenn RabbitMQ läuft. Ohne diese Zeile würden Verbindungsfehler in den ersten Sekunden nach dem Booten auftreten.
Installation und Management
sudo systemctl daemon-reload
sudo systemctl enable app-messenger
sudo systemctl start app-messenger
# Logs in Echtzeit
sudo journalctl -u app-messenger -f
# Status prüfen
sudo systemctl status app-messenger
Fehlerbehandlung und Failure-Queue
Ein großer Vorteil von Messenger ist die eingebaute Fehlerbehandlung. Fehlgeschlagene Messages landen nach den konfigurierten Retry-Versuchen in der Failure-Queue:
framework:
messenger:
failure_transport: failed
transports:
failed:
dsn: 'doctrine://default?queue_name=failed'
# Fehlgeschlagene Messages anzeigen
php bin/console messenger:failed:show
# Details einer bestimmten Message
php bin/console messenger:failed:show 42
# Erneut versuchen
php bin/console messenger:failed:retry 42
# Alle erneut versuchen
php bin/console messenger:failed:retry --force
nginx HTTP/2 Server Push
Ein oft übersehenes Feature: nginx kann in Kombination mit PHP-FPM automatisch HTTP/2 Server Push nutzen. Damit schickt der Server CSS- und JavaScript-Dateien proaktiv zum Browser — bevor dieser sie anfordern kann.
Die nginx-Konfiguration:
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
http2_push_preload on;
}
http2_push_preload on wertet automatisch Link: <...>; rel=preload HTTP-Header aus, die PHP sendet. In Symfony können Sie diese Header über die WebLink-Komponente setzen:
<?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'
);
}
}
Hinweis: HTTP/2 Push wurde in HTTP/3 (QUIC) nicht übernommen und Chrome hat die Unterstützung seit Version 106 entfernt. Für neue Projekte empfehle ich stattdessen 103 Early Hints — das Konzept ist ähnlich, wird aber von allen modernen Browsern unterstützt. Der Link: rel=preload Header bleibt trotzdem sinnvoll, da der Browser die Ressourcen dann priorisiert lädt.
Stamps und Middleware
Messenger lässt sich über Middleware erweitern. So können Sie z.B. Logging einfügen, bevor eine Message verarbeitet wird:
<?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 hat sich in meinen Projekten als unverzichtbar erwiesen. Die saubere Trennung von synchroner und asynchroner Logik macht Anwendungen robuster und skalierbarer — und mit einem sauber konfigurierten systemd-Service läuft das Ganze zuverlässig in Produktion.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.