Symfony Messenger: Queues, Workers en Asynchrone Architectuur in 2026

Diepgaande analyse van Symfony Messenger: message bus-architectuur, transport-configuratie, worker-beheer, deduplicatie-middleware, retry-strategieen, en het streaming AMQP-transport in Symfony 7.3+.

Architectuurdiagram van Symfony Messenger met asynchrone queues en workers

Symfony Messenger verwerkt asynchrone taken in PHP via een gestructureerde message bus, configureerbare transports en beheerde workers. Met Symfony 7.3 en 8.0 die deduplicatie-middleware, streaming AMQP en Doctrine keepalive introduceren, concurreert de component inmiddels met gespecialiseerde queue-systemen zoals Laravel Horizon of Sidekiq op het gebied van functionaliteit en volwassenheid.

Dit artikel behandelt de volledige Messenger-stack: van architectuurprincipes en transport-backends tot geavanceerde patronen als CQRS, retry-strategieen en deduplicatie. Elk onderdeel wordt voorzien van productieklare codevoorbeelden en configuraties die direct toepasbaar zijn in technische sollicitatiegesprekken.

Nieuwe functies in Symfony 7.3+ Messenger

Symfony 7.3 voegt DeduplicateMiddleware toe voor automatische berichtdeduplicatie, Doctrine transport keepalive om heraflevering bij langlopende taken te voorkomen, en het #[AsMessage]-attribuut voor declaratieve transport-routing direct vanuit de message-klasse.

Symfony Messenger-architectuur: Bus, Transport en Worker

De Messenger-component scheidt drie verantwoordelijkheden: het versturen (de bus), de bezorging (het transport) en de verwerking (de worker). Een message is een gewoon PHP-object zonder afhankelijkheden van het framework. Een handler is een invokeerbare klasse die precies een berichttype verwerkt. De bus verbindt beide onderdelen, routeert het bericht door een middleware-pipeline en serialiseert het optioneel naar een transport voor asynchrone verwerking.

src/Message/InvoiceGenerated.phpphp
namespace App\Message;

final readonly class InvoiceGenerated
{
    public function __construct(
        public int $orderId,
        public string $customerEmail,
    ) {}
}

Dit bericht bevat uitsluitend scalaire data, geen Doctrine-entiteiten. Het doorgeven van ID's in plaats van objecten voorkomt serialisatieproblemen en houdt berichten lichtgewicht. De handler haalt actuele gegevens op uit de database op het moment van verwerking, waardoor verouderde state geen rol speelt.

src/MessageHandler/InvoiceGeneratedHandler.phpphp
namespace App\MessageHandler;

use App\Message\InvoiceGenerated;
use App\Service\InvoiceService;
use App\Service\MailerService;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class InvoiceGeneratedHandler
{
    public function __construct(
        private InvoiceService $invoiceService,
        private MailerService $mailerService,
    ) {}

    public function __invoke(InvoiceGenerated $message): void
    {
        $pdf = $this->invoiceService->generatePdf($message->orderId);
        $this->mailerService->sendInvoice(
            $message->customerEmail,
            $pdf,
        );
    }
}

Het dispatchen van een message vanuit een controller of service vereist slechts een enkele aanroep:

src/Controller/OrderController.phpphp
$this->bus->dispatch(new InvoiceGenerated(
    orderId: $order->getId(),
    customerEmail: $order->getCustomer()->getEmail(),
));

De bus bepaalt of de uitvoering synchroon dan wel asynchroon verloopt op basis van de transport-routeringsconfiguratie. Zonder geconfigureerde routing wordt het bericht direct in hetzelfde proces afgehandeld. Zodra een transport is toegewezen, serialiseert Messenger het bericht en plaatst het in de bijbehorende queue.

Transport-configuratie en queue-backends

Messenger ondersteunt Doctrine DBAL, Redis, Amazon SQS, Beanstalkd, AMQP (RabbitMQ) en het nieuwe streaming AMQP-transport dat in 2025 werd geintroduceerd. Elk transport wordt geconfigureerd als een DSN-string in messenger.yaml.

yaml
# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed

        transports:
            async_priority_high:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: high
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 3
                    max_delay: 60000

            async_priority_low:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: low
                retry_strategy:
                    max_retries: 5
                    delay: 5000
                    multiplier: 2

            failed:
                dsn: 'doctrine://default?queue_name=failed'

        routing:
            'App\Message\InvoiceGenerated': async_priority_high
            'App\Message\CleanupTempFiles': async_priority_low

