Read/Write Splitting, Connection Pooling, and Sticky Reads in Laravel
#laravel #database #postgresql #mysql #performance

Read/Write Splitting, Connection Pooling, and Sticky Reads in Laravel

4 min read Mohamed Said Mohamed Said

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:

  • SET statements and advisory locks are not safe — they do not persist across queries.
  • Prepared statements require server_reset_query or 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 sticky on 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().

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does Laravel's sticky option work across multiple requests?
No. Under PHP-FPM the sticky flag is reset at the start of each request. Under Octane, connections are reused, so you must reset it manually with DB::resetRecordsModified() in a middleware or Octane request listener.
Q02 Can I use PgBouncer in transaction pooling mode with Laravel's default PDO settings?
Not safely. Transaction pooling does not preserve prepared statement state between queries. Set PDO::ATTR_EMULATE_PREPARES to true in your connection options, or switch PgBouncer to session pooling if you need native prepared statements.
Q03 When should I route reads at the proxy layer (ProxySQL) vs. in Laravel config?
Proxy-layer routing is better when you have multiple applications sharing the same database cluster, or when you want to change routing rules without deploying application code. Laravel-level config is simpler for single-app setups and gives you fine-grained per-query control.

Continue reading

More Articles

View all