PHP-FPM and PostgreSQL Connection Exhaustion: Why Persistent Connections Fail and What Actually Works
Your PHP workers are holding connections they cannot properly manage. Allow me to relieve them of the responsibility.
Good evening. I understand you are running out of connections.
The error message is always the same. Sometimes at 2 AM when the cron jobs overlap with the queue workers. Sometimes at noon when marketing sends the newsletter and traffic triples. Sometimes during a deploy when the old workers have not yet released their connections and the new workers are already requesting theirs.
FATAL: sorry, too many clients already
I find this particular error message — blunt, apologetic, final — to be one of PostgreSQL's most honest communications. It is not sorry. It has been signalling for weeks, through pg_stat_activity and rising connection counts, that the household was running out of rooms. The signs were there. And now the manor is full.
You are running PHP-FPM. You are connecting to PostgreSQL. And at some point, someone — perhaps you, perhaps a well-meaning predecessor, perhaps a Stack Overflow answer from 2014 that has aged like milk in sunlight — added PDO::ATTR_PERSISTENT => true to the connection options, because the PHP documentation says persistent connections improve performance by reusing existing connections instead of creating new ones on every request.
For MySQL, that advice is roughly correct. For PostgreSQL, it is the beginning of a problem that will take you months to fully understand and years to fully regret.
I have seen this pattern across Laravel applications, Symfony services, custom PHP codebases, and WordPress installations that outgrew shared hosting. The symptoms vary — data disappearing without error logs, advisory locks held by idle connections, tenant data leaking across requests, OOM kills triggered by inherited work_mem settings. The root cause does not vary. It is always the same: PHP-FPM persistent connections treat a PostgreSQL session as if it were a stateless socket, and the database disagrees.
Allow me to walk you through it properly. We shall begin with why the problem exists, proceed through five distinct failure modes, examine every available solution and its honest trade-offs, and arrive at an approach you can deploy with confidence. There is rather a lot to cover. I trust you are comfortable.
Why PDO persistent connections are broken for PostgreSQL
PDO persistent connections work by keeping the database connection open after the PHP request completes. The connection is stored in the FPM worker process and reused by the next request that worker handles. The PHP manual describes this as "caching" the connection. That word does a great deal of heavy lifting, because it implies something managed and safe. It is neither.
<?php
// The configuration that seems perfectly reasonable.
// It is not.
$dsn = 'pgsql:host=localhost;port=5432;dbname=myapp';
$pdo = new PDO($dsn, 'appuser', 'secret', [
PDO::ATTR_PERSISTENT => true, // reuse connections across requests
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// What you expect:
// - PHP-FPM worker gets a connection from a pool
// - Request executes queries
// - Connection returns to pool, clean and ready
//
// What actually happens with PostgreSQL:
// - Connection is reused, but its STATE is not reset
// - Previous request's SET statements still apply
// - Previous request's aborted transaction may still be open
// - Previous request's advisory locks are still held
// - Previous request's prepared statements are still cached
//
// With MySQL, PDO::ATTR_PERSISTENT works tolerably.
// With PostgreSQL, it is a landmine. The core problem: when a PDO persistent connection is "returned" to the pool at the end of a request, nothing is cleaned up. PDO does not issue RESET ALL. It does not roll back open transactions. It does not release advisory locks. It does not deallocate prepared statements. It simply stops using the connection and marks it available for the next request on that worker.
I find this behaviour — maintaining an open database session while performing no session management whatsoever — to be the infrastructural equivalent of leaving every door in the house unlocked and then expressing surprise when the silverware rearranges itself.
With MySQL, this is mostly harmless. MySQL's connection state is simpler, and the mysql_ping() mechanism provides basic liveness checking. MySQL also auto-rolls-back uncommitted transactions when the next query arrives on a reused connection (in most configurations).
<?php
// The difference between MySQL and PostgreSQL persistent connections:
// === MySQL ===
// mysql_ping() / mysqli_ping() checks if the connection is alive
// If the connection was in a transaction, MySQL auto-rolls-back on reuse
// SET statements persist, but most are harmless (charset, timezone)
// Prepared statements are tied to the connection, but MySQL handles
// server-side prep differently (optional, often emulated by default)
// Result: persistent connections are mostly fine for MySQL
// === PostgreSQL ===
// No equivalent of mysql_ping() for state cleanup
// Open transactions are NOT rolled back on reuse
// SET statements persist and can change behavior dramatically:
// SET search_path = 'tenant_42'; -- next request sees wrong tenant
// SET statement_timeout = '100ms'; -- next request gets timeout surprises
// SET work_mem = '1GB'; -- next request consumes excessive memory
// Advisory locks persist (session-scoped)
// LISTEN registrations persist (you receive someone else's notifications)
// Prepared statements persist (name collisions, stale plans)
// Temp tables persist (disk usage accumulates, name collisions)
// Result: persistent connections are a minefield for PostgreSQL PostgreSQL does none of this. A PostgreSQL connection is a stateful session. It accumulates configuration via SET commands, holds transaction state, caches prepared statements, maintains advisory locks, tracks LISTEN subscriptions, and stores temporary tables. All of this state persists across the boundary between one PHP request and the next. The second request inherits everything the first request left behind.
This is not a bug in PostgreSQL. It is correct behaviour. A database session should maintain its state until told otherwise. The bug is in the assumption that PHP's process-level connection caching constitutes proper session management. It does not.
Five failure modes follow from this. Each one is worse than the last.
Failure mode 1: Orphaned transactions
This is the most common and the most insidious failure, because it produces no error messages. Data simply disappears.
<?php
// Request A: starts a transaction, hits an exception before COMMIT
try {
$pdo->beginTransaction();
$pdo->exec("UPDATE accounts SET balance = balance - 100 WHERE id = 42");
// Exception thrown here — maybe a validation error, maybe a timeout
throw new RuntimeException("Payment gateway timeout");
$pdo->commit();
} catch (Exception $e) {
// Developer forgot $pdo->rollBack() in the catch block.
// Or worse: the exception was in framework middleware,
// so the catch block never ran at all.
error_log($e->getMessage());
}
// The connection returns to the persistent pool with an OPEN transaction.
// Request B: arrives 30 seconds later, same PHP-FPM worker, same connection.
$pdo->exec("INSERT INTO orders (user_id, total) VALUES (7, 59.99)");
// This INSERT is now running inside Request A's abandoned transaction.
// When Request B finishes without committing (because it never called beginTransaction),
// the INSERT silently rolls back.
// The order vanishes. No error. No log entry. Just gone.
// Diagnosis takes days. The data is inconsistent but the logs are clean. The sequence is straightforward. Request A begins a transaction, encounters an exception, and does not roll back. The persistent connection returns to the FPM worker with the transaction still open. Request B inherits the connection, runs its queries inside Request A's transaction, and finishes without committing (because it never called beginTransaction() — it does not know a transaction is active). When the FPM worker eventually closes the connection, or when the next request calls beginTransaction() and triggers an implicit rollback, everything Request B did vanishes.
This failure is especially dangerous in Laravel. Eloquent's DB::transaction() wrapper does roll back on exception. But if the exception occurs in middleware after the database call — in a response transformer, a logging handler, a notification dispatch — the rollback never runs. The connection goes back to the persistent pool with the transaction open.
<?php
// Laravel's DatabaseManager reuses connections per request.
// But with PDO::ATTR_PERSISTENT, the connection outlives the request.
// config/database.php — the configuration that creates the problem:
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'options' => [
PDO::ATTR_PERSISTENT => true, // <-- the source of the trouble
],
],
// The specific problem with Laravel and persistent PostgreSQL connections:
//
// 1. Tenant isolation via SET search_path
// Middleware does: DB::statement("SET search_path TO 'tenant_42'");
// Request completes. Connection goes back to persistent pool.
// Next request on same worker: different tenant, same search_path.
// Tenant 42's data is now visible to tenant 57's user.
// This is a data breach, not a bug.
//
// 2. DB::transaction() uses try/catch internally, but:
// - Queued closures that fail AFTER the transaction commits
// can leave the connection in an unexpected state
// - Event listeners that throw during afterCommit callbacks
// leave the connection in a half-committed state
//
// 3. Laravel's disconnect() method closes the PDO object, but
// with persistent connections, closing PDO does NOT close
// the underlying connection. It goes back to the process pool. The tenant isolation problem in that Laravel configuration deserves emphasis. Multi-tenant applications that use SET search_path to isolate tenant data — a common pattern in Laravel SaaS applications — are one PDO::ATTR_PERSISTENT => true away from a data breach. Request A sets the search path to tenant_acme. Request B, on the same FPM worker, serves a user from tenant_globex but inherits tenant_acme's search path. Globex's user now sees Acme's data. No error. No log. Just a compliance violation waiting to be discovered by an auditor or, worse, by a customer.
The diagnostic difficulty is extreme. The affected rows appear in no error log. They simply never commit. You notice days later when a customer reports a missing order, a missing payment, or — in one case I investigated — 72 hours of missing audit trail entries. The application had been silently dropping writes into orphaned transactions for three days. No alerts fired because no errors occurred.
Failure mode 2: Advisory lock leakage
If your application uses PostgreSQL advisory locks — common in job queues, rate limiters, and distributed coordination — persistent connections turn them into permanent locks.
<?php
// Request A: acquires an advisory lock for a critical section
$pdo->exec("SELECT pg_advisory_lock(12345)");
// ... does some work ...
// Request completes. Connection returns to persistent pool.
// The advisory lock is NOT released.
// pg_advisory_lock() is session-scoped, not transaction-scoped.
// Request B: different user, different endpoint, same FPM worker.
// Tries to acquire the same lock:
$pdo->exec("SELECT pg_advisory_lock(12345)");
// Blocks forever. The lock is held by THIS connection — which is
// the same connection Request B is using. PostgreSQL sees no conflict
// (same session), so it grants the lock again. Now it is held twice.
// Releasing it once will not free it.
// Request C: different FPM worker, tries the same lock.
// Blocks forever. Genuinely deadlocked against the leaked lock
// on the other worker's persistent connection.
// Your job queue stops processing. Users see timeouts.
// pg_stat_activity shows the lock holder as "idle".
// Because it IS idle. The request that acquired the lock
// finished minutes ago. PostgreSQL advisory locks are session-scoped by default. pg_advisory_lock() acquires a lock that persists until explicitly released or until the session ends. With persistent connections, the session never ends. The lock never releases. The FPM worker process must be killed or the PHP-FPM pool must be restarted.
I should note a particular subtlety that makes this failure mode especially confounding. When Request B calls pg_advisory_lock(12345) on the same connection that already holds that lock, PostgreSQL does not block. It grants the lock again. Advisory locks are re-entrant within the same session. The lock's reference count is now 2. A single pg_advisory_unlock(12345) decrements the count to 1 — the lock remains held. You must call pg_advisory_unlock() once for every pg_advisory_lock() that was called on that session, or use pg_advisory_unlock_all() to release everything.
With persistent connections, you have no idea how many times the lock has been acquired. It could be 1. It could be 47. Each request that passed through that FPM worker and called pg_advisory_lock(12345) incremented the count. You cannot unlock it without knowing the count, and you cannot know the count without querying pg_locks — which tells you the lock is held, but not its reference count.
Laravel's Cache::lock() uses advisory locks when configured with the database driver. Symfony's LockFactory with the PostgreSQL store uses them. Any queue system that uses SELECT ... FOR UPDATE SKIP LOCKED is not affected (those are row-level locks, transaction-scoped), but anything using explicit advisory locks is vulnerable.
The transaction-scoped variants — pg_advisory_xact_lock() — are safer because they release when the transaction ends. But combined with the orphaned transaction problem above, even transaction-scoped locks can persist indefinitely: the transaction never commits or rolls back, so the lock never releases.
Failure mode 3: SET statement leakage
This is the failure mode that causes the most architecturally consequential damage, because it violates assumptions that developers do not even know they are making.
<?php
// The SET statement leakage problem — subtle and dangerous.
// Request A: multi-tenant SaaS, sets search_path for tenant isolation
$pdo->exec("SET search_path TO 'tenant_acme'");
$rows = $pdo->query("SELECT * FROM invoices")->fetchAll();
// Returns tenant_acme's invoices. Correct.
// Request A completes. Connection returns to persistent pool.
// Request B: different user, different tenant, same FPM worker.
// Developer expects the default search_path ('public').
$rows = $pdo->query("SELECT * FROM invoices")->fetchAll();
// Returns tenant_acme's invoices. WRONG.
// Request B never set search_path — it inherited Request A's setting.
// This is now a data isolation violation in a multi-tenant system.
// The same problem occurs with:
// SET statement_timeout — next request gets unexpected timeout behavior
// SET work_mem — next request uses Request A's memory allocation
// SET timezone — next request generates timestamps in the wrong zone
// SET role — next request operates with the wrong database role
//
// Every SET command is a landmine when connections persist across requests. Every SET command issued by a request persists on the persistent connection. The next request inherits the previous request's search_path, statement_timeout, work_mem, timezone, role, and every other session-level parameter. This is not a theoretical concern — it is the mechanism behind real production incidents.
-- The work_mem leak scenario — memory consumption gone wrong.
-- Request A: analytics dashboard, runs a complex aggregation.
-- Developer sets generous work_mem for the sort operation:
SET work_mem = '256MB';
SELECT department, sum(revenue), avg(revenue)
FROM sales
GROUP BY department
ORDER BY sum(revenue) DESC;
-- Query runs, returns results. Request A finishes.
-- work_mem = 256MB is still set on this connection.
-- Request B: simple CRUD endpoint, same FPM worker.
-- Fetches a user profile — trivial query.
SELECT * FROM users WHERE id = 42;
-- This query now has work_mem = 256MB.
-- For this query it does not matter. But...
-- Request C: same worker again. An API endpoint that runs:
SELECT DISTINCT tag FROM articles ORDER BY tag;
-- This sorts 500,000 rows. With work_mem = 256MB,
-- PostgreSQL allocates up to 256MB for the sort.
-- With the default work_mem (4MB), it would spill to disk,
-- using ~4MB of RAM and some disk I/O.
-- With 256MB, it does the sort entirely in memory.
-- Multiply by 50 FPM workers, each potentially inheriting
-- the inflated work_mem: 50 x 256MB = 12.8GB of potential
-- sort memory allocation.
-- On a 16GB server, this triggers the OOM killer.
-- The developer who set work_mem on Request A has no idea.
-- The crash happens on Request C, in a completely unrelated endpoint.
-- The DBA sees the OOM kill and blames "a bad query."
-- The actual cause: SET statement leakage via persistent connections. The work_mem scenario is particularly instructive. A single request that sets work_mem = '256MB' for a legitimate analytics query can cause an OOM kill on an entirely unrelated endpoint hours later, on a request served by a different user, for a query that normally uses 4MB. The causal chain — analytics request sets work_mem, connection persists, CRUD request inherits work_mem, sort operation consumes 256MB — is nearly impossible to diagnose without knowing that persistent connections leak session state.
I should be forthcoming: some PHP applications never issue SET commands directly. If your application uses only the defaults and never touches session parameters, this particular failure mode will not affect you. But frameworks and libraries issue SET commands on your behalf. Doctrine's PostgreSQL platform sets search_path. Laravel Tenancy sets search_path or SET ROLE. Some monitoring middleware sets statement_timeout. The question is not whether your application uses SET — it is whether anything in your dependency tree does.
Failure mode 4: The connection math
Even if you solve every state leakage problem — through heroic register_shutdown_function() hooks, meticulous DISCARD ALL calls, and extensive testing — persistent connections create a counting problem that catches teams during scaling events.
# PHP-FPM connection math — the numbers nobody checks until production breaks
# php-fpm pool configuration (www.conf)
pm = dynamic
pm.max_children = 50 # maximum FPM worker processes
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
# Each FPM worker holds ONE persistent PDO connection to PostgreSQL.
# (One per unique DSN — multiple databases multiply this.)
# PostgreSQL default:
max_connections = 100
# Calculation for a single app server:
# 50 FPM workers x 1 connection = 50 connections
# Looks fine. 50 < 100.
# But you have 3 app servers behind a load balancer:
# 3 servers x 50 workers = 150 persistent connections
# 150 > 100.
# FATAL: sorry, too many clients already
# And you also have:
# - A cron worker with 10 FPM children: +10
# - A queue worker with 20 processes: +20
# - PgAdmin for the DBA: +2
# - Monitoring (pg_stat_statements): +1
# - Replication slots: +2
# Total: 185 connections
#
# Against max_connections = 100.
# This fails at 3 AM when the cron jobs spike. Every PHP-FPM worker process holds one persistent connection per unique DSN. The connection exists for the lifetime of the worker process, not the lifetime of the request. With pm.max_children = 50 across three servers, you are holding 150 connections to PostgreSQL even if traffic is low enough that most workers are idle.
PostgreSQL's default max_connections = 100 is a known trap. Most teams bump it to 200 or 300. But each connection consumes approximately 5-10MB of RAM on the PostgreSQL server (for work_mem, sort buffers, and connection overhead). At 300 connections, that is 1.5-3GB of memory reserved for connection overhead alone. On a server with 8GB of RAM, that leaves dangerously little for shared_buffers and operating system page cache.
There is a deeper problem that the simple arithmetic conceals. PostgreSQL's performance degrades non-linearly as connection count increases. At 100 connections, the process scheduler, lock manager, and shared buffer management all operate efficiently. At 300 connections, contention on internal locks — particularly the ProcArrayLock and snapshot management — begins to degrade throughput. At 500+ connections, PostgreSQL spends more time managing connections than executing queries. The PostgreSQL documentation itself advises keeping connection counts as low as possible.
The formula that matters:
Total connections = (app servers x pm.max_children) + cron workers + queue workers + admin tools + monitoring + replication
If this number exceeds max_connections, you will hit the wall. And because persistent connections are held for the worker lifetime, not the request lifetime, you are counting worker capacity, not actual concurrent usage. A pool of 50 workers serving 5 requests per second is holding 50 connections, not 5.
Failure mode 5: The silent recycling lottery
Many PHP teams discover pm.max_requests as a mitigation for persistent connection problems. It is worth examining why it is insufficient.
# PHP-FPM worker recycling — the band-aid solution.
# php-fpm pool configuration (www.conf)
# Force workers to restart after N requests:
pm.max_requests = 500
# This kills and replaces each worker after 500 requests.
# When a worker dies, its persistent connections close.
# PostgreSQL releases the session state.
# Advisory locks release. Open transactions roll back.
# The costs:
# - Each new worker takes 5-30ms to start (PHP initialization)
# - The first request on a new worker pays connection setup cost (3-5ms)
# - At 100 requests/second across 50 workers, you recycle a worker
# every 0.5 seconds. That is constant process churn.
# - During the recycle, that worker cannot serve requests.
# With pm.max_children = 50, losing one is a 2% capacity drop.
# At high traffic, this causes request queuing.
# pm.max_requests is a useful safety valve.
# It is not a connection management strategy.
# It is the equivalent of rebooting your server to fix a memory leak.
# Effective, but undignified. Worker recycling has its place. It bounds memory leaks, prevents indefinite accumulation of persistent connection state, and provides a hard limit on how long any single leaked lock or orphaned transaction can persist. I would not argue against setting pm.max_requests as a defense-in-depth measure.
But it is a lottery. If an advisory lock leaks on request 1 and pm.max_requests = 500, you have 499 requests where every attempt to acquire that lock on another FPM worker will block. If a transaction orphans on request 200, you have 300 requests that may silently lose their writes. The damage window is bounded but not eliminated.
And recycling has its own costs. Each new worker process requires PHP initialization — autoloader registration, framework bootstrapping, service container compilation. For a Laravel application, this is 5-30ms depending on the number of service providers. For a Symfony application with a compiled container, it is 2-10ms. These are not catastrophic numbers individually, but at high throughput they produce visible latency spikes. Your p99 response time develops a sawtooth pattern: low when workers are warm, high when a batch of workers recycles simultaneously.
I have encountered teams that set pm.max_requests = 1 — effectively disabling persistent connections by forcing every worker to restart after every request. This eliminates all state leakage at the cost of paying framework initialization overhead on every single request. It works. It is also an admission that the connection management strategy has failed entirely, which it has. At that point, a connection pooler is the dignified answer.
Diagnosing the damage: queries every PHP DBA should know
Before we discuss solutions, permit me a brief inventory of the household. When connection exhaustion strikes — or when you suspect persistent connection state leakage — these diagnostic queries will show you exactly what is happening.
-- When connections are exhausted, this is what you need.
-- Run it from a superuser connection (or pg_monitor role).
-- 1. How many connections exist, and who holds them?
SELECT
usename,
application_name,
client_addr,
state,
count(*) AS connections
FROM pg_stat_activity
GROUP BY usename, application_name, client_addr, state
ORDER BY connections DESC;
-- Typical output on a PHP app with persistent connections:
-- usename | application_name | client_addr | state | connections
-- ----------+------------------+--------------+---------+------------
-- appuser | | 10.0.1.5 | idle | 48
-- appuser | | 10.0.1.6 | idle | 50
-- appuser | | 10.0.1.7 | idle | 47
-- appuser | | 10.0.1.5 | active | 2
-- postgres | pgAdmin 4 | 10.0.2.1 | idle | 2
--
-- 145 connections held by appuser. 4 of them are active.
-- 141 connections are idle — consuming server memory, doing nothing.
-- This is what persistent connections look like at scale. The connection inventory is the first thing to check. If you see dozens of idle connections from your application servers, those are persistent connections held by FPM workers that are between requests. Each one consumes approximately 5-10MB on the PostgreSQL server. They are doing nothing. They are costing you memory.
-- 2. Find connections holding open transactions (the real danger)
SELECT
pid,
usename,
state,
now() - xact_start AS transaction_duration,
now() - query_start AS time_since_last_query,
left(query, 80) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start ASC;
-- "idle in transaction" means: a transaction was started, a query ran,
-- and then the connection went idle WITHOUT committing or rolling back.
-- This is the ghost of a PHP request that crashed mid-transaction.
-- The connection is holding row locks. It is preventing VACUUM.
-- And with persistent connections, it will stay this way until the
-- FPM worker process is recycled.
-- If transaction_duration is longer than a few seconds, something is wrong.
-- If it is longer than a few minutes, something is very wrong.
-- If it is longer than an hour, you have a persistent connection
-- that inherited an orphaned transaction. The idle in transaction state is the most dangerous thing you can find in pg_stat_activity. It means a transaction was started, a query ran, and then the connection stopped doing anything — but the transaction is still open. This connection is holding row-level locks. It is preventing VACUUM from cleaning up dead tuples in any table it touched. And if it has been in this state for more than a few seconds, something has gone wrong.
With persistent connections, the "something" is usually: the PHP request that started the transaction crashed, threw an exception, or simply ended without committing. The persistent connection kept the session alive. The transaction remains open. I have seen idle in transaction connections that persisted for hours — the FPM worker received no requests that triggered a new beginTransaction(), so nothing ever forced the orphaned transaction to resolve.
-- 3. Find advisory locks held by idle connections
SELECT
l.pid,
l.classid,
l.objid,
a.state,
a.query,
now() - a.state_change AS idle_duration
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
AND a.state = 'idle'
ORDER BY idle_duration DESC;
-- An advisory lock held by an "idle" connection is a leaked lock.
-- The request that acquired it is long gone.
-- The connection persists because PDO::ATTR_PERSISTENT kept it alive.
-- The lock persists because pg_advisory_lock() is session-scoped.
-- Nothing will release this lock except:
-- 1. Explicitly calling pg_advisory_unlock() on this connection
-- 2. Terminating the backend with pg_terminate_backend(pid)
-- 3. Restarting the PHP-FPM worker that owns this connection An advisory lock held by an idle connection is, by definition, a leaked lock. No legitimate use case holds an advisory lock while the connection is doing nothing. The request that needed the lock has finished. The lock remains because the session remains. This is the persistent connection problem in its most reduced form.
The connection timeline: where your milliseconds go
To properly evaluate the solutions that follow, you need to understand the actual cost of establishing a PostgreSQL connection from PHP. This is the number that PDO::ATTR_PERSISTENT was designed to avoid, and it is smaller than most developers assume.
<?php
// Timeline of a single PHP-FPM request — where the time goes.
// WITHOUT connection pooling (direct to PostgreSQL):
// T+0.0ms PHP-FPM worker picks up request
// T+0.5ms PDO constructor called
// T+0.5ms TCP handshake to PostgreSQL (loopback: ~0.1ms)
// T+1.0ms PostgreSQL forks new backend process (~0.5ms)
// T+2.5ms TLS handshake (if using SSL: ~1.5ms)
// T+3.5ms Authentication (SCRAM-SHA-256: ~1ms)
// T+3.5ms Connection ready. Total setup: 3.5ms
// T+4.0ms Query 1: SELECT user (0.5ms)
// T+4.5ms Query 2: SELECT products (0.5ms)
// T+5.0ms Query 3: INSERT order (0.5ms)
// T+5.5ms PHP processing (templates, etc): 10ms
// T+15.5ms Response sent
// T+15.5ms PDO destructor: connection closed
// T+15.5ms PostgreSQL backend process exits
//
// Connection overhead: 3.5ms out of 15.5ms = 22.6% of request time.
// For a request that only does one query, it is even worse.
// WITH PgBouncer (session mode, Unix socket):
// T+0.0ms PHP-FPM worker picks up request
// T+0.5ms PDO constructor called
// T+0.5ms Unix socket to PgBouncer: ~0.1ms
// T+0.7ms PgBouncer assigns pre-forked backend: ~0.1ms
// T+0.7ms Connection ready. Total setup: 0.2ms
// T+1.2ms Queries (same as above): 1.5ms
// T+12.7ms PHP processing: 10ms
// T+12.7ms Response sent, connection returned to pool
//
// Connection overhead: 0.2ms out of 12.7ms = 1.6% of request time.
// From 22.6% to 1.6%. That is the pooler dividend. The connection setup cost to PostgreSQL — approximately 3.5ms for a local connection with TLS — is the entire justification for persistent connections. And it is a real cost. For an application making a single 0.5ms query per request, connection setup is 87.5% of the database interaction time. Persistent connections eliminate this overhead entirely.
But a connection pooler eliminates it almost entirely as well. Connection to PgBouncer via a Unix socket: 0.1-0.3ms. Connection to PgBouncer via TCP loopback: 0.3-0.5ms. The pooler maintains pre-established PostgreSQL backends, so the client never pays the fork, TLS, and authentication costs. The only overhead is the pooler's own protocol handling, which for PgBouncer is approximately 0.05ms per query.
The honest calculation: persistent connections save you 3.5ms per request. A connection pooler saves you 3.2-3.4ms per request. The difference — 0.1-0.3ms — is the entire performance advantage of persistent connections. And for that 0.1-0.3ms, you accept orphaned transactions, leaked locks, SET statement contamination, connection count multiplication, and the diagnostic nightmare that accompanies all five failure modes.
This is not a favourable trade.
PgBouncer: the standard fix and its own problems
The PHP community's standard answer to connection exhaustion is PgBouncer. It sits between PHP-FPM and PostgreSQL, accepts many client connections, and multiplexes them onto fewer backend connections. In transaction mode, a backend connection is assigned to a client only for the duration of a transaction, then returned to the pool.
# PgBouncer in transaction mode — the standard PHP recommendation.
# It works. Mostly.
# pgbouncer.ini
[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp
[pgbouncer]
listen_port = 6432
pool_mode = transaction
max_client_conn = 200 # accept up to 200 FPM connections
default_pool_size = 20 # multiplex onto 20 PostgreSQL connections
reserve_pool_size = 5
server_reset_query = DISCARD ALL # critical: reset state between clients
# PHP connects to PgBouncer instead of PostgreSQL directly:
# $dsn = 'pgsql:host=localhost;port=6432;dbname=myapp';
# PDO::ATTR_PERSISTENT = false (important — persistent defeats the purpose)
# The problem: Doctrine and other PHP ORMs use prepared statements.
# Transaction-mode PgBouncer reassigns backends between transactions.
# Prepared statements are per-backend.
# The prepared statement that worked in the previous transaction
# may not exist on the backend assigned for the next transaction.
# Symptom:
# SQLSTATE[26000]: Invalid sql statement name:
# prepared statement "pdo_stmt_00000003" does not exist This solves the connection count problem elegantly. 200 FPM workers share 20 PostgreSQL connections. The multiplexing ratio depends on how long your transactions take — a 50ms transaction on a 20-connection pool gives you approximately 400 transactions per second of throughput, which is ample for most PHP applications.
But transaction mode introduces its own compatibility issue: prepared statements break.
PDO's default behavior is to use server-side prepared statements. When you call $stmt = $pdo->prepare("SELECT...") followed by $stmt->execute(), PDO sends a PostgreSQL PARSE command (creating a named prepared statement on the backend) and then an EXECUTE command. The prepared statement lives on the specific backend process where it was created.
In PgBouncer's transaction mode, the backend process changes between transactions. Your prepared statement was created on backend A, but the next transaction runs on backend B. Backend B has never heard of pdo_stmt_00000003. The result: SQLSTATE[26000]: Invalid sql statement name.
This affects every PHP ORM and database abstraction layer. Doctrine DBAL, Laravel's query builder, Symfony's database layer, and raw PDO all use server-side prepared statements by default.
I should be honest about PgBouncer's position here: it is doing what it was designed to do. Transaction-mode pooling is a legitimate optimization that works well for applications that do not use session-level features. The problem is not PgBouncer — it is the expectation that a transparent connection multiplexer can be truly transparent when the protocol it multiplexes is stateful. It cannot. And pretending it can, by papering over the incompatibility with emulated prepares, merely trades one set of problems for another.
"PgBouncer's architecture is single-threaded and event-loop based. It cannot utilise more than one CPU core regardless of available hardware. On a 64-core server, PgBouncer uses one core."
— from You Don't Need Redis, Chapter 17: Sorting Out the Connection Poolers
Fixing Doctrine and PDO behind PgBouncer
There are three approaches, each with distinct trade-offs. I shall present them in order of increasing correctness.
<?php
// Doctrine DBAL + PgBouncer transaction mode:
// You must disable emulated prepares OR server-side prepares.
// Option 1: Force emulated prepares (PDO handles placeholders client-side)
$connection = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => '127.0.0.1',
'port' => 6432, // PgBouncer port
'dbname' => 'myapp',
'user' => 'appuser',
'password' => 'secret',
'driverOptions' => [
PDO::ATTR_EMULATE_PREPARES => true, // PDO interpolates parameters client-side
],
]);
// This "works" but:
// - No server-side query plan caching (every query re-parsed, re-planned)
// - Slight SQL injection risk if you pass non-UTF-8 strings
// - 10-15% slower for repeated queries vs true prepared statements
// Option 2: Use PDO::ATTR_EMULATE_PREPARES = false (default)
// but accept that PgBouncer may break prepared statements
// unless you run PgBouncer >= 1.21 with max_prepared_statements
// Option 3: Use PgBouncer in session mode instead of transaction mode
// This preserves prepared statements but limits multiplexing.
// Each PHP-FPM worker holds a session for the request duration.
// Connection reuse only happens when an FPM worker becomes idle. Emulated prepares (PDO::ATTR_EMULATE_PREPARES = true) are the most common fix. PDO interpolates parameters into the SQL string client-side and sends the complete query as a simple query, bypassing server-side PARSE/EXECUTE entirely. This is compatible with any PgBouncer mode because no prepared statement state exists on the server.
The cost: you lose server-side query plan caching. Every query is parsed and planned from scratch. For applications that execute the same handful of queries thousands of times per minute — which describes most CRUD applications — this means 10-15% higher query latency. The parse and plan phases are fast individually (0.2-0.5ms for simple queries), but the overhead compounds. An application executing 50 queries per request, 100 requests per second, pays an additional 1-2.5 seconds of aggregate planning overhead per second. At scale, this is not trivial.
There is also a subtle security consideration. When PDO emulates prepares, it must correctly escape parameter values during string interpolation. PDO's escaping is generally reliable, but it has historically had edge cases with multibyte character sets. Server-side prepared statements avoid this entirely because the SQL and parameters are sent separately — the server never sees them as a combined string.
PgBouncer 1.21+ with max_prepared_statements is the better long-term fix if you control your PgBouncer version. It tracks prepared statements across backend reassignments and re-prepares them as needed. Doctrine's prepared statements work without modification.
# PgBouncer 1.21+ with prepared statement tracking.
# This is the specific fix for the prepared statement problem
# in transaction mode.
[pgbouncer]
listen_port = 6432
pool_mode = transaction
max_client_conn = 200
default_pool_size = 20
# The key setting — added in PgBouncer 1.21:
max_prepared_statements = 200
# How it works:
# 1. Client sends PARSE (create prepared statement) to PgBouncer
# 2. PgBouncer records the statement name and SQL text
# 3. PgBouncer forwards the PARSE to the current backend
# 4. Transaction completes, backend returns to pool
# 5. Next transaction: client references the prepared statement
# 6. PgBouncer checks if the new backend has this statement
# 7. If not, PgBouncer re-sends the PARSE before the EXECUTE
#
# This is transparent to the client. Doctrine, Laravel, raw PDO —
# all work without modification.
# The limitations:
# - max_prepared_statements caps the total tracked per backend
# - If exceeded, oldest statements are evicted and re-prepared on demand
# - Adds slight latency for the re-prepare step (~0.2ms)
# - Requires PgBouncer 1.21+ (released December 2023)
# - Not all Linux distributions package 1.21 yet
# Verify your PgBouncer version:
# pgbouncer --version
# If it says < 1.21, you need the emulated prepares workaround. The PgBouncer changelog documents this feature under the 1.21 release. It is the most complete solution to the prepared statement problem in transaction mode. However, it requires a PgBouncer version that not all Linux distributions package by default. If you are running PgBouncer from your distribution's package manager, check the version before relying on this feature.
The DISCARD ALL tax
PgBouncer's server_reset_query setting controls what happens when a backend connection is returned to the pool. The recommended value for PostgreSQL is DISCARD ALL, which resets every piece of session state.
-- What server_reset_query = DISCARD ALL actually does:
DISCARD ALL;
-- This is equivalent to running ALL of these:
RESET ALL; -- reset all session parameters to defaults
DEALLOCATE ALL; -- destroy all prepared statements
CLOSE ALL; -- close all cursors
UNLISTEN *; -- stop listening on all channels
SELECT pg_advisory_unlock_all(); -- release all advisory locks
DISCARD PLANS; -- discard cached query plans
DISCARD SEQUENCES; -- discard cached sequence values
DISCARD TEMP; -- drop all temporary tables
SET SESSION AUTHORIZATION DEFAULT; -- reset role
-- DISCARD ALL takes ~0.5ms on a quiet server.
-- On a busy server with many prepared statements, it can take 2-5ms.
-- That is 2-5ms of overhead on EVERY connection checkout in transaction mode.
-- At 1,000 requests/second, that is 2-5 seconds of cumulative overhead per second.
-- Some teams use a lighter reset query:
-- server_reset_query = RESET ALL; DEALLOCATE ALL;
-- Faster, but does not release advisory locks or clean up temp tables.
-- Whether that matters depends on your application. DISCARD ALL is thorough. It prevents every state leakage problem that persistent connections suffer from. But it is not free.
At 0.5-2ms per invocation, the reset query adds overhead to every connection checkout in transaction mode. For a high-throughput application doing 2,000 transactions per second across 20 backend connections, each connection is checked out approximately 100 times per second. The reset query adds 50-200ms of aggregate overhead per second per backend — a measurable tax on throughput.
I should note the irony: DISCARD ALL destroys prepared statements. If you are using PgBouncer 1.21+ with max_prepared_statements to preserve prepared statements across transaction boundaries, DISCARD ALL in the reset query undoes that work. The statement tracking re-prepares on demand, so correctness is maintained, but the caching benefit is diminished. Each checkout potentially pays both the DISCARD ALL cost and the re-prepare cost.
Some teams compromise with a lighter reset query: RESET ALL; DEALLOCATE ALL. This resets session parameters and destroys prepared statements but does not release advisory locks, drop temp tables, or reset the session authorization. Whether this is safe depends entirely on your application. If you never use advisory locks, temp tables, or SET ROLE, the lighter query is fine. If you use any of them, it is not.
The fundamental tension is this: the more thorough the reset query, the safer the state isolation, but the higher the per-transaction overhead. The less thorough the reset query, the faster the pooler, but the more your application must guarantee it never uses the features that the lighter reset does not clean up. This is a configuration decision that requires knowledge of your application's behaviour — and applications change. A reset query that was safe last year may not be safe after the next developer adds a SET ROLE call in a new feature.
Session mode: the overlooked middle ground
Most guides present the choice as "persistent connections vs. PgBouncer transaction mode." There is a third option that is curiously underrepresented in PHP documentation.
# PgBouncer session mode — preserves all PostgreSQL session state.
[pgbouncer]
listen_port = 6432
pool_mode = session
max_client_conn = 200
default_pool_size = 30 # more backend connections needed in session mode
server_reset_query = DISCARD ALL
# In session mode, each client holds a backend for the entire connection.
# Prepared statements, SET commands, advisory locks — all preserved.
# Multiplexing only happens when the client disconnects.
# The trade-off:
# Transaction mode: 200 FPM workers share 20 backends (10:1 ratio)
# Session mode: 200 FPM workers share 30 backends (6.7:1 ratio)
#
# You need more backend connections, but fewer than without pooling.
# Short-lived PHP requests mean connections recycle quickly.
# A 50ms request means one backend can serve ~20 requests/second.
# 30 backends = ~600 requests/second capacity.
# For most PHP applications, this is more than sufficient. PgBouncer in session mode assigns a backend connection to each client for the duration of the client's connection — not per transaction, but per session. When the PHP-FPM worker closes the connection at the end of the request, the backend returns to the pool.
This provides two critical advantages over transaction mode:
- Prepared statements work — the backend does not change mid-session, so all prepared statements remain valid.
- Full state isolation — each request gets a clean session (via
server_reset_query) without breaking stateful features like advisory locks orSETcommands within the request.
The trade-off is lower multiplexing efficiency. In transaction mode, a backend is occupied only during active transactions — perhaps 5-50ms of a 200ms request. In session mode, the backend is held for the entire request duration. A 200ms PHP request holds a backend for 200ms, even if database queries account for only 25ms of that time.
For PHP applications specifically, this trade-off is often acceptable. PHP requests are short-lived (50-500ms typically). A pool of 30 backend connections in session mode can serve 60-600 requests per second, depending on request duration. For most PHP applications — not real-time chat, not streaming analytics, but conventional web request-response — that is sufficient headroom.
The key insight: do not use PDO::ATTR_PERSISTENT with PgBouncer session mode. Persistent connections defeat the purpose. Let each PHP request open a connection to PgBouncer, use it, and close it. PgBouncer handles the backend pooling. The connection setup overhead to PgBouncer is negligible (0.1-0.3ms for a local Unix socket connection) compared to the 3-5ms of connecting directly to PostgreSQL.
An honest counterpoint: if your application serves more than 500 requests per second with request durations averaging over 200ms, session mode may not provide sufficient multiplexing. You would need 100+ backend connections, which begins to strain PostgreSQL's connection management. In that regime, transaction mode with PgBouncer 1.21+ is the better choice — or a proxy that provides session-level semantics with transaction-level multiplexing. I mention this because session mode is not universally correct, and recommending it without qualification would be a disservice.
Monitoring: a health check that catches problems before your users do
Whichever connection strategy you adopt, monitoring is non-negotiable. The failure modes I have described — orphaned transactions, leaked locks, connection count creep — develop gradually. They are detectable well before they cause an outage, but only if you are looking.
<?php
// A proper health check endpoint that catches connection problems
// BEFORE your users do.
// /health.php or a framework route
function checkDatabaseHealth(PDO $pdo): array {
$checks = [];
// 1. Basic connectivity
try {
$pdo->query("SELECT 1");
$checks['connectivity'] = 'ok';
} catch (PDOException $e) {
$checks['connectivity'] = 'failed: ' . $e->getMessage();
return $checks; // no point continuing
}
// 2. Connection count vs limit
$row = $pdo->query("
SELECT
count(*) AS current,
setting::int AS max
FROM pg_stat_activity
CROSS JOIN pg_settings
WHERE pg_settings.name = 'max_connections'
GROUP BY setting
")->fetch();
$usage = $row['current'] / $row['max'];
$checks['connections'] = [
'current' => (int)$row['current'],
'max' => (int)$row['max'],
'usage' => round($usage * 100, 1) . '%',
'status' => $usage > 0.8 ? 'warning' : 'ok',
];
// 3. Idle-in-transaction connections (orphaned transactions)
$idle_txn = $pdo->query("
SELECT count(*) AS cnt
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > interval '60 seconds'
")->fetchColumn();
$checks['orphaned_transactions'] = [
'count' => (int)$idle_txn,
'status' => $idle_txn > 0 ? 'warning' : 'ok',
];
// 4. Leaked advisory locks
$leaked = $pdo->query("
SELECT count(*) AS cnt
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
AND a.state = 'idle'
")->fetchColumn();
$checks['leaked_advisory_locks'] = [
'count' => (int)$leaked,
'status' => $leaked > 0 ? 'warning' : 'ok',
];
return $checks;
}
// Wire this into your monitoring. Alert on warnings.
// If orphaned_transactions > 0, you have a persistent connection
// state leakage problem. It is not a matter of if it causes
// data loss — it is a matter of when. Wire this endpoint into your monitoring system. The three warning conditions — connection usage above 80%, orphaned transactions older than 60 seconds, any advisory locks held by idle connections — are the early warning signs that persistent connection state leakage is occurring. If you see any of them, the problem is active and growing.
I would suggest checking this endpoint every 30 seconds. Connection exhaustion can develop from "warning" to "FATAL" in under a minute during a traffic spike, and you want as much lead time as the monitoring cadence allows.
One additional metric worth tracking: the idle in transaction connection age distribution. A histogram of how long connections have been in the idle in transaction state, updated every minute, will show you whether orphaned transactions are a rare anomaly or a systemic pattern. If the median age is over 5 seconds, you have a persistent connection state leakage problem. If the maximum age is over an hour, you have an FPM worker holding an orphaned transaction that will not resolve until the worker recycles.
"If idle connections dominate — dozens or hundreds of connections sitting open with no work — you have a pooling problem."
— from You Don't Need Redis, Chapter 18: The PostgreSQL Performance Decision Framework
The full comparison
Every approach, side by side. Choose based on what your application actually does, not on what a tutorial recommended for a different stack.
| Approach | Pooling model | Prepared stmts | State isolation | Risk level |
|---|---|---|---|---|
| PDO persistent connections | None (process-level reuse) | Yes | None | High |
| PgBouncer transaction mode | Transaction-level | Broken (without 1.21+) | DISCARD ALL | Medium |
| PgBouncer session mode | Session-level | Yes | Full | Low |
| PgBouncer 1.21+ transaction | Transaction-level | Yes (tracked) | DISCARD ALL + stmt tracking | Low-Medium |
| Gold Lapel proxy | Session-level (managed) | Yes | Full | Low |
A few observations worth highlighting.
PDO persistent connections are the worst option for PostgreSQL. They provide the highest risk for the least benefit. The connection reuse they offer (avoiding 3-5ms of connection setup per request) is trivially replaced by any connection pooler, without the state leakage problems.
PgBouncer transaction mode is the most common choice but requires either emulated prepares (slower queries) or PgBouncer 1.21+ (newer version dependency). If your application is simple — CRUD operations, no advisory locks, no SET commands, no LISTEN/NOTIFY — transaction mode with emulated prepares works well.
Session mode is underrated for PHP. PHP's request-response model naturally produces short-lived connections, which is exactly what session-mode pooling is designed for. The reduced multiplexing ratio is a real cost, but for applications under 500 requests per second, it rarely matters.
No solution is perfect. A waiter who overstates his case is no waiter at all. Transaction mode breaks prepared statements. Session mode limits multiplexing. Even Gold Lapel adds a network hop and a new process to monitor. The question is not "which approach has no trade-offs" — there is no such approach. The question is "which trade-offs match my application's actual behaviour." And for the vast majority of PHP applications, the answer is: anything other than PDO::ATTR_PERSISTENT.
What Gold Lapel does differently
I have shown you five approaches and their trade-offs. Allow me — briefly — to mention the sixth.
<?php
// Gold Lapel: install the package, remove the workarounds.
// Before (direct to PostgreSQL with persistent connection hacks):
$pdo = new PDO('pgsql:host=localhost;port=5432;dbname=myapp', 'appuser', 'secret', [
PDO::ATTR_PERSISTENT => true, // hoping for the best
PDO::ATTR_EMULATE_PREPARES => true, // working around PgBouncer
]);
// After (through Gold Lapel):
$pdo = new PDO('pgsql:host=localhost;port=5433;dbname=myapp', 'appuser', 'secret', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
// No ATTR_PERSISTENT needed — GL pools connections server-side.
// No ATTR_EMULATE_PREPARES needed — GL handles prepared statements correctly.
]);
// GL maintains a pool of 20 PostgreSQL connections (configurable).
// Your 50 FPM workers connect to GL. GL multiplexes onto 20 backends.
// Prepared statements work. Session state is managed. No configuration games.
// Scale to 3 app servers? 150 FPM workers still share 20 backends.
// No max_connections arithmetic. No PgBouncer tuning.
// Connection pooling that understands PostgreSQL's protocol, not just TCP. Gold Lapel is a PostgreSQL proxy with session-mode connection pooling built in. Your PHP-FPM workers connect to Gold Lapel on port 5433. Gold Lapel maintains a pool of 20 backend connections to PostgreSQL (configurable) and multiplexes your worker connections onto them.
The critical difference from PgBouncer: Gold Lapel understands PostgreSQL's protocol at the query level, not just the transaction level. It parses every query through pg_query, tracks prepared statement state per client, and manages session affinity intelligently. Prepared statements work. Advisory locks work. SET commands work. No DISCARD ALL overhead. No emulated prepares workaround.
The practical result for PHP teams:
- Remove
PDO::ATTR_PERSISTENTfrom your configuration. You do not need it. - Remove
PDO::ATTR_EMULATE_PREPARES. Server-side prepared statements work correctly through Gold Lapel. - Stop counting connections. 3 app servers with 50 FPM workers each? 150 connections to Gold Lapel, multiplexed onto 20 PostgreSQL backends. No
max_connectionsarithmetic. - Stop restarting FPM to clear leaked state. There is no leaked state. Each request gets a properly managed session.
Connection pooling is one of Gold Lapel's capabilities — it also provides auto-indexing, query rewriting, and performance analysis. But for PHP teams hitting the "too many clients" wall at 3 AM, the pooling alone justifies the change. Add the package. Delete the workarounds. Move on to problems that actually require your attention.
The migration path: from persistent connections to proper pooling
If you are currently running PDO::ATTR_PERSISTENT => true and you would like to stop, the transition need not be dramatic. Allow me to suggest a sequence.
- Deploy the health check endpoint from the monitoring section above. Establish baseline numbers for connection count, orphaned transactions, and leaked advisory locks. You may discover the persistent connection problems are worse than you thought.
- Set
pm.max_requests = 500if you have not already. This bounds the damage window while you prepare the proper fix. - Install PgBouncer on the same server as PostgreSQL, or on the app server — either works, local Unix socket preferred. Start with session mode. It is the lowest-risk change from your current setup because it preserves all session-level PostgreSQL features.
- Change your DSN to point at PgBouncer's port (6432) instead of PostgreSQL's port (5432). Set
PDO::ATTR_PERSISTENT => false. Deploy to one app server first. - Monitor for one week. Compare connection counts, query latency percentiles, and error rates against baseline. You should see: fewer PostgreSQL connections, similar or better latency, zero state leakage warnings from the health check.
- Roll out to remaining servers. Remove
PDO::ATTR_PERSISTENTfrom the configuration entirely. It should not be a configurable option — it should be absent.
The entire process takes a few hours of implementation and a week of soak time. There is no schema change, no application code modification (beyond the DSN and PDO options), and no downtime. If the monitoring shows a problem on the first server, revert the DSN. The rollback is a configuration change.
I have seen this migration performed on applications serving 50 requests per second and applications serving 5,000 requests per second. The procedure is the same. The confidence comes from the monitoring, not the scale.
Closing the door properly
The PHP-FPM and PostgreSQL connection story is, at its core, a story about assumptions. PHP assumed that reusing a database connection was a simple matter of keeping the socket open. PostgreSQL assumed that a session's state is the responsibility of whoever opened it. Neither is wrong. But the combination — process-level connection caching with no session management, against a database engine that maintains rich session state — produces a failure surface that is as broad as it is subtle.
The persistent connection was designed for a simpler era of database interaction. When connections were expensive, session state was minimal, and a single application server talked to a single database. That era ended when load balancers, container orchestration, and multi-tenant architectures became the norm. The cost of establishing a connection dropped to 3-5ms. The cost of mismanaging a persistent session — data loss, lock leakage, tenant isolation violations, OOM kills — grew to encompass everything that matters about your application's reliability.
A connection pooler is not an optimization. It is a correction. It places session management where it belongs — in a component designed for the purpose — and frees your PHP application to do what it does well: handle requests, produce responses, and move on. No inherited state. No leaked locks. No orphaned transactions. No arithmetic that breaks at 3 AM.
Your PHP-FPM workers have been holding connections they cannot properly manage. I trust you will now relieve them of the responsibility. In infrastructure, as in service, the highest standard is this: the guest should never have to think about how the household operates. It should simply work.
Frequently asked questions
Terms referenced in this article
A guest who found this investigation useful tends also to appreciate the PHP-FPM pool sizing guide — it addresses the arithmetic that precedes the crisis: how many workers, how many connections, and the formula that keeps the two in balance. Prevention, as they say, is the superior remedy.