Het opsplitsen van transports op basis van prioriteit garandeert dat tijdkritische taken (factuurgeneratie) eerder worden verwerkt dan onderhoudstaken (opruimen van tijdelijke bestanden). Elk transport beschikt over een eigen retry-strategie, nauwkeurig afgestemd op de kriticiteit en het idempotente karakter van de bijbehorende berichten. Het high-priority transport begint met een vertraging van 1 seconde die verdrievoudigt bij elke poging, terwijl het low-priority transport een minder agressief schema hanteert.

Doctrine vs. Redis vs. AMQP

Doctrine transport vereist geen aanvullende infrastructuur, maar verhoogt de databasebelasting. Redis biedt latency onder de milliseconde en is ideaal voor kleinere tot middelgrote volumes. AMQP (RabbitMQ) levert geavanceerde routering, dead-letter exchanges en het nieuwe streaming transport voor scenario's met hoge doorvoer. De keuze hangt af van de bestaande infrastructuur en de vereiste verwerkingscapaciteit.

Worker-beheer met Supervisor

Workers consumeren berichten vanuit transports. In productieomgevingen draait het messenger:consume-commando onder een process manager zoals Supervisor of systemd, die het proces bewaakt en automatisch herstart bij onverwachte beeindiging.

bash
# Consume high-priority messages first, then low-priority
php bin/console messenger:consume async_priority_high async_priority_low \
    --memory-limit=128M \
    --time-limit=3600 \
    --limit=500

De drie limietvlaggen vormen de eerste verdedigingslinie tegen geheugenlekken en procesveroudering. --memory-limit=128M stopt de worker zodra het geheugenverbruik de grens overschrijdt. --time-limit=3600 beperkt de levensduur tot een uur. --limit=500 stelt een maximum aan het aantal verwerkte berichten per cyclus. Supervisor herstart het proces na elke beeindiging, waardoor een continue verwerkingscyclus ontstaat.

ini
; /etc/supervisor/conf.d/messenger-worker.conf
[program:messenger-consume]
command=php /var/www/app/bin/console messenger:consume async_priority_high async_priority_low --memory-limit=128M --time-limit=3600
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
startsecs=0
stopwaitsecs=30
stdout_logfile=/var/log/messenger-worker.log
stderr_logfile=/var/log/messenger-worker-error.log

Met numprocs=2 worden twee parallelle workers gestart, wat de verwerkingscapaciteit verdubbelt. Het optimale aantal hangt af van het beschikbare aantal CPU-cores en de aard van de taken. Bij IO-gebonden verwerking (API-aanroepen, e-mailverzending) levert een verhouding van 2 tot 4 workers per core doorgaans de beste balans tussen doorvoer en resourcegebruik.

Middleware-pipeline en CQRS met meerdere bussen

Middleware omhult elke berichtdispatch en voegt cross-cutting concerns toe aan de verwerkingspipeline. De ingebouwde middleware-stack verzorgt validatie, Doctrine-transacties en routering.

yaml
# config/packages/messenger.yaml
framework:
    messenger:
        default_bus: command.bus
        buses:
            command.bus:
                middleware:
                    - validation
                    - doctrine_transaction
            query.bus:
                middleware:
                    - validation
            event.bus:
                default_middleware:
                    allow_no_handlers: true
                middleware:
                    - validation

Deze configuratie implementeert CQRS (Command Query Responsibility Segregation) via drie gescheiden bussen. Commands muteren applicatiestatus binnen een Doctrine-transactie, wat automatische rollback garandeert bij fouten. Queries zijn strikt read-only en starten bewust geen transactie. Events accepteren nul of meer handlers via allow_no_handlers: true, waarmee een pub/sub-patroon wordt gerealiseerd: listeners kunnen onafhankelijk van elkaar worden toegevoegd of verwijderd zonder invloed op bestaande logica.

Het vermogen om deze architectuurkeuze te motiveren is een terugkerend thema in senior backend-interviews. Het onderscheid tussen command bus en query bus demonstreert beheersing van het single responsibility principle op message-niveau.

Klaar om je Symfony gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Deduplicatie-middleware in Symfony 7.3

