Read Replicas in Symfony: Routing Queries Without Stale-Read Surprises
Adding read replicas in Symfony is one of the highest-leverage scaling moves you can make. Your primary database stops drowning under report queries, dashboards, and search. Reads spread across one or more replicas, write throughput on the primary recovers, and the whole application feels faster. Then a support ticket lands: a user updated their profile, hit save, and the next screen showed the old value. Nothing is broken. The replica simply had not caught up yet, and your query routing sent the read to it.
This is the central tension with read replicas. They are asynchronous by design, which means a replica is always some amount of time behind the primary. That gap, replication lag, is usually milliseconds, but it is never guaranteed to be zero. If you route queries naively, you trade a scaling win for a class of bugs that are intermittent, hard to reproduce, and infuriating for users. The good news is that Doctrine has built-in support for this pattern, and with a few deliberate rules you can get the throughput without the surprises.
How read/write splitting works in Doctrine
Doctrine DBAL ships with a connection wrapper, the PrimaryReadReplicaConnection, that holds two sets of connection parameters: one primary and one or more replicas. The routing rule it enforces is simple and worth memorizing, because most stale-read bugs come from misunderstanding it.
By default, every query goes to a replica until the connection is asked to do something that requires the primary. The moment you start a transaction, or call a write method, the connection switches to the primary and stays there for the rest of the request. In other words, the connection is "sticky" to the primary once it has been promoted. It does not switch back to a replica mid-request.
Configuring it in Symfony is a matter of the DBAL config. A typical setup looks like this:
# config/packages/doctrine.yaml
doctrine:
dbal:
default_connection: default
connections:
default:
driver: 'pdo_pgsql'
server_version: '16'
use_savepoints: true
primary:
url: '%env(resolve:DATABASE_PRIMARY_URL)%'
replica:
replica_one:
url: '%env(resolve:DATABASE_REPLICA_URL)%'
With that in place, a read-heavy endpoint that never opens a transaction will be served entirely from replica_one. An endpoint that writes will be pinned to the primary. That default behavior covers a surprising amount of ground, but it leaves the hard cases to you.
The read-your-own-writes problem
The most common stale-read bug is read-your-own-writes. A user performs an action that writes to the primary, then the application redirects or re-renders and reads the same data back. If that read lands on a replica that has not yet replicated the write, the user sees stale state.
Because Doctrine keeps the connection pinned to the primary after a write within the same request, the simple case is handled: write then read in one request both hit the primary. The danger is across requests. A POST that updates a record, followed by a redirect to a GET that reads it, are two separate requests with two separate connections. The GET starts fresh, defaults to a replica, and may read stale data.
There are a few honest ways to handle this.
The most reliable is to force the read onto the primary when correctness matters more than offloading. Doctrine lets you do this explicitly by calling ensureConnectedToPrimary() on the connection before the query, or by wrapping the read in a transaction (even a read-only one) so the connection promotes itself. For the handful of endpoints where a user reads data they just changed, this is the right call. Routing a small number of consistency-critical reads to the primary costs you almost nothing in throughput and removes the bug entirely.
A second option is to make the write path return the fresh data directly instead of redirecting to a separate read. If the POST handler already has the updated entity in memory from the primary, render the response from that rather than issuing a follow-up read. You sidestep the replica entirely.
A third option, useful for APIs, is a "consistency token." After a write, return a marker (a timestamp or log sequence number) to the client. On the next read, the client sends the token back, and your routing layer checks whether the chosen replica has caught up to that point before serving from it, falling back to the primary if not. This is more machinery than most applications need, but for high-traffic APIs where you cannot afford to send every post-write read to the primary, it scales well.
Be deliberate about what goes where
The biggest practical risk with read replicas in Symfony is not the framework, it is accidental routing. A long-running command, a message handler, or a background job that opens a transaction early will pin itself to the primary for its entire run, quietly undoing the offload you wanted. The reverse also happens: an analytics or export job that you want on a replica gets routed there, then chokes on replication lag because it expected freshly written rows.
Treat routing as an explicit decision per workload, not an accident of whether some code path happened to open a transaction. A few rules that hold up well in production:
- Reporting, search, list views, and analytics are replica-friendly. They tolerate a few seconds of lag and benefit most from offloading.
- Anything in a write transaction stays on the primary. Doctrine already enforces this; do not fight it.
- Reads that immediately follow a user's own write, across requests, should be pinned to the primary for that endpoint only.
- Background jobs and consumers should declare their intent. If a Messenger handler only reads, consider routing it to a replica; if it writes, accept the primary pin and keep the transaction as short as possible.
For Messenger specifically, watch out for handlers that open a transaction at the top "just in case." That single decision sends all their reads to the primary. Open the transaction only around the actual write, and the read portion can be served from a replica.
Make replication lag observable
You cannot route around a problem you cannot see. Replication lag is not constant; it spikes during bulk imports, large migrations, vacuum operations, and traffic surges. A setup that behaves perfectly at low lag can produce a wave of stale-read tickets the moment a batch job pushes lag to several seconds.
Expose lag as a first-class metric. On PostgreSQL you can measure the delay between primary and replica using the replication views, and on MySQL the replica status reports seconds behind the source. Pull that number into your monitoring and alert on it. A practical pattern is a health check that marks a replica as unhealthy above a lag threshold and temporarily routes its traffic to the primary or to a healthier replica. That way a lagging replica degrades throughput gracefully instead of silently serving stale data.
It is also worth logging, in your application, when a consistency-critical read was forced to the primary and why. When you later tune which endpoints need that treatment, you will have real data instead of guesses.
Test the unhappy path
Stale-read bugs hide in production because local and CI environments usually run a single database with zero lag. Everything passes, then the bug only appears once real replication exists. Close that gap by testing with lag deliberately introduced. Spin up a primary and a replica in your integration environment and, for the tests that matter, write to the primary and immediately read in a way that would hit the replica, asserting the routing behavior you expect. Some teams artificially delay replication in a test fixture to force the failure mode and prove their consistency-critical reads really do go to the primary.
The point is not exhaustive coverage. It is to convert an invisible, intermittent production bug into a deterministic test that fails loudly when someone changes routing behavior.
A pragmatic rollout
If you are introducing read replicas to an existing Symfony application, resist the urge to flip everything at once. Start by adding the replica connection with the default routing, then move clearly safe, high-volume read workloads, reporting, dashboards, and search, onto replicas first. Measure the load drop on the primary. Then audit the endpoints where a user reads data they just changed, and pin those reads to the primary explicitly. Finally, add lag monitoring before you widen the rollout, so you are never flying blind.
Done this way, read replicas give you most of the read-scaling benefit early, with the consistency-sensitive cases handled deliberately rather than discovered through support tickets.
Read/write splitting touches query patterns, transaction boundaries, and infrastructure all at once, and the failure mode is subtle enough that it often shows up months after rollout. If you want a second set of eyes on your routing strategy, our code quality consulting and database-focused custom development work covers exactly this kind of scaling decision. Reach out at hello@wolf-tech.io or visit wolf-tech.io, and we will help you scale reads without trading correctness for throughput.

