Why Read/Write Splitting Breaks More Apps Than It Fixes
Adding a read replica feels like a free performance win. In practice, replication lag — even 50 ms — causes subtle, hard-to-reproduce bugs: a user creates a record, gets redirected, and the next page query hits the replica before the write has propagated. Laravel ships with first-class support for read/write connections, but the defaults are more dangerous than most engineers realise.
Configuring Read/Write Connections
Laravel's config/database.php accepts read and write keys inside any connection. The driver merges them with the top-level config, so you only override what differs:
'mysql' => [
'driver' => 'mysql',
'read' => [
'host' => [
env('DB_READ_HOST_1', '10.0.1.11'),
env('DB_READ_HOST_2', '10.0.1.12'),
],
],
'write' => [
'host' => env('DB_WRITE_HOST', '10.0.1.10'),
],
'sticky' => true,
'database' => env('DB_DATABASE', 'app'),
'username' => env('DB_USERNAME', 'app'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
],
When multiple hosts are listed under read, Laravel picks one at random per request — a simple but effective load-distribution strategy.
The sticky Option: What It Actually Does
With 'sticky' => true, Laravel tracks whether a write query has been executed during the current request lifecycle. If it has, all subsequent reads for that request are routed to the write connection, bypassing the replica entirely.
This is implemented in Illuminate\Database\Connection via a simple boolean flag:
// Simplified from the framework source
if ($this->recordsHaveBeenModified() && $this->getConfig('sticky')) {
return $this->getWritePdo();
}
return $this->getReadPdo();
The flag is reset at the start of each request via the DatabaseServiceProvider, so there is no cross-request leakage in FPM. Under Laravel Octane, however, the connection object is reused across requests — you must call DB::resetRecordsModified() in an octane:request listener or use a middleware:
// app/Http/Middleware/ResetDbStickyFlag.php
public function handle(Request $request, Closure $next): Response
{
DB::connection()->resetRecordsModified();
return $next($request);
}
Register it early in the global middleware stack.
Forcing a Connection Explicitly
Sometimes you need deterministic routing regardless of sticky state — for example, an admin dashboard that must always read fresh data:
$users = DB::connection('mysql::write')
->table('users')
->where('active', true)
->get();
Or with Eloquent:
User::on('mysql::write')->where('active', true)->get();
// Alternatively, use the useWritePdo() scope:
User::query()->useWritePdo()->where('active', true)->get();
useWritePdo() is available on the query builder directly and is the cleanest option for one-off overrides.
Connection Pooling: PgBouncer and ProxySQL
Laravel opens a new PDO connection per worker process. Under FPM with 50 workers × 4 app servers, you can exhaust PostgreSQL's max_connections (default 100) instantly.
PgBouncer (PostgreSQL) in transaction pooling mode is the standard solution. Each query borrows a server connection for its duration, then returns it to the pool. Your Laravel DB_HOST points to PgBouncer, not Postgres directly.
Key caveats with PgBouncer transaction mode:
SETstatements and advisory locks are not safe — they do not persist across queries.- Prepared statements require
server_reset_queryor disabling them in Laravel: set'options' => [PDO::ATTR_EMULATE_PREPARES => true]in your connection config.
ProxySQL serves the same role for MySQL/MariaDB and additionally supports query routing rules — you can route SELECT statements to replicas and writes to the primary at the proxy layer, removing that concern from application config entirely.
Takeaways
- Enable
stickyon every read/write split config — replication lag bugs are silent and costly. - Under Octane, reset the modified flag explicitly; FPM handles it automatically.
- Use
useWritePdo()for admin or post-write reads that must be consistent. - Point Laravel at PgBouncer/ProxySQL rather than the database directly to avoid connection exhaustion.
- Disable PDO prepared statements when using PgBouncer in transaction pooling mode.
- Test replica routing in CI by asserting which connection a query targets using
DB::listen().