Dubbele berichten verspillen serverbronnen en veroorzaken potentieel ernstige bijwerkingen: tweemaal factureren, meervoudige e-mailbezorging of onbedoelde dubbele API-aanroepen naar externe systemen. Symfony 7.3 introduceert DeduplicateMiddleware om identieke berichten die al in de queue staan automatisch over te slaan.

src/Message/SendWelcomeEmail.phpphp
namespace App\Message;

use Symfony\Component\Messenger\Stamp\DeduplicateStamp;

final readonly class SendWelcomeEmail
{
    public function __construct(
        public int $userId,
    ) {}
}
php
// Dispatching with deduplication
use Symfony\Component\Messenger\Stamp\DeduplicateStamp;

$this->bus->dispatch(
    new SendWelcomeEmail(userId: 42),
    [new DeduplicateStamp(id: 'welcome-email-42')],
);

De DeduplicateStamp ontvangt een lock-resource-identifier. Staat er al een bericht met hetzelfde ID in de wachtrij, dan wordt de nieuwe dispatch stilzwijgend genegeerd. Dit mechanisme vereist de Lock-component met een serialiseerbare store (Redis, Memcached of database). Een veelgestelde interviewvraag gaat over het verschil tussen deduplicatie bij verzending (deze stamp) en idempotentie bij verwerking (controle in de handler zelf). Beide complementeren elkaar, maar dienen verschillende doeleinden.

Retry-strategieen en het failure-transport

Wanneer een handler een exception gooit, probeert Messenger het bericht opnieuw te verwerken volgens de retry-strategie van het bijbehorende transport. Na het uitputten van alle toegestane pogingen wordt het bericht verplaatst naar het failure-transport, waar het beschikbaar blijft voor handmatige inspectie en eventuele herverwerking.

src/MessageHandler/PaymentHandler.phpphp
namespace App\MessageHandler;

use App\Message\ProcessPayment;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;

#[AsMessageHandler]
final class PaymentHandler
{
    public function __invoke(ProcessPayment $message): void
    {
        try {
            $this->gateway->charge($message->amount, $message->token);
        } catch (GatewayTimeoutException $e) {
            // Recoverable: retry with backoff
            throw new RecoverableMessageHandlingException(
                'Payment gateway timeout, retrying',
                previous: $e,
            );
        } catch (InvalidCardException $e) {
            // Unrecoverable: send to failure transport immediately
            throw new UnrecoverableMessageHandlingException(
                'Invalid card, no retry',
                previous: $e,
            );
        }
    }
}

RecoverableMessageHandlingException activeert de retry-strategie met exponentieel toenemende vertraging. UnrecoverableMessageHandlingException omzeilt de volledige retry-keten en stuurt het bericht rechtstreeks naar het failure-transport. Dit onderscheid voorkomt zinloze retries bij permanent ongeldige berichten: een geweigerde creditcard wordt na drie extra pogingen niet alsnog geaccepteerd.

bash
# Inspect and manage failed messages
php bin/console messenger:failed:show
php bin/console messenger:failed:show 20 --transport=failed

# Retry specific messages
php bin/console messenger:failed:retry 20 30

# Filter and remove by class (Symfony 7.3+)
php bin/console messenger:failed:remove --class-filter="App\Message\CleanupTempFiles"

Het commando messenger:failed:show toont mislukte berichten inclusief de volledige stack trace en het aantal uitgevoerde pogingen. Met messenger:failed:retry kunnen specifieke berichten opnieuw worden ingevoerd nadat het onderliggende probleem is opgelost. Het klassefilter, nieuw in Symfony 7.3, maakt selectief opschonen van verouderde berichten mogelijk zonder andere berichttypen te raken.

Idempotente handlers zijn essentieel

Retries impliceren dat een handler meerdere keren wordt uitgevoerd voor hetzelfde bericht. Ontwerp elke handler als idempotent: controleer altijd of de bewerking al is uitgevoerd voordat deze wordt herhaald. Gebruik database unique constraints of statusvlaggen om dubbele verwerking structureel te voorkomen.

Doctrine Keepalive bij langlopende berichten

Handlers voor langlopende taken lopen het risico dat hun berichten opnieuw worden afgeleverd zodra de visibility timeout van het transport verstrijkt. Symfony 7.2 introduceerde keepalive voor Redis, SQS en Beanstalkd. Symfony 7.3 breidt deze functionaliteit uit naar het Doctrine-transport, wat cruciaal is voor teams die zonder externe queue-infrastructuur werken.

