Run Laravel Behind PgBouncer in Transaction Mode Without Breaking Booleans

Run Laravel behind PgBouncer in transaction mode: fix prepared statement errors with Laravel 13 pooled connections and keep migrations on a direct endpoint.

Steven Richardson
Steven Richardson
· 11 min read

Forty PHP-FPM workers across four servers is 160 idle Postgres connections, so you put PgBouncer in front of the database. Within an hour production is throwing prepared statement "pdo_stmt_00000001" does not exist on maybe one request in twenty. Every answer you find says set PDO::ATTR_EMULATE_PREPARES => true, and that does clear the error — right before where('is_active', true) starts throwing a datatype mismatch. This is the Laravel PgBouncer two-bug chain, and as of Laravel 13.17 you no longer have to hand-roll the fix.

Reproduce the prepared statement error against a transaction-mode pooler#

Stand up a pooler in transaction mode so you can see the failure on demand rather than in production. This Compose service is the smallest thing that reproduces it:

# docker-compose.yml
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    ports: ["5432:5432"]

  pgbouncer:
    image: edoburu/pgbouncer:latest
    environment:
      DB_HOST: postgres
      DB_NAME: app
      DB_USER: postgres
      DB_PASSWORD: secret
      POOL_MODE: transaction      # the mode that breaks prepared statements
      MAX_CLIENT_CONN: "500"
      DEFAULT_POOL_SIZE: "20"
      AUTH_TYPE: scram-sha-256
    ports: ["6432:5432"]          # app traffic goes here, 5432 stays direct
    depends_on: [postgres]

There are three pool modes and only one of them is interesting. session hands a client a backend connection until it disconnects — full Postgres semantics, and useless for PHP-FPM because a worker holds its connection for the life of the process. statement returns the backend after every single statement, which makes multi-statement transactions impossible. transaction returns the backend at COMMIT, which is the only mode that actually pools PHP traffic, and it is the mode that breaks things.

The breakage is a protocol mismatch. PDO issues Parse/Bind/Execute as separate messages. In transaction mode the pooler is free to hand the second message to a different backend, and that backend has never heard of your statement:

SQLSTATE[26000]: Invalid sql statement name: 7 ERROR:
prepared statement "pdo_stmt_00000001" does not exist

It is intermittent because it only fires when the pooler happens to switch backends between the parse and the execute — which is exactly why it passes CI and fails under load.

Check your Laravel and PgBouncer versions before choosing a fix#

