Symfony Messenger Transports Compared: AMQP vs Doctrine vs Redis for Production SaaS

#symfony messenger transport comparison
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Every Symfony Messenger setup starts with one line of configuration: the transport DSN. That single line decides more about your production behavior than most of the code around it. It determines whether messages survive a crash, whether urgent jobs can jump the queue, and what breaks first when traffic spikes. This Symfony Messenger transport comparison covers the three options most SaaS teams choose between, AMQP via RabbitMQ, Doctrine, and Redis, and ends with a decision matrix built on what each transport does in production rather than what its documentation promises.

We covered the operational side of running Messenger under load, backpressure, poison pills and rate limiting, in Symfony Messenger at Scale. This post is about the decision that comes before any of that: which transport to run in the first place.

What the transport actually decides

Your handlers do not know which transport delivered their messages. That is worth saying early, because it means the choice is reversible. Swapping Doctrine for AMQP later is a configuration change plus a queue migration, not a rewrite. Teams agonize over this decision as if it were permanent. It is not.

What the transport does lock in is operational behavior. Does a message survive a worker crash, a broker restart, a full server reboot? Can an invoice email overtake a nightly sync job? How quickly do workers receive new messages, and what happens to a message that keeps failing? And how much new infrastructure does your team now have to run and monitor at 3 a.m.? Those are the questions where the three transports actually differ.

Doctrine: the queue you already run

The Doctrine transport stores messages in a messenger_messages table inside your existing relational database. For a team already running PostgreSQL or MySQL, the infrastructure cost is zero. You add no new service and no failure mode your team has not seen before. Backups cover your queue because your queue is a table.

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: 'doctrine://default?queue_name=async'
                options:
                    redeliver_timeout: 3600

Durability is as strong as your database, which is usually the strongest guarantee available anywhere in your stack. If you dispatch inside a Doctrine transaction, the message and your business data commit or roll back together, something neither Redis nor RabbitMQ can give you without an outbox pattern on top.

The limits show up under volume. Workers poll the table, one second by default, so delivery latency is bounded by the polling interval. There are no message priorities; the transport processes in insertion order per queue name, and the workaround of separate queue names with dedicated workers is coarse. The bigger problem is write amplification: every message is an INSERT, an UPDATE when claimed, and a DELETE when acknowledged. At tens of thousands of messages per day that churn is invisible. At hundreds of thousands, on PostgreSQL, the dead tuples put real pressure on autovacuum, and your queue starts competing with your application for I/O on the same database instance.

Our rule of thumb: below roughly 50,000 messages a day with no hard latency requirements, Doctrine is the right default, and picking anything else buys operational complexity you do not need yet.

Redis: fast, with fine print on persistence

The Redis transport is built on Redis Streams. Delivery is near-instant because workers block on the stream instead of polling a table, and throughput comfortably exceeds what the Doctrine transport handles. If you already run Redis for caching or sessions, the marginal infrastructure cost is small.

framework:
    messenger:
        transports:
            async_fast:
                dsn: 'redis://redis:6379/messages'
                options:
                    stream_max_entries: 100000
                    delete_after_ack: true

The fine print is durability. Redis persists to disk only as well as you configure it to. With default RDB snapshotting, a crash loses everything since the last snapshot, which can mean minutes of accepted messages. For a queue that is unacceptable, so AOF becomes mandatory: appendonly yes with appendfsync everysec. That still concedes up to one second of writes in a hard crash, and appendfsync always costs enough throughput that it erases much of the reason you picked Redis. Managed Redis products differ widely here, so check what your provider actually fsyncs before trusting it with jobs you cannot afford to lose.

Memory is the other boundary. A queue backlog lives in RAM, so a consumer outage that would be a boring table growth incident on Doctrine becomes an eviction or out-of-memory incident on Redis. Cap stream length with stream_max_entries and alert on memory, not only on queue depth.

Redis fits time-sensitive work where a rare loss is survivable or where AOF is configured deliberately: notification fan-out, cache warming, webhook retries where the upstream source of truth lets you replay.

AMQP: the most capable option, and one more service to operate

RabbitMQ through the AMQP transport is the only option of the three that was built as a message broker. It pushes messages to consumers instead of being polled, supports per-message priorities natively, routes through exchanges and binding keys, and handles dead-lettering in the broker itself: a rejected message moves to a dead letter exchange with its own routing, no application code involved. Consumer prefetch gives you real backpressure control, and quorum queues give you replicated durability across nodes.

framework:
    messenger:
        transports:
            async_priority:
                dsn: '%env(RABBITMQ_DSN)%'
                options:
                    exchange:
                        name: app_events
                        type: direct
                    queues:
                        high:
                            binding_keys: [high]
                            arguments:
                                x-max-priority: 10

The cost is that RabbitMQ is a service with its own operational surface: Erlang VM memory alarms, disk watermarks, version upgrades, connection churn from PHP's process model, and clustering decisions once you need high availability. None of it is exotic, but somebody on the team now owns it. If nobody wants to, a managed offering like CloudAMQP shifts that burden for a monthly fee.

AMQP earns its keep when you have sustained six-figure daily volume, real priority classes, multiple consumers with different routing needs, or non-PHP services that should share the same broker.

Symfony Messenger transport comparison: the decision matrix

DoctrineRedisAMQP (RabbitMQ)
New infrastructureNoneNone if Redis existsRabbitMQ cluster
DurabilityDatabase-grade, transactional with your dataAOF-dependent, up to 1s loss windowBroker-grade, quorum queues
PrioritiesNo, separate queues onlyNo, separate streams onlyNative per-message
Delivery latencyPolling, about 1sNear-instantNear-instant, push
Comfortable volumeUp to ~50k msg/dayHundreds of thousands per dayMillions per day
Backlog riskTable bloat, vacuum pressureRAM exhaustionDisk watermark
New ops burdenNonePersistence tuningA real second system

Read the table top down against your constraints. Most teams land on Doctrine first, add Redis for a latency-sensitive queue second, and adopt RabbitMQ only when volume or routing complexity forces the issue. Running two transports side by side is normal and well supported. The failure transport in particular should usually stay on Doctrine regardless of what handles the hot path, because you want failed messages to survive anything short of losing the database.

framework:
    messenger:
        failure_transport: failed
        transports:
            failed:
                dsn: 'doctrine://default?queue_name=failed'
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
                    max_delay: 60000

Whatever you choose, monitor queue age, not just depth. A queue with 10,000 fresh messages is a busy afternoon. A queue whose oldest message is 40 minutes old is an incident. Export both to Prometheus: a count(*) plus min(created_at) query for Doctrine, XLEN and the oldest entry ID for Redis, and the rabbitmq_prometheus plugin for AMQP.

Pick for your actual load, not your imagined one

The most common mistake we see in code audits is a transport chosen for traffic that never arrived: a three-node RabbitMQ cluster nursing 8,000 messages a day, operated by a two-person team. The second most common is the opposite, a Doctrine queue quietly degrading a production database because nobody revisited the decision after volume grew tenfold.

Measure your real daily volume, write down your actual latency requirement, and pick the cheapest transport that satisfies both. Revisit once a year. The config change is the easy part.

If you want a second pair of eyes on your Messenger setup, or you are building a SaaS backend and want the queue architecture right from the start, write to hello@wolf-tech.io or visit wolf-tech.io. We will tell you if your queue is boring, which is exactly what a queue should be.