Queues in PHP: Why They're Critical
Every web application eventually hits tasks that can't run inside an HTTP request: sending email, generating PDFs, processing uploaded files, calling external APIs, resizing images. The user clicks a button — and waits 15 seconds while the server sends an email via SMTP. That's unacceptable.
Queues solve this: instead of synchronous execution, the task goes into a queue, a worker picks it up in the background, and the user gets an instant response. In the PHP ecosystem, two major solutions exist: Laravel Queues and Symfony Messenger. Both are mature, both are production-ready, but architecturally they're fundamentally different.
I've worked with both: Laravel Queues on two e-commerce projects handling tens of thousands of jobs per hour, Symfony Messenger on a fintech project with event-driven architecture. Here's a detailed comparison based on real experience.
Architecture: Two Approaches to the Same Problem
Laravel Queues — Job-Centric Model
Laravel builds queues around the Job concept — a class that encapsulates a unit of work:
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("Welcome email failed for user {$this->user->id}: {$e->getMessage()}");
}
}
// Dispatch — one line
SendWelcomeEmail::dispatch($user);
Everything in one place: data ($user), logic (handle), error handling (failed), configuration (queue, delay, retry). A Job is both the message and the handler.
Symfony Messenger — Message + Handler
Symfony separates the message from the 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('Welcome!')
->htmlTemplate('emails/welcome.html.twig', ['user' => $user])
);
}
}
// Dispatch
$this->messageBus->dispatch(new SendWelcomeEmail($user->getId()));
The message is a simple DTO with data. The handler is a separate class with dependencies via the DI container. One message can have multiple handlers.
Key Architectural Differences
| Aspect | Laravel Queues | Symfony Messenger |
|---|---|---|
| Unit of work | Job (message + handler) | Message + Handler (separate) |
| Serialization | Models via SerializesModels | Only primitives (ID, strings) |
| DI | Method injection in handle() | Constructor injection in handler |
| Routing | Queue specified in Job | Configured in messenger.yaml |
| Multiple handlers | No (1 job = 1 handler) | Yes (1 message → N handlers) |
| Middleware | Job middleware | Bus middleware |
| Complexity | Low | Medium-high |
Transports and Drivers
Laravel: Built-in Drivers
// config/queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'default',
'retry_after' => 90,
'block_for' => 5,
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX'),
'queue' => env('SQS_QUEUE', 'default'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
],
Supported drivers out of the box: Redis, Database, Amazon SQS, Beanstalkd, Sync. Redis is the most popular for production.
Symfony: Transports 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
'App\Message\ProcessPayment': async
# .env
MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
# or
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# or
MESSENGER_TRANSPORT_DSN=doctrine://default
Supported transports: AMQP (RabbitMQ), Doctrine (database), Redis, Amazon SQS, Beanstalkd, In-memory. AMQP is preferred for production in the Symfony world.
Transport Comparison
| Transport | Laravel | Symfony | When to Use |
|---|---|---|---|
| Redis | Yes (primary) | Yes | Speed, simplicity, up to ~50k msg/sec |
| RabbitMQ (AMQP) | Via package | Yes (primary) | Routing, fan-out, delivery guarantees |
| Database | Yes | Yes (Doctrine) | Simplicity, no Redis needed |
| Amazon SQS | Yes | Yes | AWS infrastructure, serverless |
| Kafka | Via package | Via package | Event streaming, high throughput |
Retry and Error Handling
Laravel: Retry at the Job Level
class ProcessPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 5;
public $maxExceptions = 3;
public $timeout = 120;
public $backoff = [10, 30, 60, 120, 300];
public function __construct(
public readonly Order $order,
) {}
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());
Notification::route('slack', config('services.slack.payments'))
->notify(new PaymentFailedNotification($this->order, $e));
}
public function retryUntil(): DateTime
{
return now()->addHours(24);
}
}
Symfony: Retry via Middleware and Stamps
#[AsMessageHandler]
class ProcessPaymentHandler
{
public function __invoke(ProcessPayment $message): void
{
$order = $this->orderRepository->find($message->orderId);
$result = $this->gateway->charge($order->getTotal(), $message->paymentMethodId);
if (!$result->isSuccess()) {
if ($result->isRetryable()) {
throw new RecoverableMessageHandlingException($result->getError());
}
throw new UnrecoverableMessageHandlingException($result->getError());
}
$order->markPaid($result->getTransactionId());
}
}
framework:
messenger:
transports:
async_payments:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 5
delay: 10000
multiplier: 3
max_delay: 300000
failure_transport: failed
Symfony uses RecoverableMessageHandlingException for retry and UnrecoverableMessageHandlingException for immediate move to the failure transport.
Failed Jobs: Monitoring and Replay
Laravel:
bash
php artisan queue:failed # list failed jobs
php artisan queue:retry 5 # retry specific job
php artisan queue:retry all # retry all
php artisan queue:flush # clear all failed
Symfony:
bash
php bin/console messenger:failed:show # list
php bin/console messenger:failed:retry 5 # retry specific
php bin/console messenger:failed:retry # retry all
php bin/console messenger:failed:remove 5 # remove specific
Priorities and Multiple Queues
Laravel: Queues with Priorities
class SendWelcomeEmail implements ShouldQueue
{
public $queue = 'emails';
}
class ProcessPayment implements ShouldQueue
{
public $queue = 'payments';
}
# Worker processes queues with priority
php artisan queue:work --queue=payments,emails,reports
# Dedicated workers
php artisan queue:work --queue=payments --timeout=120
php artisan queue:work --queue=emails --timeout=60
php artisan queue:work --queue=reports --timeout=600 --memory=512
Symfony: Routing and Multiple Consumers
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\SendWelcomeEmail': async_high
'App\Message\UpdateSearchIndex': async_low
# Separate consumers
php bin/console messenger:consume async_high --limit=1000
php bin/console messenger:consume async_low --limit=500
# One consumer, multiple transports (with priority)
php bin/console messenger:consume async_high async_low
Job Middleware
Laravel
class RateLimited
{
public function handle(object $job, callable $next): void
{
$key = 'job-rate:' . get_class($job);
if (RateLimiter::tooManyAttempts($key, 100)) {
$job->release(60);
return;
}
RateLimiter::hit($key, 60);
$next($job);
}
}
class WithoutOverlapping
{
public function __construct(
private readonly string $lockKey,
private readonly int $releaseAfter = 300,
) {}
public function handle(object $job, callable $next): void
{
$lock = Cache::lock("job-lock:{$this->lockKey}", $this->releaseAfter);
if (!$lock->get()) {
$job->release(30);
return;
}
try {
$next($job);
} finally {
$lock->release();
}
}
}
// Usage in Job
class SyncInventory implements ShouldQueue
{
public function middleware(): array
{
return [
new RateLimited(),
new WithoutOverlapping("inventory-{$this->warehouseId}"),
];
}
}
Symfony
class LoggingMiddleware implements MiddlewareInterface
{
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$class = get_class($envelope->getMessage());
$this->logger->info("Processing: {$class}");
$start = microtime(true);
try {
$envelope = $stack->next()->handle($envelope, $stack);
$duration = round((microtime(true) - $start) * 1000);
$this->logger->info("Done: {$class} ({$duration}ms)");
return $envelope;
} catch (\Throwable $e) {
$this->logger->error("Failed: {$class} - {$e->getMessage()}");
throw $e;
}
}
}
Batch Processing
Laravel: Job Batches
$batch = Bus::batch([
new ProcessImage($images[0]),
new ProcessImage($images[1]),
new ProcessImage($images[2]),
])
->then(function (Batch $batch) {
Notification::send($user, new ImagesProcessedNotification());
})
->catch(function (Batch $batch, \Throwable $e) {
Log::error("Batch {$batch->id} failed: {$e->getMessage()}");
})
->finally(function (Batch $batch) {
CleanupTempFiles::dispatch($batch->id);
})
->allowFailures()
->name('process-user-images')
->onQueue('images')
->dispatch();
// Monitor progress
$batch = Bus::findBatch($batchId);
echo $batch->progress(); // 67 (percent)
echo $batch->processedJobs(); // 2
echo $batch->totalJobs; // 3
Symfony has no built-in batch equivalent — you'd need to build progress tracking manually with cache counters. Laravel wins decisively here: batches are a first-class feature with progress, callbacks, and monitoring.
Event-Driven Architecture
Laravel: Events + Listeners + Queues
// Event
class OrderPlaced
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly Order $order,
) {}
}
// Queued listeners
class SendOrderConfirmation implements ShouldQueue
{
public $queue = 'emails';
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->email)
->send(new OrderConfirmationMail($event->order));
}
}
class UpdateInventory implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
foreach ($event->order->items as $item) {
Product::where('id', $item->product_id)
->decrement('stock', $item->quantity);
}
}
}
// Dispatch
event(new OrderPlaced($order));
Symfony: Event Bus + Handlers
class OrderPlaced
{
public function __construct(
public readonly int $orderId,
) {}
}
#[AsMessageHandler]
class SendOrderConfirmationHandler
{
public function __invoke(OrderPlaced $event): void
{
// send confirmation
}
}
#[AsMessageHandler]
class UpdateInventoryHandler
{
public function __invoke(OrderPlaced $event): void
{
// update stock
}
}
In Symfony, one message automatically reaches all registered handlers. No need to explicitly wire events and listeners — the DI container does it via attributes.
Testing
Laravel: Queue::fake()
public function test_placing_order_dispatches_jobs(): void
{
Queue::fake();
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)->post("/orders/{$order->id}/place");
Queue::assertPushed(SendOrderConfirmation::class, function ($job) use ($order) {
return $job->order->id === $order->id;
});
Queue::assertNotPushed(RefundOrder::class);
Queue::assertPushedOn('emails', SendOrderConfirmation::class);
}
public function test_batch_processing(): void
{
Bus::fake();
$this->post('/images/process', ['ids' => [1, 2, 3]]);
Bus::assertBatched(function (PendingBatch $batch) {
return $batch->name === 'process-images' && $batch->jobs->count() === 3;
});
}
Symfony: InMemoryTransport
public function testPlacingOrderDispatchesMessages(): void
{
$client = static::createClient();
$client->request('POST', '/orders/1/place');
$transport = $this->getContainer()->get('messenger.transport.async');
$messages = $transport->get();
$this->assertCount(3, $messages);
$this->assertInstanceOf(OrderPlaced::class, $messages[0]->getMessage());
}
# config/packages/test/messenger.yaml
framework:
messenger:
transports:
async:
dsn: 'in-memory://'
Laravel is more convenient here: Queue::fake() is one line with rich assertions built in.
Monitoring
Laravel: Horizon
Horizon is a full dashboard for Redis queue monitoring: real-time metrics, throughput graphs, failed job inspection and retry, worker balancing. Install with composer require laravel/horizon.
// config/horizon.php
'environments' => [
'production' => [
'supervisor-payments' => [
'queue' => ['payments'],
'balance' => 'auto',
'minProcesses' => 2,
'maxProcesses' => 10,
],
],
],
Symfony: No Built-in Dashboard
Symfony has no Horizon equivalent. Monitoring is built through Prometheus + Grafana with custom metrics, or via event subscribers on WorkerMessageHandledEvent and WorkerMessageFailedEvent.
Performance Benchmarks
Tests on same machine (4 cores, 16GB RAM, Redis 7, PHP 8.4, 4 workers).
Throughput (jobs/sec)
| Scenario | Laravel Queues | Symfony Messenger |
|---|---|---|
| Simple job (DB write) | ~2,800 | ~2,400 |
| Email sending | ~1,200 | ~1,100 |
| Heavy job (API + DB + cache) | ~180 | ~170 |
| Large model serialization | ~900 | ~1,500* |
*Symfony is faster on serialization because it passes only IDs, not entire models.
Memory Usage (per worker)
| Metric | Laravel | Symfony |
|---|---|---|
| Baseline | ~40 MB | ~35 MB |
| After 10,000 jobs | ~55 MB | ~42 MB |
| Memory drift over 24h | ~15 MB | ~8 MB |
When to Choose What
Laravel Queues
- Already on Laravel — no reason to bring in Symfony Messenger
- Need batches with progress and callbacks
- Need Horizon for monitoring
- Simple architecture: one handler per task
- Redis as primary broker
Symfony Messenger
- Already on Symfony — no reason to bring in Laravel
- Event-driven architecture: one event → many handlers
- RabbitMQ with advanced routing (exchanges, bindings)
- CQRS: separate command and query buses
- Microservice architecture with different consumers
Neither
- Less than 100 jobs per day — cron or
pcntl_forksuffices - Real-time WebSocket — look at Centrifugo, Mercure
- Event streaming (millions of events) — Kafka
- Need exactly-once semantics — neither guarantees it, need Kafka/Pulsar
Final Checklist
Choosing
- [ ] Determine stack: Laravel → Queues, Symfony → Messenger
- [ ] Determine transport: Redis (simple), RabbitMQ (routing), SQS (AWS), Database (minimal infra)
- [ ] Determine pattern: Job-centric (Laravel) or Event-driven (Symfony)
- [ ] Estimate volume: < 1K jobs/hr → database, < 100K → Redis, > 100K → RabbitMQ/Kafka
Design
- [ ] Split queues by priority: payments (critical), emails (high), reports (low)
- [ ] Configure retry: exponential backoff, max_retries, dead letter queue
- [ ] Make jobs idempotent — re-runs must not create duplicates
- [ ] Pass only IDs, not large objects — load from DB in the handler
Production
- [ ] Supervisor/systemd for workers — automatic restart on crash
- [ ] Memory limits:
--memory=128(Laravel),--memory-limit=128M(Symfony) - [ ] Time limits for graceful restart
- [ ] Monitoring: Horizon (Laravel) or Prometheus + Grafana (Symfony)
- [ ] Alerts: failed jobs > threshold, queue depth > threshold, consumer lag
Comments (0)