Artikel
KI-Artikel 7 Min 684

Laravel Queues vs Symfony Messenger: Architektur, Retry, Batches, Benchmarks

Detaillierter Vergleich zweier PHP-Queue-Systeme: Laravel Queues und Symfony Messenger. Retry-Strategien, Middleware, Batches, Event-Driven, Performance.

Dieser Artikel wurde von einem KI-Modell erstellt und kann Ungenauigkeiten enthalten. Überprüfen Sie die Informationen vor der Verwendung in der Produktion.

Queues in PHP: Warum sie entscheidend sind

Jede Webanwendung stößt früher oder später auf Aufgaben, die nicht innerhalb eines HTTP-Requests ausgeführt werden können: E-Mail-Versand, PDF-Generierung, Dateiverarbeitung, externe API-Aufrufe, Bildverkleinerung. Der Benutzer klickt auf eine Schaltfläche — und wartet 15 Sekunden, während der Server eine E-Mail über SMTP sendet. Das ist inakzeptabel.

Queues lösen das Problem: Statt synchroner Ausführung wird die Aufgabe in eine Queue gelegt, ein Worker nimmt sie im Hintergrund auf, und der Benutzer bekommt sofort eine Antwort. Im PHP-Ökosystem gibt es zwei Hauptlösungen: Laravel Queues und Symfony Messenger. Beide sind ausgereift und produktionsbereit, aber architektonisch grundlegend verschieden.

Ich habe mit beiden gearbeitet: Laravel Queues auf zwei E-Commerce-Projekten mit Zehntausenden von Jobs pro Stunde, Symfony Messenger auf einem Fintech-Projekt mit Event-Driven-Architektur. Hier ist ein detaillierter Vergleich aus praktischer Erfahrung.

Architektur: Zwei Ansätze für dasselbe Problem

Laravel Queues — Job-zentriertes Modell

Laravel baut Queues um das Job-Konzept herum — eine Klasse, die eine Arbeitseinheit kapselt:

class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public readonly User $user,
    ) {}

    public function handle(Mailer $mailer): void
    {
        $mailer->to($this->user->email)
            ->send(new WelcomeEmail($this->user));
    }

    public function failed(\Throwable $e): void
    {
        Log::error("Willkommens-E-Mail fehlgeschlagen: {$e->getMessage()}");
    }
}

// Dispatch — eine Zeile
SendWelcomeEmail::dispatch($user);

Alles an einem Ort: Daten ($user), Logik (handle), Fehlerbehandlung (failed), Konfiguration. Ein Job ist gleichzeitig Message und Handler.

Symfony Messenger — Message + Handler

Symfony trennt Nachricht und Handler:

// src/Message/SendWelcomeEmail.php
class SendWelcomeEmail
{
    public function __construct(
        public readonly int $userId,
    ) {}
}

// src/MessageHandler/SendWelcomeEmailHandler.php
#[AsMessageHandler]
class SendWelcomeEmailHandler
{
    public function __construct(
        private readonly UserRepository $userRepository,
        private readonly MailerInterface $mailer,
    ) {}

    public function __invoke(SendWelcomeEmail $message): void
    {
        $user = $this->userRepository->find($message->userId);
        if (!$user) {
            return;
        }

        $this->mailer->send(
            (new Email())
                ->to($user->getEmail())
                ->subject('Willkommen!')
                ->htmlTemplate('emails/welcome.html.twig', ['user' => $user])
        );
    }
}

// Dispatch
$this->messageBus->dispatch(new SendWelcomeEmail($user->getId()));

Die Nachricht ist ein einfaches DTO. Der Handler ist eine separate Klasse mit Abhängigkeiten über den DI-Container. Eine Nachricht kann mehrere Handler haben.

Wichtige architektonische Unterschiede

Aspekt Laravel Queues Symfony Messenger
Arbeitseinheit Job (Message + Handler) Message + Handler (getrennt)
Serialisierung Models via SerializesModels Nur Primitive (ID, Strings)
DI Method Injection in handle() Constructor Injection im Handler
Routing Queue im Job angegeben In messenger.yaml konfiguriert
Mehrere Handler Nein (1 Job = 1 Handler) Ja (1 Message → N Handler)
Komplexität Niedrig Mittel-hoch

Transporte und Treiber

Laravel: Eingebaute Treiber

