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
Preload-Header statt HTTP/2 Server Push
HTTP/2 Server Push war früher eine Möglichkeit, CSS- und JavaScript-Dateien proaktiv an den Browser zu schicken. Für neue Projekte sollte man ihn nicht mehr einplanen: Chrome hat die Unterstützung entfernt, HTTP/3 hat das Feature nicht übernommen, und aktuelle nginx-Versionen führen die alte Push-Konfiguration nicht mehr fort. Sinnvoll bleibt dagegen der Link: rel=preload Header: Er signalisiert dem Browser früh, welche Ressourcen wichtig sind.
Die alte nginx-Konfiguration sah so aus und ist nur noch als Legacy-Kontext relevant:
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
http2_push_preload on;
}
In aktuellen Setups setzen Sie stattdessen direkt Link: <...>; rel=preload HTTP-Header. In Symfony können Sie diese Header über die WebLink-Komponente oder direkt auf der Response 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: 103 Early Hints kann Preload-Header noch früher an den Browser senden. Der Link: rel=preload Header bleibt auch ohne Early Hints sinnvoll, weil 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.