Run composer show laravel/framework and pgbouncer --version first, because the correct fix is entirely determined by those two numbers. Laravel 13.17.0 (released 23 June 2026, PR #60425) added first-class transaction-pooler support to the pgsql driver. PgBouncer 1.21 added max_prepared_statements for protocol-level statement tracking, and 1.25.2 is the current stable release.

Your stack Do this
Laravel 13.17+ Use 'pooled' => true and a direct endpoint. Nothing else needed.
Laravel 12 or earlier Set ATTR_EMULATE_PREPARES manually and fix boolean binding yourself.
Can't touch app config Set max_prepared_statements on the pooler, with the caveat below.

If you are on Neon, Supabase, or AWS RDS Proxy, you are already behind a transaction-mode pooler whether you chose one or not. The same applies to the pooled endpoint on Laravel Cloud's serverless Postgres — the connection string you were given is the pooler, not the database.

Configure the Laravel PgBouncer connection in pooled mode#

Set pooled to true on the pgsql connection and point host/port at the pooler. Laravel's ConfiguresPooledConnections trait sees the flag and stamps PDO::ATTR_EMULATE_PREPARES => true onto the connection options for you — including onto any read and write arrays you have configured for replicas.

// config/database.php
'pgsql' => [
    'driver' => 'pgsql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '6432'),      // PgBouncer, not Postgres
    'database' => env('DB_DATABASE', 'app'),
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
    'charset' => 'utf8',
    'prefix' => '',
    'prefix_indexes' => true,
    'search_path' => 'public',
    'sslmode' => 'prefer',
    'pooled' => env('DB_POOLED', false),
    'direct' => array_filter([
        'host' => env('DB_DIRECT_HOST'),
        'port' => env('DB_DIRECT_PORT'),
        'username' => env('DB_DIRECT_USERNAME'),
        'password' => env('DB_DIRECT_PASSWORD'),
        'sslmode' => env('DB_DIRECT_SSLMODE'),
    ]),
],

Emulated prepares are not string concatenation. PDO still quotes and escapes every bound value using the driver's own quoting; the only change is that interpolation happens client-side and the finished SQL goes over the wire in one round trip. You lose the plan-cache benefit of server-side prepares, not the injection protection.

Add a direct endpoint for migrations and DDL#

Fill in the DB_DIRECT_* variables so Laravel has a second route to Postgres on port 5432, bypassing the pooler entirely. This is not optional bookkeeping — configuring 'pooled' => true with an empty direct array triggers an E_USER_WARNING telling you that migrations will still traverse the pooler.

# .env
DB_HOST=pgbouncer.internal
DB_PORT=6432
DB_POOLED=true

DB_DIRECT_HOST=postgres.internal
DB_DIRECT_PORT=5432
DB_DIRECT_SSLMODE=require

Once a direct endpoint exists, the Migrator appends ::direct to the connection name on its own, so plain php artisan migrate already avoids the pooler. php artisan db also defaults to the direct endpoint in pooled mode; pass --pooled when you specifically want to debug through PgBouncer. You can route anything else the same way:

php artisan migrate                      # auto-routed to pgsql::direct
php artisan db                           # direct session
php artisan db --pooled                  # through PgBouncer, for reproducing pool bugs
php artisan schema:dump --database=pgsql # dump/load use the direct variant too
// Route a single query at the direct endpoint from application code.
DB::connection('pgsql::direct')->statement('SELECT pg_advisory_lock(?)', [42]);

Laravel deliberately forces ATTR_EMULATE_PREPARES => false on the direct config unless you set it explicitly, so the direct connection keeps native server-side prepares and full session semantics. That matters for expand-and-contract migrations, where long-running DDL and lock timeouts need a stable session.

Enable max_prepared_statements if you cannot upgrade Laravel#

Set max_prepared_statements in pgbouncer.ini when the application config is out of your hands. PgBouncer 1.21+ tracks protocol-level prepared statements and rewrites them onto whichever backend serves the query. The default is 0, meaning off:

; pgbouncer.ini
[databases]
app = host=postgres.internal port=5432 dbname=app

[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200   ; default is 0 (disabled)
max_client_conn = 500
default_pool_size = 20
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

Verify this against your own driver before you rely on it. pgbouncer#991 is the PHP-specific report: with max_prepared_statements > 0, a client that deallocates statements by its own name produces ERROR: prepared statement "pdo_stmt_00000001" does not exist on the Postgres side for every request, because PgBouncer renamed the statement to PGBOUNCER_1 and never parses the DEALLOCATE. It was closed without a pooler-side fix — PgBouncer does not parse SQL by design. Behaviour differs between pdo_pgsql and the native pgsql extension, so run a load test and tail the Postgres log rather than trusting a blog post, this one included.

Fix boolean binding on Laravel 12 and earlier#

Register a custom connection resolver if you are stuck below 13.17, because emulated prepares alone will hand you a second failure. Illuminate\Database\Connection::prepareBindings() casts PHP booleans to integers, and since PHP 7.4.18 tightened PDO_pgsql parameter typing, an interpolated 1 is no longer coerced into a boolean column:

SQLSTATE[42804]: Datatype mismatch: 7 ERROR:
column "is_active" is of type boolean but expression is of type integer

Laravel 13's PostgresConnection now overrides prepareBindings() and checks usesEmulatedPrepares(), emitting 'true'/'false' instead of 1/0. On older versions, reproduce that override yourself:

// app/Database/PgBouncerPostgresConnection.php
namespace App\Database;

use DateTimeInterface;
use Illuminate\Database\PostgresConnection;

class PgBouncerPostgresConnection extends PostgresConnection
{
    public function prepareBindings(array $bindings): array
    {
        foreach ($bindings as $key => $value) {
            if ($value instanceof DateTimeInterface) {
                $bindings[$key] = $value->format($this->getQueryGrammar()->getDateFormat());
            } elseif (is_bool($value)) {
                // Postgres accepts the literals, not 1/0, once prepares are emulated.
                $bindings[$key] = $value ? 'true' : 'false';
            }
        }

        return $bindings;
    }
}
// app/Providers/AppServiceProvider.php — register() method
use App\Database\PgBouncerPostgresConnection;
use Illuminate\Database\Connection;

Connection::resolverFor('pgsql', function ($connection, $database, $prefix, $config) {
    return new PgBouncerPostgresConnection($connection, $database, $prefix, $config);
});

The community packages t1nkl/postgres-pgbouncer-extension and vermaysha/pgbouncer-laravel-extension do the same thing behind a service provider and are both still receiving commits. They are perfectly reasonable, but for thirty lines of code that Laravel 13 makes redundant, I keep it in the application and delete it at upgrade time.

Audit your app for session-scoped Postgres features#

Grep the codebase for anything that assumes one client owns one backend for longer than a transaction, because transaction mode silently breaks all of it:

grep -rn "pg_advisory_lock\|pg_try_advisory_lock" app/ database/
grep -rn "LISTEN \|NOTIFY \|WITH HOLD" app/
grep -rn "search_path\|SET LOCAL\|CREATE TEMP" app/ config/

Advisory locks are the dangerous one. They are session-scoped, so a scheduler mutex or a withoutOverlapping() implementation backed by pg_advisory_lock acquires a lock on a backend that gets returned to the pool moments later — it does not error, it just stops mutexing. Route those at pgsql::direct, or move the lock to Redis.

SET search_path is worse if you do schema-per-tenant. The setting survives on the pooled backend and the next request from a different tenant inherits it. If your multi-tenant Laravel application switches schemas per request, use SET LOCAL inside an explicit transaction, or keep tenant resolution in a WHERE clause instead of the search path.

DB::transaction() itself is completely safe. It compiles to a real BEGIN/COMMIT, which is precisely the unit that transaction mode pools on. The hazard is always state set outside a transaction that assumed session continuity.

Size the pool against your PHP-FPM worker count#

Work out default_pool_size from your actual concurrency, not from a default you copied. max_client_conn is how many clients PgBouncer will accept; default_pool_size is how many real Postgres connections it opens per database/user pair, and that second number is the one protecting Postgres:

max_client_conn    >= pm.max_children x web servers + queue workers + cron
default_pool_size  ~= peak concurrent queries, not peak concurrent requests

Forty pm.max_children across four servers is 160 clients, but those workers spend most of their time rendering rather than querying, so a default_pool_size of 20–25 usually saturates fine. Count queue workers separately — they hold long-lived connections and a --max-jobs recycle briefly doubles the count, which matters when you are running many workers per host. Octane changes the arithmetic again: workers keep PDO handles alive across requests, so an Octane deployment needs pool size sized against worker count rather than request concurrency.

Read/write splitting also interacts with pooling in a way that catches people out. A sticky read-after-write guarantee is scoped to the request, not to a backend connection, so it still holds — but it pins that request to the write pool, which quietly increases write-pool pressure.

Verify the Laravel PgBouncer setup with SHOW POOLS and a boolean round trip#

Connect to the PgBouncer admin console and confirm traffic is actually being pooled rather than passed through one connection per client:

psql -h pgbouncer.internal -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
 database | user     | cl_active | cl_waiting | sv_active | sv_idle | pool_mode
----------+----------+-----------+------------+-----------+---------+------------
 app      | app_user |       118 |          0 |         6 |      14 | transaction

118 clients on 20 server connections with cl_waiting at zero is a healthy pool. Sustained cl_waiting above zero means default_pool_size is too small; sv_idle permanently near default_pool_size means it is larger than you need.

Then pin the boolean behaviour with a test, so an upgrade or a config change cannot silently reintroduce the bug:

// tests/Feature/PooledConnectionTest.php
it('round-trips booleans through the pooled connection', function () {
    $user = User::factory()->create(['is_active' => true]);

    expect(User::where('is_active', true)->whereKey($user->id)->exists())->toBeTrue();
    expect(User::where('is_active', false)->whereKey($user->id)->exists())->toBeFalse();
});

it('uses emulated prepares on the pooled connection only', function () {
    $pooled = DB::connection('pgsql')->getConfig('options');
    $direct = DB::connection('pgsql::direct')->getConfig('options');

    expect($pooled[PDO::ATTR_EMULATE_PREPARES])->toBeTrue();
    expect($direct[PDO::ATTR_EMULATE_PREPARES])->toBeFalse();
});

Run the suite in parallel (php artisan test --parallel) against the pooler. Serial tests rarely trip the backend switch that causes the original error; parallel ones do.

Wrapping Up#

If you are on Laravel 13.17 or later, this is two config keys: 'pooled' => true and a populated direct array. Everything after that is auditing the session-scoped things transaction mode quietly breaks — advisory locks and search_path above all. Add a health check that exercises the pooled connection so a misconfigured pooler fails your readiness probe instead of your customers, and turn on Eloquent strict mode while you are in there — fewer queries per request is the cheapest pool sizing there is.

FAQ#

Why do I get 'prepared statement does not exist' with Laravel and PgBouncer?

PDO sends PREPARE and EXECUTE as separate protocol messages. In PgBouncer's transaction pooling mode the server connection is returned to the pool at every COMMIT, so the execute can land on a different backend that never saw the prepare, and Postgres answers with SQLSTATE[26000] ... prepared statement "pdo_stmt_00000001" does not exist. It is intermittent because it only happens when the pooler actually swaps backends mid-request. The fix is to stop using server-side prepared statements on the pooled connection, or to let PgBouncer track them with max_prepared_statements.

Should I use transaction or session pool mode for Laravel?

Transaction mode, in almost every case. Session mode gives one client exclusive use of a backend connection until it disconnects, which for PHP-FPM means one Postgres connection per worker process — exactly the problem you installed a pooler to solve. Transaction mode is the only setting that gives you real connection multiplexing under PHP, and the compatibility cost is well understood: no server-side prepared statements and no session-scoped state.

How do I set PDO::ATTR_EMULATE_PREPARES in Laravel?

On Laravel 13.17 and later you do not set it directly — add 'pooled' => true to the pgsql connection in config/database.php and the framework stamps PDO::ATTR_EMULATE_PREPARES => true onto the connection options, including any read and write arrays. On earlier versions, add an options array to the connection with PDO::ATTR_EMULATE_PREPARES => true. Either way, the direct endpoint should keep native prepares, which Laravel enforces by defaulting the direct config's emulation flag to false.

Why do boolean columns fail after enabling emulated prepares in Laravel?

Illuminate\Database\Connection::prepareBindings() casts PHP booleans to integers, and once prepares are emulated that integer is interpolated into the SQL as a literal 1. PostgreSQL will not coerce an integer literal into a boolean column, so you get SQLSTATE[42804]: Datatype mismatch: 7 ERROR: column "is_active" is of type boolean but expression is of type integer. Laravel 13's PostgresConnection overrides prepareBindings() and emits 'true'/'false' when it detects emulated prepares. On Laravel 12 and earlier you need a connection subclass registered through Connection::resolverFor(), or one of the community packages that does the same thing.

Do Laravel migrations work through PgBouncer?

They can run, but they should not. DDL, schema:dump, lock timeouts and anything long-running want a stable session, which transaction mode cannot guarantee. Configure a direct endpoint on the connection and Laravel's Migrator appends ::direct to the connection name automatically, so plain php artisan migrate bypasses the pooler with no flags. If you set 'pooled' => true without a direct endpoint, Laravel raises an E_USER_WARNING telling you exactly this.

Does PgBouncer break Laravel database transactions or advisory locks?

Transactions are fine. DB::transaction() maps to a real BEGIN/COMMIT, which is the exact unit transaction mode pools on, so the whole transaction runs on one backend. Advisory locks are a different story: pg_advisory_lock is session-scoped, so the lock is released as soon as the backend returns to the pool, and the failure is silent rather than an exception. Route advisory locks at the direct connection, or move that mutex to Redis or the cache lock driver.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.