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)