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

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

4 min read Mohamed Said Mohamed Said

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

Scaling a Laravel application's database layer is rarely about raw query optimisation alone. Once you add a read replica, you immediately inherit a class of subtle bugs caused by replication lag. Laravel ships with first-class support for read/write connections, but the defaults can surprise you in production.

Configuring Read/Write Connections

Laravel's config/database.php accepts a read and write key inside any connection definition. Both accept an array of hosts, and Laravel picks one at random per request.

'mysql' => [
    'driver' => 'mysql',
    'read' => [
        'host' => [
            env('DB_READ_HOST_1', '10.0.1.10'),
            env('DB_READ_HOST_2', '10.0.1.11'),
        ],
    ],
    'write' => [
        'host' => [env('DB_WRITE_HOST', '10.0.1.1')],
    ],
    'sticky' => true,
    'database' => env('DB_DATABASE', 'app'),
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
],

Every SELECT goes to a random read host; every INSERT, UPDATE, DELETE, and DDL goes to the write host.

The sticky Option and Why It Exists

Without sticky, a user who just submitted a form might immediately read their own write from a replica that hasn't caught up yet — a classic "I just saved this, where did it go?" bug.

When sticky => true, Laravel records whether the write connection was used during the current request. If it was, all subsequent reads in that same request are routed to the write host instead of a replica.

// Internally, Connection::$recordsModified drives this.
// After any write, Laravel sets it to true and useReadPdo() returns false.

This is a per-request flag, so it resets on the next HTTP request. It is not a session-level concept — long-running queue workers or CLI commands do not benefit from it automatically.

Forcing a read from the write host explicitly:

$user = DB::connection('mysql::write')
    ->table('users')
    ->find($id);

// Or via Eloquent:
$user = User::on('mysql::write')->find($id);

Use this sparingly — it defeats the purpose of replicas — but it is the right tool after a critical write in a job.

Connection Pooling with PgBouncer

Laravel opens a new PDO connection per worker process. Under Octane or high-concurrency FPM, this means hundreds of connections hitting your database simultaneously. PgBouncer (PostgreSQL) and ProxySQL (MySQL) sit in front of the database and multiplex many application connections onto a smaller pool.

PgBouncer in transaction-mode is the most efficient but breaks SET statements, advisory locks, and prepared statements. Configure Laravel to disable server-side prepared statements:

'pgsql' => [
    'driver' => 'pgsql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '6432'), // PgBouncer port
    'options' => [
        PDO::ATTR_EMULATE_PREPARES => true, // avoids named prepared statements
    ],
    // ...
],

Alternatively, run PgBouncer in session mode if you rely on advisory locks or LISTEN/NOTIFY — you lose some multiplexing efficiency but retain full feature compatibility.

Handling Replication Lag in Jobs

Queue workers are long-lived processes. sticky does not help them. After a job writes data and then reads it back, it may hit a stale replica.

class ProcessOrderJob implements ShouldQueue
{
    public function handle(): void
    {
        // Write
        $order = Order::create([...]);

        // Explicitly read from write host to avoid lag
        $fresh = Order::on('mysql::write')->find($order->id);

        // Or simply use the model already in memory:
        $fresh = $order->refresh(); // still hits read by default!
        // Better:
        DB::connection()->recordsModified(); // check flag — not reliable in jobs
    }
}

The cleanest pattern: pass the already-hydrated model or its ID into subsequent steps rather than re-querying immediately after a write.

Takeaways

  • Enable sticky => true to prevent users from reading stale data after their own writes within the same HTTP request.
  • Use DB::connection('mysql::write') explicitly in jobs and CLI commands where sticky has no effect.
  • Disable PDO prepared statements (ATTR_EMULATE_PREPARES) when routing through PgBouncer in transaction mode.
  • Prefer session-mode PgBouncer if your application uses advisory locks, LISTEN/NOTIFY, or pg_temp schemas.
  • Avoid re-querying immediately after a write in queue workers; pass hydrated models or use the write connection explicitly.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does Laravel's `sticky` option work inside queue jobs?
`sticky` is a per-request, in-memory flag on the Connection object. Queue workers are long-lived processes that handle many jobs sequentially, so the flag is not reset between jobs reliably. Always use `DB::connection('mysql::write')` or pass already-loaded models when you need to read immediately after a write inside a job.
Q02 Why do prepared statements break with PgBouncer in transaction mode?
PgBouncer in transaction mode can route consecutive transactions to different backend connections. Named prepared statements are tied to a specific backend connection, so a statement prepared on connection A is not available on connection B. Setting `PDO::ATTR_EMULATE_PREPARES => true` makes PDO interpolate parameters client-side, avoiding server-side named statements entirely.
Q03 How do I verify which connection (read or write) a query actually used?
Enable the query log with `DB::enableQueryLog()` and inspect `DB::getQueryLog()`. For deeper inspection, attach a `DB::listen()` listener that logs the connection name alongside the SQL. In Telescope or Debugbar, the connection name is shown per query in the database tab.

Continue reading

More Articles

View all