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
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:
'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
- Confirm you're on Laravel 13 or later — this fix relies on the framework's native pooler support.
- Add
'pooled' => trueto your mainpgsqlconnection config for regular application queries. - Add a second connection entry (e.g.
pgsql::direct) pointing at Postgres's real port, bypassing PgBouncer, for schema-level work. - Run migrations explicitly against the direct connection:
php artisan migrate --database=pgsql::direct. - Update your deploy script/CI pipeline to always use the direct connection for the migrate step going forward.
- Re-test any
CREATE INDEX CONCURRENTLYor 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.