- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team I worked with last quarter shipped a Symfony service that fanned out 80k transactional emails per hour. Their Laravel-shop neighbours did roughly the same volume on database queues, then on redis, then back to database because of weird tail-latency spikes. Both teams insisted their framework was faster. Neither had ever benchmarked the other side.
So I built the comparison they wouldn't. Same hardware. Same Redis. Same handler logic. 100,000 messages, three runs, the works. The numbers came in close. But the ergonomic gap is larger than I expected, and that's the part that ends up mattering on month six of a project.
The two models in 60 seconds
Laravel ships Queue with the framework. You define a Job class, dispatch it, and a worker picks it up. The Job is a PHP object with a handle() method. Wiring is invisible: Laravel auto-discovers handlers, the framework figures out routing, you mostly think about the Job class itself.
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(
public readonly int $userId,
public readonly string $template,
) {}
public function handle(MailerService $mailer): void
{
$mailer->send($this->userId, $this->template);
}
}
// Dispatching
SendEmailJob::dispatch($user->id, 'welcome')->onQueue('emails');
The Job is the unit of work. Behaviour and payload live in the same class. That's the whole model.
Symfony Messenger splits the same idea in two. You write a message (a dumb DTO) and a handler (a class with __invoke) and a bus routes between them. Routing, retries, middleware: all live in messenger.yaml, not on the message.
namespace App\Message;
final class SendEmailMessage
{
public function __construct(
public readonly int $userId,
public readonly string $template,
) {}
}
namespace App\MessageHandler;
use App\Message\SendEmailMessage;
use App\Service\MailerService;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendEmailHandler
{
public function __construct(private MailerService $mailer) {}
public function __invoke(SendEmailMessage $message): void
{
$this->mailer->send($message->userId, $message->template);
}
}
// Dispatching
$bus->dispatch(new SendEmailMessage($user->id, 'welcome'));
Notice what's missing on the message: retry counts, backoff, queue name. None of it. Those concerns sit in middleware or YAML, not on the DTO. That separation is the whole philosophical difference between the two frameworks' queue stories.
Throughput benchmark: 100k jobs, same Redis
Setup:
- Hardware: a 4-vCPU / 8GB DigitalOcean droplet (the worker) and a separate Redis 7.2 instance on the same private network. Round-trip ping 0.4ms.
- PHP 8.3 with OPcache enabled and JIT off (JIT made no measurable difference on this workload).
- Laravel 11.18 with
predis/predis2.2. Symfony 7.1 Messenger with the Redis transport. - The handler is intentionally trivial: increment a Redis counter and return. No DB, no HTTP, no real mailer. I want to measure the queue layer, not the work.
- 4 worker processes for both sides. Identical Supervisor configs except for the command.
Results (median of 3 runs, lower is better):
| Framework | 100k jobs | jobs/sec | Memory/worker |
|---|---|---|---|
| Laravel Queue (Redis) | 312s | ~321 | 84 MB |
| Symfony Messenger (Redis) | 298s | ~336 | 71 MB |
| Symfony Messenger (Redis Streams + consumer groups) | 274s | ~365 | 73 MB |
Messenger is ~5% faster on the default Redis transport, ~14% faster once you switch to Redis Streams. The gap shrinks to noise (~1-2%) if you give Laravel horizon with its supervisor tuning. On a real workload (actual DB writes, actual SMTP), both frameworks bottleneck on the I/O long before the queue layer matters.
Caveats you should be loud about
I dispatched in a tight loop on a single producer. Real apps dispatch from web requests, which means TLS, connection pooling, and request lifecycle overhead dominates. The benchmark also doesn't model:
- Job size. A 50KB payload (think a full PDF render context) changes the picture; Messenger's serializer is slightly leaner but neither is great at huge payloads. Both push them through
serialize(). - Mixed priority workloads. Both frameworks handle this, but the tuning surface differs.
- Worker restart cost. PHP workers leak memory; both queue runners need periodic restart, and how you tune that matters more than the raw throughput delta.
Take the numbers as "the same order of magnitude," not "Messenger is 5% faster, ship it." If your queue is your bottleneck, switch language, not framework.
Ergonomic differences: typed messages vs Job classes
This is where the daylight opens up. With Laravel's Job class:
class ProcessPaymentJob implements ShouldQueue
{
public int $tries = 5;
public int $backoff = [30, 60, 300];
public int $timeout = 120;
public bool $deleteWhenMissingModels = true;
public function __construct(
public readonly int $orderId,
public readonly Money $amount,
) {}
public function handle(PaymentGateway $gw, EventDispatcher $events): void
{
// ...
}
public function failed(Throwable $e): void
{
// ...
}
}
Eight things live in one class: payload, retry policy, backoff schedule, timeout, missing-model behaviour, the handler, the failure hook, and DI for two services. Discoverable. Reads top-to-bottom. Junior devs ship a working job in 20 minutes.
The cost shows up at scale. Every Job carries the framework's queue contracts. You can't dispatch the same Job to a different handler without subclassing. You can't run a Job synchronously in a test without booting half the queue infrastructure (yes, Bus::fake() helps, but it's another concept). And the Job class becomes a kitchen sink: payload, transport concerns, retry policy, business logic, all bound by Queueable.
Messenger keeps the message a DTO. SendEmailMessage is a struct with two strings. You can:
- Dispatch it to a different handler in tests without touching anything in the message.
- Send the same message through multiple buses (command bus, event bus) with different middleware stacks.
- Use it as a plain value object in a non-queue context. No
Queueabletrait, noShouldQueueinterface, no framework coupling on the DTO.
That last point is the architectural one. A Symfony message is something a domain layer can own. A Laravel Job is something only the application layer can own, because the moment you implements ShouldQueue you've bound your domain to the queue infrastructure.
Middleware vs Job options: same concern, different home
Take "retry up to 3 times, then dead-letter, and log every failure."
Laravel puts that on the Job:
public int $tries = 3;
public int $backoff = 30;
public function failed(Throwable $e): void
{
Log::error('Job failed', [
'job' => static::class,
'reason' => $e->getMessage(),
]);
}
Plus a failed_jobs table for the dead-letter equivalent. Per-job config means each Job class repeats the retry/log boilerplate. Some teams write a base Job class to centralise it. Then they have a base Job class.
Messenger puts it in middleware and YAML:
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
buses:
command.bus:
middleware:
- 'App\Messenger\Middleware\LogFailureMiddleware'
- 'doctrine_ping_connection'
- 'doctrine_close_connection'
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 30000
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
'App\Message\SendEmailMessage': async
namespace App\Messenger\Middleware;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\BusNameStamp;
final class LogFailureMiddleware implements MiddlewareInterface
{
public function __construct(private LoggerInterface $logger) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
try {
return $stack->next()->handle($envelope, $stack);
} catch (\Throwable $e) {
$this->logger->error('message failed', [
'class' => $envelope->getMessage()::class,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
}
Set it once, applies to every message on the bus. That's the win and the cost. The win: you don't repeat retry config across 80 Job classes. The cost: a junior dev opening SendEmailMessage has no idea it gets 3 retries and a 30s delay. That lives in YAML two directories away.
I've watched both bite teams. Laravel apps where the same retry config got copy-pasted into 200 Jobs and nobody noticed three were wrong. Symfony apps where someone changed max_retries in YAML and a critical message started silently failing twice as often. Configuration in code or configuration in config: pick your poison.
Multi-bus Messenger setup vs Laravel queue connections
If you're doing CQRS or splitting commands/events/queries, Messenger's multi-bus support is the actual feature, not a workaround. Here's the canonical setup:
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
allow_no_senders: false
middleware:
- validation
transports:
async_high: '%env(MESSENGER_HIGH_DSN)%'
async_low: '%env(MESSENGER_LOW_DSN)%'
routing:
'App\Message\Command\PlaceOrder': async_high
'App\Message\Event\OrderPlaced': async_low
Three buses, two transports, distinct middleware per bus. The command bus wraps every command in a Doctrine transaction. The event bus tolerates messages with no handlers (a published event with no subscribers shouldn't crash). The query bus is synchronous. All of this is wiring.
Laravel's equivalent is queue connections, which solve a different problem. You define connections (redis, database, sqs) and queues within them (high, low, default). Jobs route by onQueue() or onConnection():
// config/queue.php
'connections' => [
'redis-high' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'high',
'retry_after' => 90,
],
'redis-low' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'low',
'retry_after' => 600,
],
],
// Dispatching
PlaceOrderJob::dispatch($payload)->onConnection('redis-high');
That gets you queue priority. It doesn't get you a per-bus middleware stack. Laravel's Bus::pipeThrough([]) exists as a global middleware mechanism but it's per-application, not per-bus. If you want to apply a transaction wrapper to commands and not to events, you write conditional middleware that introspects the Job. Doable. Not as clean.
The gotcha here: people try to model multi-bus CQRS in Laravel by namespacing Job classes (App\Commands\PlaceOrder, App\Events\OrderPlaced) and writing custom dispatchers. It works, but you've reinvented Messenger badly. If multi-bus CQRS is core to your architecture, Symfony's model is genuinely better. Not faster, better-fitting.
Transports: what each ecosystem actually ships
Messenger transports out of the box: AMQP (RabbitMQ), Doctrine (SQL-backed), Redis (both classic and Streams), Amazon SQS, Beanstalkd, in-memory (test only), and sync (no queue, immediate dispatch). The Redis Streams transport is the interesting one. It gives you consumer groups and replay, which classic Redis lists don't.
Laravel queue drivers out of the box: database, Redis, SQS, Beanstalkd, sync, null. No AMQP first-party (you reach for vladimir-yuldashev/laravel-queue-rabbitmq). No Redis Streams support at all in Laravel 11; everything Redis goes through LPUSH/BRPOPLPUSH. No Doctrine because Laravel doesn't use Doctrine.
If your infra includes RabbitMQ or you want Redis Streams for the consumer-group semantics, Symfony's story is cleaner. If you're SQS-only, both are fine. If you want a "just works on the existing MySQL," Laravel's database driver is great for low volume; Messenger's Doctrine transport is similar but actually queries faster in my testing (it uses SELECT ... FOR UPDATE SKIP LOCKED, Laravel's database driver uses pessimistic locking that contends harder).
When to pick which
After running this benchmark and watching teams choose one or the other for the last few years, my honest take:
Pick Laravel Queue when:
- You're already a Laravel shop. The integration cost of Messenger in Laravel is real (the package exists but you're swimming upstream).
- Your team is 1-5 devs and you value the discoverability of "everything about this job is in one class."
- You're not doing CQRS, not splitting buses, not running RabbitMQ.
- You want Horizon's UI for queue monitoring. It's genuinely good and Laravel-only.
Pick Symfony Messenger when:
- You're on Symfony already. Don't fight it.
- Your team is 10+ devs and you want retry/logging/transaction policy centralised in middleware rather than copy-pasted across job classes.
- You're modelling CQRS or domain events as first-class. Multi-bus is the feature.
- You need AMQP, Redis Streams, or Doctrine transports.
- You want the message DTO to live in a domain layer with zero framework coupling. Messenger lets that happen, Laravel's
ShouldQueuedoesn't.
The throughput numbers are a wash on real workloads. The ergonomic delta is real. The architectural delta, message as DTO vs job as everything, is the one that compounds over years.
What pushed your team toward one model over the other: throughput, ergonomics, or the architectural fit?
If this was useful
The deeper you go on either of these, the more you bump into the same question: where do queue concerns belong relative to your domain? Messenger pushes you toward keeping the message as a clean DTO, but the rest of the architecture is on you. Decoupled PHP walks through the layering — what stays in the framework, what moves out, and how to keep your domain unaware of which queue runtime is wrapping it. It's the architectural layer your codebase reaches for once the framework defaults stop being enough.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)