// config/queue.php
'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'queue' => 'default',
        'retry_after' => 90,
    ],
    'database' => [
        'driver' => 'database',
        'table' => 'jobs',
        'queue' => 'default',
    ],
    'sqs' => [
        'driver' => 'sqs',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'queue' => env('SQS_QUEUE', 'default'),
        'region' => env('AWS_DEFAULT_REGION', 'eu-central-1'),
    ],
],

Symfony: Transporte via DSN

# 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
            failed:
                dsn: 'doctrine://default?queue_name=failed'

        routing:
            'App\Message\SendWelcomeEmail': async
# .env
MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
# oder
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages

Transport-Vergleich

Transport Laravel Symfony Wann verwenden
Redis Ja (primär) Ja Geschwindigkeit, Einfachheit
RabbitMQ (AMQP) Via Paket Ja (primär) Routing, Fan-out, Zustellgarantien
Datenbank Ja Ja (Doctrine) Einfachheit, kein Redis nötig
Amazon SQS Ja Ja AWS-Infrastruktur
Kafka Via Paket Via Paket Event Streaming

Retry und Fehlerbehandlung

Laravel: Retry auf Job-Ebene

class ProcessPayment implements ShouldQueue
{
    public $tries = 5;
    public $timeout = 120;
    public $backoff = [10, 30, 60, 120, 300];

    public function handle(PaymentGateway $gateway): void
    {
        $result = $gateway->charge($this->order->total, $this->order->payment_method_id);

        if (!$result->success) {
            if ($result->isRetryable()) {
                $this->release($this->backoff[$this->attempts() - 1] ?? 300);
                return;
            }
            $this->fail(new PaymentFailedException($result->error));
        }

        $this->order->markPaid($result->transaction_id);
    }

    public function failed(\Throwable $e): void
    {
        $this->order->markPaymentFailed($e->getMessage());
    }
}

Symfony: Retry über Middleware

#[AsMessageHandler]
class ProcessPaymentHandler
{
    public function __invoke(ProcessPayment $message): void
    {
        $result = $this->gateway->charge($order->getTotal(), $message->paymentMethodId);

        if (!$result->isSuccess()) {
            if ($result->isRetryable()) {
                throw new RecoverableMessageHandlingException($result->getError());
            }
            throw new UnrecoverableMessageHandlingException($result->getError());
        }
    }
}
framework:
    messenger:
        transports:
            async_payments:
                retry_strategy:
                    max_retries: 5
                    delay: 10000
                    multiplier: 3
                    max_delay: 300000
        failure_transport: failed

Prioritäten und mehrere Queues

Laravel

# Worker verarbeitet Queues mit Priorität
php artisan queue:work --queue=payments,emails,reports

# Dedizierte Worker
php artisan queue:work --queue=payments --timeout=120
php artisan queue:work --queue=reports --timeout=600 --memory=512

Symfony

framework:
    messenger:
        transports:
            async_high:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: high_priority
            async_low:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: low_priority
        routing:
            'App\Message\ProcessPayment': async_high
            'App\Message\UpdateSearchIndex': async_low
php bin/console messenger:consume async_high async_low --limit=1000

Batch-Verarbeitung

Laravel: Job Batches

$batch = Bus::batch([
    new ProcessImage($images[0]),
    new ProcessImage($images[1]),
    new ProcessImage($images[2]),
])
->then(fn(Batch $batch) => Notification::send($user, new ImagesProcessedNotification()))
->catch(fn(Batch $batch, \Throwable $e) => Log::error("Batch fehlgeschlagen: {$e->getMessage()}"))
->name('process-user-images')
->dispatch();

// Fortschritt überwachen
$batch = Bus::findBatch($batchId);
echo $batch->progress(); // 67%

Symfony hat kein eingebautes Batch-Äquivalent — Fortschrittsverfolgung muss manuell über Cache-Zähler implementiert werden. Laravel gewinnt hier eindeutig.

Event-Driven-Architektur

Laravel: Events + Listeners

class OrderPlaced
{
    use Dispatchable, SerializesModels;

    public function __construct(public readonly Order $order) {}
}

class SendOrderConfirmation implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->email)->send(new OrderConfirmationMail($event->order));
    }
}

event(new OrderPlaced($order));

Symfony: Message Bus + Handler

class OrderPlaced
{
    public function __construct(public readonly int $orderId) {}
}

