Fixed: Laravel Migrations Failing With PgBouncer/Connection Pooler

The problem: Running php artisan migrate against a PostgreSQL database sitting behind a transaction-mode connection pooler (PgBouncer, AWS RDS Proxy, Neon) fails or hangs, even though normal queries work fine.

Environment: Laravel 13.x, PostgreSQL 15/16, PgBouncer (transaction pooling mode), PHP 8.3

The Error

● ● ●  terminal
SQLSTATE[08P01]: : prepared statement "pdo_stmt_00000001"
already exists

SQLSTATE[25001]: Active sql transaction: CREATE INDEX CONCURRENTLY cannot
run inside a transaction block

Why It Happens

Transaction-mode poolers like PgBouncer don't give your application a dedicated, persistent connection to Postgres — instead, they hand out a connection from a shared pool only for the duration of a single transaction, then return it to the pool for another client to use. This breaks two things PHP's PDO driver and Laravel's schema builder normally rely on:

1. Prepared statement caching. PDO caches prepared statements by name so repeated queries don't re-parse SQL every time. But under a transaction pooler, the underlying Postgres connection you get handed can change between requests — so a prepared statement name that existed on one physical connection may collide with a statement of the same name already prepared on a different connection you've now been handed.

2. Session-level and multi-statement DDL operations. Some schema operations — like CREATE INDEX CONCURRENTLY — explicitly cannot run inside a transaction block at all, but a transaction-mode pooler wraps essentially everything in an implicit transaction to manage the pooling, conflicting directly with what the migration needs to do.

The Fix

Laravel 13 added first-party support for exactly this setup. Mark the pooled connection explicitly in your database config:

config/database.php
'pgsql' => [
    'driver' => 'pgsql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '6432'),
    'database' => env('DB_DATABASE'),
    'pooled' => true,
],

With pooled => true set, Laravel automatically handles emulated prepares and correct boolean binding for you, removing the prepared-statement collision problem without any manual PDO configuration.

For schema and DDL operations that specifically need a real, direct connection (migrations included), append ::direct to the connection name so Laravel routes around the pooler entirely:

php artisan migrate --database=pgsql::direct

Make sure pgsql::direct (or whatever name you choose) points at the actual Postgres port, not the pooler's port, in your .env:

DB_PORT=6432       # PgBouncer pooled port, used by the app
DB_DIRECT_PORT=5432  # Real Postgres port, used only for migrations

Step-by-Step

  1. Confirm you're on Laravel 13 or later — this fix relies on the framework's native pooler support.
  2. Add 'pooled' => true to your main pgsql connection config for regular application queries.
  3. Add a second connection entry (e.g. pgsql::direct) pointing at Postgres's real port, bypassing PgBouncer, for schema-level work.
  4. Run migrations explicitly against the direct connection: php artisan migrate --database=pgsql::direct.
  5. Update your deploy script/CI pipeline to always use the direct connection for the migrate step going forward.
  6. Re-test any CREATE INDEX CONCURRENTLY or other transaction-incompatible DDL specifically, since these are the most common migration failures under pooling.

Edge case: If you're on Laravel 12 or earlier and can't upgrade immediately, none of this native pooled/::direct handling exists yet — your only real options are configuring PgBouncer itself in session mode (not transaction mode) for the connection Laravel's migrator uses, or running migrations through a completely separate direct connection string that bypasses the pooler at the infrastructure level rather than the framework level.

Still hitting pooler-related migration errors after this setup? Reach out on the Contact page and I'll help debug your specific pooling config.

Advertisement
Advertisement