bash
# Enable keepalive to prevent redelivery during long processing
php bin/console messenger:consume async --keepalive

De --keepalive-vlag werkt periodiek de delivered_at-timestamp bij in de Doctrine-transporttabel, waarmee wordt gesignaleerd dat de worker het bericht nog actief verwerkt. Zonder deze vlag wordt een bericht waarvan de verwerking langer duurt dan de transport-timeout (standaard 5 minuten) opgepakt door een andere worker, met dubbele verwerking als gevolg. Dit scenario doet zich met name voor bij taken als rapportgeneratie, bulkexport of zware berekeningen.

Het #[AsMessage]-attribuut voor declaratieve routing

Symfony 7.2 introduceerde het #[AsMessage]-attribuut, waarmee transport-routing verhuist van YAML-configuratie naar de message-klasse zelf. Dit past binnen de bredere Symfony-trend richting PHP 8-attributen als primair configuratiemechanisme.

src/Message/GenerateReport.phpphp
namespace App\Message;

use Symfony\Component\Messenger\Attribute\AsMessage;

#[AsMessage(transport: 'async_priority_low')]
final readonly class GenerateReport
{
    public function __construct(
        public int $reportId,
        public string $format = 'pdf',
    ) {}
}

Deze benadering elimineert de routing-sectie in messenger.yaml voor het betreffende bericht. Het transport wordt bij de bron gedeclareerd, waardoor de codebase zelfdocumenterend wordt. Beide benaderingen (YAML-routing en attribuut) bestaan naast elkaar, waarbij het attribuut voorrang heeft bij conflicten. In de praktijk kiezen veel teams voor een hybride strategie: het attribuut voor standaardroutering en YAML voor omgevingsspecifieke overschrijvingen.

Streaming AMQP-transport voor high-throughput queues

Het traditionele AMQP-transport maakt gebruik van polling (get()) om berichten op te halen, wat onnodige belasting op RabbitMQ genereert bij lage berichtvolumes en onnodige latency bij hoge volumes. Het streaming AMQP-transport dat in 2025 werd uitgebracht, schakelt over naar een push-model (consume()), waardoor zowel latency als CPU-verbruik significant dalen.

yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            streaming:
                dsn: 'amqp-lib://guest:guest@localhost:5672/%2f/messages'
                options:
                    exchange:
                        name: app_events
                        type: topic
                    queues:
                        order_events:
                            binding_keys: ['order.*']

De voornaamste verschillen ten opzichte van het standaard AMQP-transport: geen C-extensie vereist (het maakt gebruik van php-amqplib), streaming delivery via langlevende TCP-verbindingen, en native ondersteuning voor topic exchanges met binding key-routing. Dit transport verwerkt duizenden berichten per seconde met minimale CPU-overhead. Voor event-driven architecturen maakt de combinatie van het streaming transport met granulaire binding keys het mogelijk om berichten te distribueren naar gespecialiseerde consumers zonder het aantal exchanges te vermenigvuldigen.

Conclusie

  • Geef scalaire ID's door in berichten, geen Doctrine-entiteiten; haal actuele gegevens op in de handler om serialisatieproblemen en verouderde state te voorkomen
  • Splits transports op basis van prioriteit en kriticiteit; elk transport krijgt een eigen retry-strategie en een dedicated worker-pool
  • Gebruik RecoverableMessageHandlingException en UnrecoverableMessageHandlingException om retry-gedrag expliciet aan te sturen
  • Schakel --keepalive in bij Doctrine transport workers om heraflevering van langlopende berichten te voorkomen
  • Pas DeduplicateStamp toe op berichten waarbij dubbele verwerking bijwerkingen veroorzaakt (betalingen, e-mails, notificaties)
  • Implementeer CQRS met meerdere bussen: command bus voor mutaties met Doctrine-transacties, query bus voor leesoperaties, event bus voor pub/sub
  • Gebruik Supervisor of systemd in productie met de vlaggen --memory-limit, --time-limit en --limit voor het beheer van de worker-levenscyclus
  • Overweeg het streaming AMQP-transport voor high-throughput scenario's die latency onder de milliseconde vereisen

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

#symfony
#messenger
#php
#async
#message queue
#workers

Delen

Gerelateerde artikelen