Originally published at hafiz.dev
Laravel PostgreSQL connection pooling used to mean one thing: workarounds. If you've ever run Laravel against Supabase, Neon, or a self-hosted PgBouncer, you know the ritual. Set PDO::ATTR_EMULATE_PREPARES, install a community package to patch boolean bindings, keep a second connection config for migrations, and hope nobody on the team forgets which one is which.
That ritual is over. Laravel 13.17 shipped framework-level support for Postgres transaction poolers. One config flag, an optional direct block, and the framework handles everything the pooler requires. Emulated prepares, boolean binding, routing migrations around the pooler. All of it.
This post covers what the feature does, why the problem existed in the first place, the exact setup for Supabase, Neon, and self-hosted PgBouncer, and which of your old hacks you can now delete.
Why Postgres Connections Are Expensive
If you come from MySQL (like most Laravel developers), this problem probably never bit you. MySQL handles connections with lightweight threads. Postgres forks an entire OS process per connection, and each one eats roughly 5-10MB of RAM on the server. The default max_connections on most Postgres setups sits around 100.
Now do the math for a typical Laravel deployment. PHP-FPM opens a fresh database connection for every request. A moderately busy app with 50 concurrent requests is already holding 50 Postgres processes. Add a queue worker or three, a scheduler, and a second app server, and you're staring at FATAL: too many connections in your logs.
Octane makes this worse, not better. Persistent workers hold their database connection for their entire lifetime. 32 Swoole workers means 32 permanent Postgres processes, most of them idle at any given moment. There's a years-old GitHub discussion of Octane users hitting exactly this wall and hand-rolling PgBouncer setups to survive it.
The fix isn't more connections. It's a pooler.
What a Transaction Pooler Actually Does
A pooler like PgBouncer is a lightweight middleman between your app and Postgres. Your app opens hundreds of cheap connections to the pooler. The pooler multiplexes them over a small set of real Postgres connections, maybe 20.
In transaction mode (the mode that matters here), a real connection is borrowed only for the duration of a single transaction. The moment your transaction commits, that connection goes straight back into the pool for the next request. This is how Supabase's Supavisor serves thousands of clients, how Neon's pooler endpoints work, and how AWS RDS Proxy keeps Lambda functions from melting a small RDS instance.
Transaction mode has one big catch: it breaks server-side prepared statements. Your next query might execute on a different real connection, one that never saw the PREPARE. The same goes for session-level features like SET commands and LISTEN/NOTIFY. That catch is the entire reason Laravel needed framework support.
The Old Pain: What We All Had to Do Before 13.17
Before this release, running Laravel through a transaction pooler meant assembling your own solution from three parts.
First, you disabled server-side prepares by adding PDO::ATTR_EMULATE_PREPARES => true to your connection's options array. That made queries pooler-safe.
But it introduced a second bug: with emulated prepares, Laravel sends PHP booleans to Postgres as 1 and 0 instead of 'true' and 'false', and Postgres rejects them. Entire community packages exist purely to patch this. Their whole job is a custom PostgresConnection class that reformats boolean bindings. If you have one of those in your composer.json, you already know.
Third, migrations and DDL can't run through a transaction pooler at all. So you kept a second connection config pointing at the real database host and remembered to pass --database=pgsql_direct to every migrate call. Every deployment script grew a little ritual around it. Forget once and your migration hangs or fails in a way that takes twenty minutes to diagnose.
None of this was hard, exactly. It was just fragile, undocumented tribal knowledge that every team rediscovered the painful way.
What Laravel 13.17 Ships
PR #60425 (merged June 21, shipped in v13.17.0) moves all of that into the framework. Yes, the same release that gave us route metadata. 13.17 was a good week. Interesting detail: Laravel Cloud has had internal pooled-connection handling since 2025, but it was private plumbing. This PR is the first time it's a public, documented API that any Laravel app can use.
Here's the whole configuration:
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
// ...
'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'),
]),
],
The main connection points at your pooler. The direct block points at the real database. With pooled => true, the framework does four things automatically:
Emulated prepares turn on for the pooled connection. Direct connections keep native prepares. You never touch PDO::ATTR_EMULATE_PREPARES yourself again.
Boolean bindings get fixed. Under emulated prepares, Postgres now receives 'true' and 'false' strings instead of integer literals. Delete the community patch package.
Schema operations route around the pooler. Migrations, schema:dump, schema load, db:wipe, db:show, and db:table all use the direct connection without you passing any flags. Your deployment script gets simpler, not more complicated. The full command list lives in the Laravel Artisan Commands reference if you want to check what else touches the schema.
php artisan db defaults to direct. When pooled mode is on and a direct endpoint exists, the interactive database CLI connects directly, which is what you want for poking at the schema. Pass --pooled if you specifically want to inspect behavior through the pooler.
And when your own application code needs the real connection (say, a LISTEN/NOTIFY listener or a long-running cursor), append the ::direct suffix:
DB::connection('pgsql::direct')->statement('LISTEN order_events');
Backward compatibility is clean. Without pooled => true, nothing changes. Existing ::read and ::write suffixes keep their meaning. Adding a direct block alone doesn't reroute anything.
Setting It Up With Real Providers
The config shape is identical everywhere. The only thing that changes is where the pooled and direct hosts come from.
Supabase
Supabase gives you two connection paths. The direct connection runs on port 5432 at your project's database host. The transaction pooler (Supavisor) runs on port 6543 at a regional pooler host. Both are on your project's Connect page.
DB_HOST=aws-0-eu-central-1.pooler.supabase.com
DB_PORT=6543
DB_POOLED=true
DB_DIRECT_HOST=db.yourproject.supabase.co
DB_DIRECT_PORT=5432
Your app traffic flows through Supavisor. Your migrations hit the database directly. No custom connection classes, no session-mode compromises.
Neon
Neon exposes the pooler through a hostname suffix: take your endpoint host and add -pooler to it. Same credentials for both.
DB_HOST=ep-cool-name-123456-pooler.eu-central-1.aws.neon.tech
DB_POOLED=true
DB_DIRECT_HOST=ep-cool-name-123456.eu-central-1.aws.neon.tech
If you're on Neon specifically for the scale-to-zero pricing, this pairing matters more than it looks. Pooled connections are what let a tiny compute instance handle bursty traffic without connection churn waking it into a bigger bill.
Self-Hosted PgBouncer
Run PgBouncer with pool_mode = transaction in pgbouncer.ini, point DB_HOST at the PgBouncer port (usually 6432), and point the direct block at Postgres itself on 5432. That's it. All the ignore_startup_parameters and prepared-statement tuning advice you'll find in old tutorials was written for the pre-13.17 world. The framework handles the client side now.
One honest note for Octane users: a pooler doesn't reduce how many connections your workers open. It makes those connections cheap. 32 workers still hold 32 client connections, but they multiplex over a fraction of the real Postgres processes, which is the resource that actually runs out.
How to Verify It's Actually Working
Don't trust the config, check the behavior. Three quick tests after enabling pooled:
First, confirm your app traffic goes through the pooler. Run a request, then check active server connections from a direct session:
SELECT count(*) FROM pg_stat_activity WHERE datname = 'your_database';
Under load, that number should stay small and stable (your pool size) while your app happily serves far more concurrent requests. If it climbs with concurrency, you're not actually connecting to the pooler endpoint.
Second, confirm schema routing. Run php artisan db:show. It should report the direct host, not the pooler host. Then run a throwaway migration on staging and watch it complete without hanging. That's the automatic routing doing its job.
Third, if you had the boolean problem before, hit a code path that writes a boolean column. On 13.17 with pooled => true it just works. No exception about invalid input syntax for type boolean, no patch package in sight.
Should You Turn This On?
If you're on Supabase, Neon, RDS Proxy, or any managed Postgres with a pooler endpoint: yes, and this feature removes the last good excuse not to. The old objection was that pooler setups in Laravel were fragile hand-rolled things. That objection died in 13.17.
If you're self-hosting Postgres for a small app with steady traffic, you don't need a pooler yet. A single server with PHP-FPM and modest concurrency lives comfortably inside default connection limits. Add PgBouncer when you see connection pressure, not before. Database indexing will buy you more performance per hour invested until then.
The setup where I'd call it non-negotiable: Octane on Postgres, or anything serverless. Persistent workers and scale-to-zero databases are both connection-hungry patterns, and transaction pooling is the standard answer in every ecosystem, ours included.
One thing to check before enabling it on an existing app: emulated prepares change how query plans are cached server-side, and code that relied on session state between queries (temporary tables, SET commands, advisory locks held across requests) will behave differently through a pooler. That's pooler physics, not a Laravel limitation. Audit for those patterns first. Multi-tenant apps that switch databases per tenant should test carefully too, since tenant connection strategies often assume dedicated connections.
FAQ
Which Laravel version do I need for native pooler support?
Laravel 13.17 or newer. The feature landed via PR #60425 and shipped in v13.17.0 in late June 2026. If you're still on Laravel 12, the upgrade to 13 is worth it for this alone if you run Postgres in production.
Does this work with MySQL or MariaDB?
No. The pooled option is specific to the PostgreSQL driver, because the problem it solves (process-per-connection cost plus prepared statements breaking under transaction pooling) is specific to Postgres. MySQL's threaded connection model doesn't need this.
Do I still need packages that patch PgBouncer boolean bindings?
Not on 13.17+. Correct boolean binding under emulated prepares is now framework behavior. Remove the package, remove any custom PostgresConnection class you copied from a gist, and remove PDO::ATTR_EMULATE_PREPARES from your connection options. The framework sets it for pooled connections automatically.
What happens to migrations if I don't configure a direct block?
Then there's nothing to route to, and schema operations go through your main connection like before. If that connection is a transaction pooler, DDL can fail or hang, exactly as it always did. The direct block is what makes the automatic routing possible, so treat it as required whenever pooled is true.
Does pooling change anything for read/write connection splits?
No. Explicit ::read and ::write suffixes and the --read/--write CLI flags keep working exactly as before and aren't rerouted to the direct connection. The ::direct suffix is a separate, additional routing option.
Wrapping Up
This is one of those features where the code change in your app is five lines of config and the actual win is everything you get to delete: the options array hack, the patch package, the second migration config, the deploy-script flags, and the tribal knowledge explaining why they all exist. Laravel apps on Supabase and Neon just got noticeably simpler to run, and Octane on Postgres finally has a first-party answer to connection exhaustion.
Top comments (0)