#[AsMessageHandler]
class SendOrderConfirmationHandler
{
    public function __invoke(OrderPlaced $event): void
    {
        // Bestätigung senden
    }
}

#[AsMessageHandler]
class UpdateInventoryHandler
{
    public function __invoke(OrderPlaced $event): void
    {
        // Bestand aktualisieren
    }
}

In Symfony erreicht eine Nachricht automatisch alle registrierten Handler — ohne explizite Verdrahtung.

Testen

Laravel: Queue::fake()

public function test_order_dispatches_jobs(): void
{
    Queue::fake();

    $this->actingAs($user)->post("/orders/{$order->id}/place");

    Queue::assertPushed(SendOrderConfirmation::class);
    Queue::assertNotPushed(RefundOrder::class);
    Queue::assertPushedOn('emails', SendOrderConfirmation::class);
}

Symfony: InMemoryTransport

public function testOrderDispatchesMessages(): void
{
    $client = static::createClient();
    $client->request('POST', '/orders/1/place');

    $transport = $this->getContainer()->get('messenger.transport.async');
    $messages = $transport->get();

    $this->assertCount(3, $messages);
}
# config/packages/test/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: 'in-memory://'

Monitoring

Laravel: Horizon

Horizon ist ein vollständiges Dashboard für Redis-Queue-Monitoring: Echtzeit-Metriken, Durchsatz-Grafiken, Failed-Job-Inspektion, Worker-Balancierung. Installation mit composer require laravel/horizon.

Symfony: Kein eingebautes Dashboard

Monitoring wird über Prometheus + Grafana mit benutzerdefinierten Metriken aufgebaut, oder über Event-Subscriber auf WorkerMessageHandledEvent.

Performance-Benchmarks

Tests auf gleicher Maschine (4 Kerne, 16GB RAM, Redis 7, PHP 8.4, 4 Worker).

Szenario Laravel Queues Symfony Messenger
Einfacher Job (DB-Schreiben) ~2.800/s ~2.400/s
E-Mail-Versand ~1.200/s ~1.100/s
Schwerer Job (API + DB + Cache) ~180/s ~170/s
Große Model-Serialisierung ~900/s ~1.500/s*

*Symfony ist schneller bei Serialisierung, da nur IDs übertragen werden.

Speicherverbrauch (pro Worker)

Metrik Laravel Symfony
Grundverbrauch ~40 MB ~35 MB
Nach 10.000 Jobs ~55 MB ~42 MB
Memory-Drift über 24h ~15 MB ~8 MB

Wann was wählen

Laravel Queues

  • Bereits auf Laravel — kein Grund, Symfony Messenger einzuführen
  • Batches mit Fortschritt und Callbacks benötigt
  • Horizon für Monitoring benötigt
  • Einfache Architektur: ein Handler pro Aufgabe

Symfony Messenger

  • Bereits auf Symfony
  • Event-Driven-Architektur: ein Event → viele Handler
  • RabbitMQ mit erweitertem Routing
  • CQRS: getrennte Command- und Query-Busse
  • Mikroservice-Architektur

Keines von beiden

  • Weniger als 100 Jobs pro Tag — Cron reicht
  • Echtzeit mit WebSocket — Centrifugo, Mercure
  • Event Streaming (Millionen Events) — Kafka

Abschließende Checkliste

Auswahl

  • [ ] Stack bestimmen: Laravel → Queues, Symfony → Messenger
  • [ ] Transport bestimmen: Redis, RabbitMQ, SQS, Datenbank
  • [ ] Muster bestimmen: Job-zentriert oder Event-Driven
  • [ ] Volumen abschätzen: < 1K Jobs/h → Datenbank, < 100K → Redis, > 100K → RabbitMQ

Design

  • [ ] Queues nach Priorität aufteilen: payments, emails, reports
  • [ ] Retry konfigurieren: exponentieller Backoff, Dead Letter Queue
  • [ ] Jobs idempotent machen
  • [ ] Nur IDs übergeben, nicht große Objekte

Produktion

  • [ ] Supervisor/systemd für Worker
  • [ ] Memory-Limits setzen
  • [ ] Monitoring: Horizon oder Prometheus + Grafana
  • [ ] Alerts: Failed Jobs, Queue-Tiefe, Consumer-Lag

Kommentare (0)