Too Many Connections: Postgres Pooling for Laravel Apps

Every PHP worker, queue worker, and cron job opens its own database connection. Where those slots go, when PgBouncer earns its place, and the session features that break in transaction pooling mode.

The error that only happens at 21:40

The "too many connections" failure has a cruel timing habit: it never shows up while you're watching. One traffic spike in the evening, and Postgres rejects logins because max_connections (100 by default on most distros) ran out. Nothing in the code changed that day. The change was that traffic finally crossed a threshold your connection math was already past.

The math most people skip: a Laravel deployment doesn't have one connection to the database. It has one per process. A PHP-FPM pool with 20 children is up to 20 connections. Queue workers each hold one, usually idle, always claimed. The scheduler, cron jobs, any Octane workers, and you sitting in psql during an incident all take a slot too. Two app servers and you can burn through the default limit without anything looking wrong at any single moment.

Count first, pool second

Before installing anything, count what you actually have. This takes five minutes and sometimes ends the conversation:

SELECT count(*), application_name FROM pg_stat_activity GROUP BY application_name;

If the total sits comfortably below max_connections and spikes stay rare, your problem is usually worker sizing, not pooling. Lowering pm.max_children on FPM, running one queue worker per queue, or adding RAM so Postgres can afford a few more connections is cheaper than adding a moving part to your data path. Install a pooler when the process count genuinely outruns the limit, and keep it there for a while.

What PgBouncer actually does

PgBouncer is a small proxy that holds a fixed number of real connections to Postgres and multiplexes incoming client connections onto them. Your 60 Laravel processes can share, say, 20 real connections, because no single process uses the database that much of the time.

The one setting that matters: pool_mode.

  • Session pools nothing useful for this problem. The client keeps its real connection until it disconnects, so you get connection reuse for reconnecting clients but no multiplexing.
  • Transaction hands a real connection to the client for the duration of one transaction or statement, then gives it back. This is the mode that makes 60 processes fit into 20 connections, and it's the one you almost certainly want.

What breaks in transaction mode

This is the part that turns into a 2 a.m. incident if you skip it. Transaction pooling means the next query from your app may land on a different physical connection, so anything that lives on a connection stops working:

  • Prepared statements. PDO uses them by default. Modern PgBouncer can track them (max_prepared_statements, 1.21+); on older versions, switch PDO to emulated prepares for the pooled connections.
  • Session-level SETs. Timezone or statement timeout applied per session now applies per unpredictable connection. Set them per transaction, or in postgresql.conf.
  • Advisory locks, LISTEN/NOTIFY, temp tables. All session-scoped. Laravel's lock drivers and queue features can use advisory locks, so test your lock usage against the pooler specifically.

The classic symptom after enabling transaction mode: "locks stop working" or "column does not exist" errors appearing randomly. Both mean a session assumption leaked.

The setup, briefly

Run PgBouncer on the app host or next to Postgres, point Laravel's config at port 6432 instead of 5432, and keep the real credentials in userlist.txt:

[databases]
app = host=127.0.0.1 port=5432 dbname=app

[pgbouncer]
pool_mode = transaction
default_pool_size = 20
max_client_conn = 200
max_prepared_statements = 200

Keep default_pool_size below max_connections with headroom for superuser logins and migrations, so you can always get into the database while the pool is saturated. Migration runs should point at Postgres directly, not through the pooler, since DDL and session features coexist badly with multiplexing.

The honest boundary

Pooling fixes connection count. It does nothing for slow queries, lock contention, or a server running out of RAM. If you're adding PgBouncer to hide an N+1 or an understaffed queue, the pooler will just queue up the misery in a new place. Fix the connection math you can control first, then let the proxy absorb the rest.