PHP-FPM Pool Sizing and PostgreSQL max_connections: The Capacity Planning Guide You've Been Missing
One formula, two config files, and the arithmetic that prevents your database from falling off a cliff at 3 AM.
Good evening. Your PHP-FPM workers and your PostgreSQL server need to have a conversation about boundaries.
Every PHP-FPM worker can hold a PostgreSQL connection. Every PostgreSQL connection consumes memory, a process slot, and a share of the server's finite scheduling capacity. When the number of FPM workers across all your application servers exceeds what PostgreSQL can comfortably handle, performance does not degrade gracefully. It falls off a cliff.
This is not theoretical. It is the most common production failure mode in PHP + PostgreSQL deployments, and it happens because pm.max_children and max_connections live in different config files, on different servers, managed by different people, with no built-in mechanism to keep them in agreement.
I have seen this particular catastrophe unfold at 2 AM on a Saturday, at 11:30 AM on Black Friday, and — most memorably — at 9:01 AM on a Monday when an autoscaler decided to be helpful. In each case, the database did not run out of CPU or memory or disk. It ran out of connection slots. The application returned 500 errors. The monitoring dashboard, itself unable to connect, went dark. And somewhere, a postgresql.conf with max_connections = 100 sat quietly, having done exactly what it was told.
There is a formula. It is not complicated. But it requires knowing six numbers, and most teams know at best two of them. We are going to fix that, and then we are going to discuss why the formula itself — while necessary — represents a structural problem that no amount of arithmetic can permanently resolve.
How PHP-FPM manages processes (and why each one is a connection)
PHP-FPM (FastCGI Process Manager) maintains a pool of worker processes. Each worker handles one HTTP request at a time, sequentially. While a worker is processing a request, it is unavailable for other requests. When all workers are busy, new requests queue at the web server level (Nginx, Apache) until a worker becomes free or the request times out.
This is a fundamentally different model from, say, Node.js or Go, where a single process handles thousands of concurrent requests through event loops or goroutines. In PHP, concurrency equals processes. If you want to handle 50 simultaneous requests, you need 50 FPM workers. There is no other way. This is the architectural reality that shapes everything that follows.
The critical setting is pm.max_children. This is the absolute ceiling on worker processes for a given pool. With pm = dynamic, FPM scales between pm.min_spare_servers and pm.max_children based on demand. With pm = static, all workers are spawned at startup and kept alive permanently. With pm = ondemand, workers are created only when requests arrive and reaped after an idle timeout.
; /etc/php/8.3/fpm/pool.d/www.conf
[www]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
; --- Process manager ---
pm = dynamic
pm.max_children = 50 ; absolute ceiling: total worker processes
pm.start_servers = 10 ; workers spawned at FPM startup
pm.min_spare_servers = 5 ; minimum idle workers kept alive
pm.max_spare_servers = 15 ; maximum idle workers before reaping
pm.max_requests = 1000 ; recycle a worker after 1000 requests (leak guard)
; --- Timing ---
request_terminate_timeout = 30 ; kill any request running > 30 seconds
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 5 ; log requests taking > 5 seconds
; Each worker holds ONE persistent connection to PostgreSQL.
; 50 workers = up to 50 simultaneous Postgres connections from this pool. Here is the fact that creates the capacity planning problem: when a PHP application uses persistent connections (via pg_pconnect(), PDO with ATTR_PERSISTENT, or framework connection pooling), each FPM worker holds its own dedicated PostgreSQL connection for its entire lifetime. Not per request. Per process.
50 workers = 50 connections. On 3 servers = 150 connections. That number is set the moment FPM starts. It does not care whether those workers are actively running queries or sitting idle waiting for the next request. It does not care that your actual query concurrency at any given moment is perhaps 8 out of 150. The connections exist because the workers exist, and the workers exist because you need HTTP concurrency.
Without persistent connections, the situation is different but not necessarily better. Each request opens a new connection (3-5ms, 15ms with TLS), runs its queries, and closes the connection. The connection count fluctuates with traffic rather than with worker count. But the connection setup overhead adds up fast under load, and during traffic spikes, you can still exhaust max_connections — just unpredictably. I have a slight preference for the predictability of persistent connections, even though their wastefulness offends me. At least you can do arithmetic with a constant.
The three process manager modes and their connection implications
The choice between pm = static, pm = dynamic, and pm = ondemand is not merely a PHP performance decision. It is a PostgreSQL capacity planning decision, and I find that most teams make it without considering the database at all.
; pm = static
; All workers are spawned at startup and never reaped.
; Predictable: you always have exactly pm.max_children processes.
; Connection count is constant: pm.max_children persistent connections.
; Memory usage is constant: pm.max_children x worker_memory.
;
; Best for: dedicated application servers with stable traffic patterns.
; Worst for: servers handling variable traffic or running other services.
[www]
pm = static
pm.max_children = 40
; 40 workers at all times. 40 persistent PG connections at all times.
; No scaling, no surprises, no connection storms.
; ====================================================================
; pm = dynamic
; Workers are spawned and reaped based on demand.
; min_spare_servers idles are kept alive. max_spare_servers caps idle.
; Connection count fluctuates with worker count.
;
; Best for: shared servers, variable traffic, cost-conscious deployments.
; Worst for: predictable capacity planning (the connection count moves).
[www]
pm = dynamic
pm.max_children = 50 ; ceiling — the number you use for the formula
pm.start_servers = 10 ; on startup
pm.min_spare_servers = 5 ; always keep at least 5 idle
pm.max_spare_servers = 15 ; reap idle workers above 15
; At low traffic: ~10-15 workers, ~10-15 PG connections.
; At peak: up to 50 workers, up to 50 PG connections.
; Your formula must accommodate the CEILING, not the average.
; ====================================================================
; pm = ondemand
; Workers are spawned only when a request arrives.
; Workers are reaped after pm.process_idle_timeout seconds of inactivity.
; At zero traffic, zero workers. Zero PostgreSQL connections.
;
; Best for: low-traffic admin panels, staging environments.
; Worst for: latency-sensitive production. First request pays spawn cost.
[www]
pm = ondemand
pm.max_children = 20
pm.process_idle_timeout = 10 ; kill idle workers after 10 seconds
; Connection count fluctuates wildly. Hard to capacity plan.
; But at least it won't hold 50 idle PG connections at 4 AM. Allow me to be direct about the trade-offs.
Static is the easiest to capacity plan. You know exactly how many connections each server will hold, at all times, in all traffic conditions. The connection budget formula gives you a precise, unchanging answer. The cost is that you pay for the maximum at all times — during the 3 AM lull when three workers would suffice, you have 40 running, each holding a PostgreSQL connection that is doing precisely nothing.
Dynamic is the most common choice and, I should note, the most frequently misconfigured. The mistake I see repeatedly: teams set pm.max_children = 50, observe that their server typically runs 15 workers, and conclude they have a connection budget based on 15 workers per server. They do not. Under load, FPM will scale to 50. The connection budget formula must use pm.max_children — the ceiling — not the average. The average is a comforting lie.
Ondemand is a special case. Workers are spawned on arrival and reaped after process_idle_timeout seconds. This means your connection count can swing from 0 to pm.max_children in the time it takes a traffic spike to arrive. This creates connection storms: PostgreSQL sees a burst of new connection requests, each consuming the TCP handshake, authentication, and backend process spawn overhead. On a server that was peacefully idle a moment ago, this is unwelcome. For low-traffic admin panels, ondemand is perfectly sensible. For production traffic, I would not recommend it.
What PostgreSQL does with all those connections
PostgreSQL's architecture is process-per-connection. Each client connection spawns a dedicated backend process on the server. This process has its own memory allocation, its own query execution context, its own transaction state. It is an elegant design for correctness and isolation. It is an expensive design for scale.
I should be clear about what "expensive" means, because the word is often used loosely. Each PostgreSQL backend process is not a lightweight thread sharing memory with its siblings. It is a full Unix process, forked from the postmaster, with its own address space. The OS must maintain page tables, schedule CPU time, and handle context switches for each one independently.
# /etc/postgresql/16/main/postgresql.conf
max_connections = 120 # total connection slots
superuser_reserved_connections = 3 # always keep 3 for superuser access
# Available for applications: 120 - 3 = 117
# Performance-relevant settings that scale with connection count:
shared_buffers = 4GB # 25% of RAM (standard guidance)
work_mem = 16MB # per-sort/hash — multiplied by connections
# 50 connections x 16MB = 800MB worst case
effective_cache_size = 12GB # OS page cache estimate The costs of each connection, itemized for the accountants among you:
- Memory — base overhead. Each backend process allocates catalog caches, connection state, and internal buffers. On a typical installation, this is 5-10 MB per connection before any queries run. With 200 connections, that is 1-2 GB consumed merely by existing.
- Memory — per-operation. Each sort, hash, or materialization operation within a query allocates up to
work_mem. A complex query with 3 sort operations andwork_mem = 16MBuses 48MB. Multiply by active connections. This is where the mathematics become alarming. - CPU scheduling. The OS must schedule all backend processes. With 200 processes, context switching overhead becomes significant — even when most processes are idle. The scheduler does not know that 180 of your 200 processes are sleeping. It must consider them all.
- Lock management. PostgreSQL's lock manager scales poorly with connection count. More connections means more potential lock holders and waiters, more deadlock detection cycles, and more time spent in lock management rather than query execution. Deadlock detection is O(n²) in the number of waiting transactions.
- Shared buffer contention. All backend processes share the buffer pool. More processes competing for buffer pool access means more contention on buffer pool locks (particularly the
BufMappingLockin older versions). PostgreSQL 16 improved this with partitioned buffer pool locks, but the fundamental contention remains at high connection counts.
The max_connections setting is a hard limit. Connection 121 on a server with max_connections = 120 receives a "too many connections" error and is refused. There is no queue. There is no graceful degradation. There is no "please hold, your connection is important to us." The connection is rejected, and in a PHP application, this typically manifests as a PDOException that surfaces as a 500 error to the end user.
I find this behaviour — refusing connections without any waiting mechanism — to be the infrastructural equivalent of a maître d' who, upon discovering the dining room is full, simply locks the front door and turns off the lights. Effective, certainly. Hospitable, it is not.
The formula
This is the arithmetic that needs to be true at all times, under all traffic conditions, including during deployments when old and new application servers may be running simultaneously.
# THE FORMULA
# ============================================================
#
# (pools x pm.max_children) <= max_connections
# - superuser_reserved_connections
# - monitoring_connections
# - replication_slots
#
# Or, stated as a budget:
#
# connection_budget = max_connections
# - superuser_reserved_connections
# - monitoring_connections
# - replication_slots
#
# per_pool_limit = floor(connection_budget / number_of_pools)
# pm.max_children <= per_pool_limit
#
# ============================================================
#
# Example: 1 app server, 1 FPM pool
# max_connections = 120
# superuser_reserved = 3
# monitoring (Datadog, etc) = 2
# replication slots = 0
# ---------------------------------
# connection_budget = 115
# pools = 1
# pm.max_children <= 115
#
# Example: 3 app servers, each running 1 FPM pool
# connection_budget = 115
# pools = 3
# pm.max_children <= 38 (per server)
#
# Example: 2 app servers, each running 2 FPM pools (api + admin)
# connection_budget = 115
# pools = 4
# pm.max_children <= 28 (per pool) The formula itself is straightforward. The difficulty is in accounting for every consumer of the connection budget. This is where teams get into trouble — not because the math is hard, but because the inventory is incomplete.
- FPM pools. Count every pool on every server. A "pool" in FPM terms is a
[section]in the pool configuration. Many deployments run separate pools for the web application, admin panel, queue workers, and cron jobs — each with their ownpm.max_children. I have audited deployments with six distinct pools across four servers. The team was aware of two. - Superuser reserved.
superuser_reserved_connections(default: 3) keeps slots available for thepostgressuperuser. This is your emergency access — the ability to connect when everything else is full, diagnose the problem, and kill offending connections. Never include these in the application budget. They are your fire escape, not your front door. - Monitoring. Datadog, pgwatch2, pg_stat_monitor, Prometheus postgres_exporter — each holds 1-2 connections. Count them. I have encountered deployments where the monitoring stack consumed 8 connections across 4 different agents, none of which appeared in any capacity planning document.
- Replication. Each streaming replica consumes a connection on the primary. WAL senders and replication slots are not free. If you have 2 replicas, that is 2 connections reserved for replication, permanently.
- Migration tools. If you run Doctrine migrations, Laravel migrations, or manual schema changes during deployment, these briefly consume additional connections. A migration that takes 30 seconds holds a connection for 30 seconds. During a deployment with 5 pending migrations, that could be 5 connections for 2-3 minutes.
- Background jobs. Laravel Horizon, Symfony Messenger workers, custom PHP daemons — if they connect to PostgreSQL, they consume connection slots. Queue workers are especially insidious because they are often on separate servers that nobody includes in the FPM pool count.
A safe margin is 10-15% of max_connections left unused. If your math puts you at 95% capacity under normal load, a traffic spike or a deployment overlap will push you to 100% — and connection 121 gets the door. I prefer 20% headroom for production systems. Generosity with margins is a sign of operational maturity, not waste.
The performance cliff: why 150 connections is worse than 20
Most teams set max_connections high enough to accommodate their FPM workers and consider the problem solved. It is not solved. It is masked. You have ensured that no connection will be refused. You have not ensured that any connection will perform well.
PostgreSQL's throughput does not scale linearly with connections. It peaks at a modest number — typically 2-4x the CPU core count — and then declines. This is well-documented but chronically underappreciated. Allow me to make it concrete.
| Active connections | Transactions/sec | P50 latency | P99 latency | CPU | Notes |
|---|---|---|---|---|---|
| 10 | 8,200 | 1.2ms | 4.1ms | 65% | Sweet spot for 4-core |
| 20 | 9,100 | 1.4ms | 5.8ms | 82% | Still healthy |
| 50 | 8,800 | 2.1ms | 18ms | 95% | Throughput plateaus |
| 100 | 7,200 | 4.8ms | 85ms | 99% | Throughput declining |
| 200 | 5,100 | 12ms | 320ms | 99% | Lock contention dominates |
| 500 | 2,800 | 48ms | 1.2s | 99% | Performance cliff |
pgbench on a 4-core / 16GB instance, PostgreSQL 16, read-write workload. Your numbers will differ, but the shape of the curve is universal.
At 10 connections on a 4-core server, throughput is 8,200 TPS with sub-5ms P99 latency. At 200 connections, throughput has dropped 38% and P99 latency has ballooned to 320ms. At 500, you have lost 66% of your throughput and P99 has crossed one second. Please read that again. Five hundred connections delivers less than half the throughput of twenty connections. You have added 480 connections and received negative value for every single one.
The cause is contention. With more active connections than CPU cores, backend processes compete for CPU time, shared buffer access, and lock acquisition. Context switches multiply. The lock manager spends more cycles on deadlock detection. Each query takes longer, which means connections are held longer, which means more connections are active simultaneously. It is a feedback loop — the technical term is "convoy effect" — and once it begins, it accelerates.
I should offer an honest counterpoint here. The table above shows active connections, not total. If you have 150 total connections but only 10 are running queries at any given moment, you do not experience the performance cliff. The overhead of 140 idle connections is real but modest — memory consumption and scheduling overhead, not query contention. The cliff occurs when many connections are simultaneously executing queries, which happens during traffic spikes, batch operations, or when slow queries hold connections open longer than expected.
This distinction matters, but it does not comfort. You do not capacity plan for the average moment. You capacity plan for the worst moment. And when a burst of traffic arrives and 80 of your 150 workers simultaneously hit the database, you are on the cliff — regardless of what the average looked like five minutes ago.
The implication for PHP-FPM sizing is significant: setting max_connections = 300 to accommodate your FPM workers does not give you more database capacity. It gives you less. The optimal number of active connections to PostgreSQL is far smaller than the number of FPM workers you need to handle HTTP traffic.
This is the fundamental mismatch. PHP needs many workers because each one handles a single request. PostgreSQL needs few connections because each one consumes significant resources. The two numbers serve different purposes and should not be forced into agreement. Forcing them into agreement is like sizing your electrical system based on the number of rooms in the house rather than the actual power draw. More rooms does not mean more watts, and more HTTP workers does not mean more database capacity.
Persistent connections: understanding what they are and what they are not
; php.ini — persistent connections to PostgreSQL
;
; With persistent connections enabled, each FPM worker holds
; a connection open between requests. Without them, each request
; pays the TCP + auth overhead (~3-5ms, ~15ms with TLS).
; For pg_connect / pg_pconnect:
pgsql.allow_persistent = On
pgsql.max_persistent = -1 ; -1 = unlimited (bounded by pm.max_children)
pgsql.max_links = -1 ; total connections per process
; For PDO (Laravel, Symfony, etc.):
; Set in your DSN options, not php.ini:
; new PDO($dsn, $user, $pass, [
; PDO::ATTR_PERSISTENT => true,
; ]);
;
; Laravel: set 'options' => [PDO::ATTR_PERSISTENT => true] in config/database.php
; Symfony: add ?persistent=true to DATABASE_URL
; WARNING: persistent connections are per-worker, not pooled.
; Worker A's connection cannot be used by Worker B.
; This is NOT connection pooling. It is connection reuse within a single process. Persistent connections in PHP are frequently misunderstood, and the misunderstanding leads to configuration errors that are both common and severe. Allow me to be precise about what they are.
A persistent connection in PHP-FPM means: when a worker process finishes handling a request, it does not close its PostgreSQL connection. The connection remains open, attached to that specific worker process, ready for the next request that worker handles. The connection survives across requests but belongs exclusively to one worker.
This is not connection pooling. A connection pool is shared — any thread or process can borrow a connection, use it, and return it for someone else. PHP persistent connections are per-process — Worker A's connection cannot be used by Worker B, even if Worker A is idle for the next 30 seconds waiting for a request. The connection sits there, holding a PostgreSQL backend process slot, a chunk of memory, and potentially locks from an uncommitted transaction, doing nothing productive while it waits for its specific FPM worker to receive another HTTP request.
The benefit of persistent connections is eliminating per-request connection overhead. Instead of opening and closing a connection on every request (3-15ms each way), the worker reuses its existing connection. For high-traffic applications, this saves substantial time and eliminates connection-storm scenarios during traffic spikes. On an application processing 1,000 requests per second across 50 workers, persistent connections save roughly 3-5 seconds of aggregate connection overhead per second. That is meaningful.
The cost is that your maximum connection count is determined by pm.max_children, not by actual concurrency. If you have 50 FPM workers and only 5 are actively running queries at any given moment, you still have 50 connections to PostgreSQL. 45 of them sit idle, consuming memory and process slots on the database server, doing nothing. They are guests who have checked into the hotel, occupying rooms they are not sleeping in, while other guests are turned away at the front desk.
There is a subtler cost that catches many teams. When a persistent connection encounters an error — a network timeout, a server restart, a statement_timeout cancellation — the connection may be left in an invalid state. The next request that reuses this connection may encounter unexpected behaviour: a transaction that was never committed or rolled back, a SET command that changed session parameters, a prepared statement that no longer exists. Good frameworks handle this with connection validation and automatic reconnection. Bad frameworks pass the corrupted state to the next request and let you discover it in your error logs.
This is the exact problem that connection poolers (PgBouncer, pgcat) and multiplexing proxies (Gold Lapel) solve. They decouple the number of application-side connections from the number of database-side connections, allowing 50 FPM workers to share 10 database connections — because at most 10 are ever actively querying simultaneously. The pooler manages connection state, validates connections before lending them, and handles errors transparently. It is the difference between 50 guests each having a private telephone line to the kitchen and 50 guests sharing a concierge desk with 10 lines. The kitchen's capacity has not changed. The wiring has become dramatically more efficient.
The work_mem multiplication problem
-- Why connection count affects memory, not just slots
--
-- work_mem is allocated PER OPERATION, PER CONNECTION.
-- A single query with 3 sort/hash operations uses 3 x work_mem.
--
-- With work_mem = 64MB and 100 connections:
-- Worst case: 100 x 3 x 64MB = 19.2 GB
-- You have 16 GB of RAM.
-- OOM killer enters the chat.
-- Safe guideline:
-- work_mem = (available_memory * 0.25) / (max_connections * 2)
--
-- For 16GB RAM, 120 connections:
-- work_mem = (4GB) / (240) = ~16MB
-- The fewer connections, the more work_mem each one can afford.
-- 20 connections at 64MB is safer than 200 connections at 8MB. This is the cost that does not appear in any dashboard until it is too late. Allow me to walk through the mathematics, because they are alarming and instructive.
work_mem controls how much memory PostgreSQL allocates for each sort or hash operation within a query. A SELECT with an ORDER BY clause uses one allocation. A query with a hash join and a sort uses two. A complex analytical query with three joins, a GROUP BY, and an ORDER BY might use four or five.
Each allocation is per-connection. So the worst-case memory consumption for work_mem alone is:
max_connections x operations_per_query x work_mem
With 150 connections (our 3-server PHP deployment), 2 sort operations per average query, and work_mem = 32MB:
150 x 2 x 32MB = 9.6 GB
On a server with 16 GB of RAM, 4 GB already committed to shared_buffers, and 2 GB for the OS, you have 10 GB remaining. Your theoretical worst-case work_mem consumption is 9.6 GB. You are operating on a 400 MB margin. One analytical query with an extra join, one traffic spike that pushes active connections above average, and the OOM killer intervenes with all the subtlety of a fire alarm.
The practical consequence: if you want generous work_mem for complex queries (analytical reports, large sorts, hash joins), you need fewer connections. If you have many connections, you must reduce work_mem to avoid out-of-memory conditions. This is another dimension of the same trade-off: more connections means less memory per connection, which means slower complex queries, which means connections are held longer, which means you need more connections. The feedback loop again.
I have seen teams chase this loop for months. Queries are slow, so they raise work_mem. Memory usage spikes, so they lower work_mem. Queries are slow again, so they add more FPM workers to handle the increased queue. More workers means more connections, which means even less work_mem per connection. The solution was never in postgresql.conf. The solution was fewer connections to PostgreSQL, which permits more memory per connection, which makes queries faster, which frees connections sooner. But achieving fewer connections requires breaking the 1:1 coupling between FPM workers and database connections.
Multi-server and read replica scenarios
The formula grows more complex — but not fundamentally different — when you have multiple application servers, multiple FPM pools per server, or read replicas.
# Multi-database scenario with read replicas
#
# Primary: handles writes + complex queries
# Read replica: handles read-heavy traffic (API listings, search, reports)
#
# FPM pool "api" (3 servers):
# pm.max_children = 30 per server
# Connects to: primary (writes) + read replica (reads)
# Primary connections: ~10 per server (write ratio ~30%)
# Replica connections: ~20 per server (read ratio ~70%)
#
# FPM pool "admin" (1 server):
# pm.max_children = 15
# Connects to: primary only
#
# Connection budget — PRIMARY:
# max_connections = 100
# superuser_reserved = 3
# monitoring = 2
# replication = 2 (streaming to replica)
# ----------------------------------
# available = 93
# api write connections = 3 servers x 10 = 30
# admin connections = 1 server x 15 = 15
# total demand on primary = 45 <-- comfortable headroom
#
# Connection budget — READ REPLICA:
# max_connections = 100
# superuser_reserved = 3
# monitoring = 2
# replication = 1 (WAL receiver)
# ----------------------------------
# available = 94
# api read connections = 3 servers x 20 = 60
# total demand on replica = 60 <-- comfortable headroom The key principle: each PostgreSQL instance has its own independent connection budget. The primary's max_connections governs connections to the primary. The replica's max_connections governs connections to the replica. They do not share a budget.
Read replicas are the most effective tool for reducing connection pressure on the primary. If 70% of your queries are reads (common for web applications), routing them to a replica cuts your primary connection demand by 70%. This often means the difference between "we need to raise max_connections and suffer the performance cliff" and "the primary is comfortably within budget."
However — and I must be forthcoming about this — read replicas introduce their own complexity that teams frequently underestimate:
- Replication lag. The replica is always behind the primary. For most web reads, milliseconds of lag are invisible. For reads that must see data just written (the "read your own writes" problem), you must either read from the primary or implement sticky connections. Laravel's
stickyoption handles this within a single request, but cross-request consistency requires more thought. - Double the connection accounting. You now have two connection budgets to manage. Each FPM worker that uses read/write splitting needs connections to both the primary and the replica. If 50 workers each hold a persistent connection to both, that is 50 connections on the primary and 50 on the replica — not 50 total.
- Failover complexity. When a replica fails, all connections to it die. The application must detect the failure, reconnect (either to the primary or another replica), and handle the transient errors. With persistent connections, this means every FPM worker that held a replica connection encounters an error on its next read query.
In Laravel, read/write splitting is a first-class feature via the read/write database configuration. In Symfony and Doctrine, it requires a master-slave configuration with the doctrine.dbal settings. In both cases, the application must be aware that write queries go to the primary and read queries go to the replica. This is application-level routing, not infrastructure-level — and it requires discipline to ensure writes never reach the replica.
"Connection pooling before query optimization. Query optimization before indexes. Indexes before materialized views. Each step in this sequence is more expensive than the one before it. Skip a step, and you will optimize the wrong layer."
— from You Don't Need Redis, Chapter 18: The PostgreSQL Performance Decision Framework
The memory ceiling: why your server is half-empty
# Memory budget: how pm.max_children relates to server RAM
#
# Each FPM worker consumes memory. The amount depends on your application:
# - Minimal PHP app: 20-30 MB per worker
# - Laravel/Symfony app: 40-80 MB per worker
# - Large monolith with heavy dependencies: 80-150 MB per worker
#
# Measure YOUR application:
# ps -eo pid,rss,comm | grep php-fpm | awk '{sum+=$2; n++} END {print sum/n/1024 " MB avg per worker"}'
#
# Memory budget formula:
# available_memory = total_RAM - OS_overhead - other_services
# pm.max_children = floor(available_memory / per_worker_memory)
#
# Example: 8 GB server running only PHP-FPM + Nginx
# Total RAM: 8192 MB
# OS + Nginx: 512 MB
# Available: 7680 MB
# Per worker: 60 MB (measured)
# max_children: 128 (7680 / 60)
#
# BUT: 128 persistent PG connections is too many.
# The formula says pm.max_children <= 38 (for your PG budget).
#
# So you set max_children = 38 and have 5.4 GB of unused RAM.
# Five and a half gigabytes, sitting idle, because PostgreSQL
# cannot handle the connections your PHP server could provide.
#
# This is the efficiency loss of direct connections.
# With a proxy multiplexing to 20 PG connections:
# max_children = 128 (the memory limit)
# PG connections = 20 (the proxy limit)
# RAM utilization = 100%
# No waste. No artificial ceiling. This is the calculation that makes the waste visible, and it is worth walking through slowly because it reveals the fundamental inefficiency of direct connections.
Your application server has a memory ceiling for pm.max_children: total available RAM divided by per-worker memory consumption. For an 8 GB server running a Laravel application at 60 MB per worker, that ceiling is approximately 128 workers.
Your PostgreSQL server has a connection ceiling: max_connections minus reserved and monitoring slots, divided by the number of pools. For a modest deployment — 120 max_connections, 3 servers, 1 pool each — that ceiling is 38 workers per server.
You set pm.max_children = 38 because the connection ceiling is lower. Your 8 GB server now runs 38 workers consuming 2.3 GB of RAM. The remaining 5.4 GB sits unused. Not because the server cannot handle more workers. Not because the application is CPU-bound. Not because PHP is slow. Because PostgreSQL cannot accept the connections that more workers would create.
You are paying for an 8 GB server and using 2.3 GB of it. The other 5.4 GB is a tax on direct database connections.
I see this across hundreds of deployments. Teams size their application servers for the RAM they need, discover they can only use a third of it, and either accept the waste or raise max_connections (inviting the performance cliff) or add more PostgreSQL hardware (solving a software problem with money). None of these are satisfying answers.
With a connection proxy multiplexing to 20 backend connections, pm.max_children returns to its natural limit: the memory ceiling. 128 workers, 7.7 GB utilized, 20 PostgreSQL connections. The server does the job you are paying it to do.
Seeing your connection budget in real time
-- See who is consuming your connection budget right now
SELECT
usename,
application_name,
client_addr,
state,
count(*) AS connections
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY usename, application_name, client_addr, state
ORDER BY connections DESC;
-- Typical output for a 3-server PHP deployment:
-- usename | application_name | client_addr | state | connections
-- ---------+------------------+---------------+--------+------------
-- app_user | php-fpm | 10.0.1.10 | idle | 42
-- app_user | php-fpm | 10.0.1.11 | idle | 38
-- app_user | php-fpm | 10.0.1.12 | idle | 35
-- app_user | php-fpm | 10.0.1.10 | active | 3
-- monitor | datadog-agent | 10.0.2.5 | idle | 2
-- total = 120 -- uh oh The pg_stat_activity view is your real-time connection budget audit. Group by client_addr to see connections per application server. Group by application_name to distinguish FPM pools, queue workers, and monitoring agents. Group by state to see how many are active versus idle.
The pattern to watch for: a large number of idle connections from your FPM servers. This tells you that your FPM workers are holding connections they are not using. Every idle connection is a slot that cannot be used by any other client — including your admin panel, your migration tool, or the monitoring agent that is about to alert you that connections are exhausted.
I recommend setting application_name in your PHP connection string to make this audit easier. In Laravel, add 'application_name' => 'laravel-web' to your connection options. In Symfony, append ?application_name=symfony-web to DATABASE_URL. When a crisis occurs at 3 AM and you are staring at pg_stat_activity, the difference between seeing "php-fpm" on every line and seeing "laravel-web," "laravel-admin," "laravel-horizon," and "datadog-agent" is the difference between diagnosis and guessing.
-- Detect connection storms: sudden spikes in new connections
-- Run this during peak traffic or deployments.
-- Current connection count by state:
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state;
-- state | count
-- --------------------+------
-- active | 8
-- idle | 107
-- idle in transaction | 3
-- total = 118 (out of max_connections = 120)
-- Connections opened in the last 60 seconds:
SELECT count(*)
FROM pg_stat_activity
WHERE backend_start > now() - interval '60 seconds'
AND datname = current_database();
-- If this number is high (>20) during normal traffic,
-- you may have a connection storm from FPM worker respawns
-- or a deployment overlap.
-- Find the long-running idle-in-transaction offenders:
SELECT
pid,
usename,
application_name,
state,
now() - xact_start AS transaction_duration,
now() - query_start AS last_query_age,
left(query, 100) AS query_snippet
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND xact_start < now() - interval '30 seconds'
ORDER BY xact_start;
-- idle in transaction connections hold locks AND a connection slot.
-- They are the worst of both worlds: consuming resources while doing nothing.
-- Set idle_in_transaction_session_timeout in postgresql.conf to kill them:
-- idle_in_transaction_session_timeout = '5min' Two additional patterns deserve your attention. First: connection storms. If you see a high number of connections opened in the last 60 seconds during otherwise normal traffic, something is spawning connections rapidly — often a deployment, an FPM restart, or the pm = ondemand mode ramping up. This puts burst pressure on PostgreSQL's authentication and process spawning.
Second: idle in transaction connections. These are the worst of all connection states. An idle-in-transaction connection holds a connection slot, consumes memory, and — most damagingly — holds any locks it acquired. If a SELECT took an AccessShareLock on a table and the transaction was never committed or rolled back, that lock persists, potentially blocking DDL operations and preventing vacuum from cleaning dead tuples on that table.
In PHP, idle-in-transaction typically occurs when an exception is thrown inside a transaction block and the error handler fails to roll back. The request returns a 500 error, the worker picks up a new request, and the uncommitted transaction lingers until the worker is recycled by pm.max_requests. Set idle_in_transaction_session_timeout in postgresql.conf to catch these automatically. Five minutes is a reasonable default. No legitimate PHP transaction should be open for five minutes.
The deployment overlap: when your connection count doubles
# Deployment overlap: the silent connection doubler
#
# Rolling deployment with 3 app servers:
#
# T+0s Server 1: old code (50 workers, 50 PG connections)
# Server 2: old code (50 workers, 50 PG connections)
# Server 3: old code (50 workers, 50 PG connections)
# Total PG connections: 150
#
# T+10s Server 1: deploying (draining: 50 old workers still connected)
# Server 1: new code starting (50 new workers connecting)
# Server 2: old code (50 workers, 50 PG connections)
# Server 3: old code (50 workers, 50 PG connections)
# Total PG connections: UP TO 250 <-- danger
#
# T+30s Server 1: new code (50 workers, 50 PG connections)
# Server 2: deploying (up to 100 connections from this server)
# Server 3: old code (50 workers, 50 PG connections)
# Total PG connections: UP TO 250 <-- still danger
#
# During a rolling deployment, you may briefly have DOUBLE the normal
# connection count. If your steady-state is 80% of max_connections,
# deployment pushes you to 160%. Hello, "too many connections."
#
# Solutions:
# 1. Set pm = ondemand for the old pool during drain
# (workers die as they become idle)
# 2. Use graceful shutdown: send SIGQUIT to old FPM master
# (workers finish current request, then exit)
# 3. Use a connection proxy (PgBouncer, Gold Lapel)
# (backend connections don't change during deploy)
# 4. Lower pm.max_children to leave deployment headroom
# (trades steady-state capacity for deployment safety) This is the scenario that catches teams who have done the connection budget math perfectly for steady state and neglected to account for transient conditions. It is, in my experience, the single most common trigger for production "too many connections" errors.
During a rolling deployment, you take servers out of the load balancer one at a time, deploy new code, and bring them back. The intention is zero downtime. The reality is a period where old FPM workers are draining (finishing their current requests) while new FPM workers are starting (spawning and opening fresh connections). Both hold PostgreSQL connections simultaneously.
If your steady-state connection count is 150 (3 servers x 50 workers) and your max_connections is 160, you have 10 slots of headroom. During deployment, the overlap on a single server can add 50 connections. You are now at 200 against a limit of 160. Forty connection attempts fail. Forty HTTP requests return 500 errors. Your "zero-downtime deployment" has caused downtime.
The solutions, in order of my preference:
- Use a connection proxy. Backend connections do not change during deployment. The proxy absorbs the worker churn on the application side without increasing load on PostgreSQL.
- Graceful FPM shutdown with SIGQUIT. Old workers finish their current request and exit, releasing their PostgreSQL connection. New workers start and connect. The overlap is minimal — only the duration of in-flight requests, not the full drain timeout.
- Lower
pm.max_childrento leave deployment headroom. If your budget allows 38 workers per server, set it to 25. This leaves room for the overlap but sacrifices 34% of your steady-state HTTP concurrency permanently to accommodate a transient event. - Stagger deploys with sufficient delay. Wait for each server's old workers to fully exit before deploying the next. This extends deployment time but avoids overlap entirely.
Option 3 is the most common in practice, and it is the one I find most disappointing. You are perpetually underprovisioning your application to accommodate a 2-minute deployment window. Every hour of every day, your application handles fewer concurrent requests than it could, because you need slack for the 2 minutes per week when you deploy. This is not capacity planning. This is a tax on your release cadence.
The scenario table: from comfortable to catastrophic
| Scenario | Servers | Pools | max_children | Total workers | PG max_conn | Headroom | Verdict |
|---|---|---|---|---|---|---|---|
| 1 server, 1 pool | 1 | 1 | 50 | 50 | 60 | 7 slots | Fine for small apps |
| 3 servers, 1 pool each | 3 | 3 | 50 | 150 | 160 | 5 slots | Tight. Postgres will suffer. |
| 3 servers, 1 pool each (tuned) | 3 | 3 | 25 | 75 | 85 | 5 slots | Better. But fragile. |
| 3 servers, 2 pools each | 3 | 6 | 20 | 120 | 130 | 5 slots | Common. Painful to manage. |
| 5 servers, 1 pool each | 5 | 5 | 50 | 250 | 260 | 5 slots | Postgres is melting. |
| 5 servers + Gold Lapel | 5 | 5 | 50 | 250 | 30 | unlimited* | GL multiplexes. Postgres is calm. |
* With Gold Lapel, adding FPM workers does not increase PostgreSQL connection count. The proxy absorbs the growth.
The pattern is clear. As you add servers or pools, max_connections must grow proportionally — or pm.max_children must shrink. Neither option is pleasant. Growing max_connections hits the performance cliff. Shrinking pm.max_children limits your HTTP concurrency, which limits your throughput at the application layer.
This is a structural problem. No amount of tuning resolves the fundamental tension between PHP's process-per-request model and PostgreSQL's process-per-connection model. The only way to eliminate it is to break the 1:1 relationship between FPM workers and database connections.
"PgBouncer has been in production since 2007. In infrastructure years, this makes it approximately ancient — and in infrastructure, ancient is a compliment."
— from You Don't Need Redis, Chapter 17: Sorting Out the Connection Poolers
PgBouncer: the traditional answer (and its limitations)
Before discussing Gold Lapel, I should address PgBouncer, because it is the most widely deployed PostgreSQL connection pooler and the first tool most teams reach for. It would be a disservice to the guest to omit it.
; PgBouncer configuration (pgbouncer.ini)
; The most common standalone connection pooler for PostgreSQL.
;
[databases]
myapp = host=pg-primary.internal port=5432 dbname=myapp
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
; Pool mode determines when connections are returned:
pool_mode = transaction ; return to pool after each transaction
; (not 'session', which defeats the purpose)
; Pool sizing:
default_pool_size = 20 ; connections per user/database pair
min_pool_size = 5 ; pre-warm this many connections
reserve_pool_size = 5 ; overflow for short bursts
reserve_pool_timeout = 3 ; seconds before using reserve
max_client_conn = 200 ; total client connections PgBouncer accepts
; This can be much larger than default_pool_size.
; With this config:
; 200 FPM workers connect to PgBouncer
; PgBouncer maintains 20-25 connections to PostgreSQL
; Multiplexing ratio: 200:25 = 8:1
; Limitations:
; - No query analysis or optimization
; - SET commands leak across transactions in transaction mode
; - PREPARE/DEALLOCATE require session mode (negating the benefit)
; - No automatic failover or health checking
; - Single-threaded (one CPU core maximum) PgBouncer is excellent at what it does: accepting many client connections and multiplexing them onto a smaller pool of PostgreSQL connections. In transaction mode, a backend connection is assigned to a client for the duration of a transaction and returned to the pool when the transaction completes. This means 200 FPM workers can share 20 PostgreSQL connections, dramatically reducing database load.
I recommend PgBouncer without reservation for teams that need connection pooling and have straightforward query patterns. It is mature, stable, widely understood, and simple to operate. If PgBouncer solves your problem, use it.
However, I should be honest about its limitations, because they are the limitations that motivated Gold Lapel's existence:
- Session-level state does not persist across transactions. In transaction mode,
SETcommands, prepared statements, and advisory locks may not work as expected because the backend connection may change between transactions. This breaks some ORMs and application patterns. - Single-threaded. PgBouncer runs on a single CPU core. For most workloads this is fine — the bottleneck is PostgreSQL, not the pooler. But at very high throughput, PgBouncer itself becomes the constraint.
- No query analysis. PgBouncer does not examine the queries passing through it. It cannot route reads to replicas, identify slow queries, or provide query-level insights. It is a pipe, not a proxy.
- No automatic failover. If the backend PostgreSQL server goes down, PgBouncer does not redirect connections to a standby. You need additional tooling (HAProxy, Patroni) for that.
For the specific problem of PHP-FPM connection sizing, PgBouncer is sufficient and appropriate. I would not tell you otherwise. The additional capabilities of Gold Lapel — query analysis, automatic optimization, intelligent routing — are valuable, but they are not required to solve the connection sizing problem itself. A waiter who oversells his services is no waiter at all.
Framework-specific configuration: Laravel, Symfony, and raw PDO
The connection configuration varies by framework, and the mistakes I see are framework-specific as well. Allow me to address each.
// config/database.php — Laravel connection configuration
// Three scenarios for managing PostgreSQL connections.
// Scenario 1: Direct connection (no pooler, persistent)
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'pg-primary.internal'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME', 'app_user'),
'password' => env('DB_PASSWORD', ''),
'options' => [
PDO::ATTR_PERSISTENT => true, // one connection per FPM worker
],
],
// Scenario 2: Through PgBouncer
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'pgbouncer.internal'),
'port' => env('DB_PORT', '6432'), // PgBouncer port
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME', 'app_user'),
'password' => env('DB_PASSWORD', ''),
'options' => [
PDO::ATTR_PERSISTENT => false, // PgBouncer handles pooling
],
// Note: with PgBouncer in transaction mode,
// do NOT use persistent connections.
// The connection belongs to the pool, not the worker.
],
// Scenario 3: Through Gold Lapel
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'localhost'), // GL runs as sidecar
'port' => env('DB_PORT', '6432'), // GL's listen port
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME', 'app_user'),
'password' => env('DB_PASSWORD', ''),
'options' => [
PDO::ATTR_PERSISTENT => true, // persistent TO GL is fine
],
// GL handles multiplexing on the backend.
// Persistent connections to GL are lightweight (no PG process).
// pm.max_children is now a PHP decision, not a Postgres decision.
],
// Read/write splitting (works with any of the above):
'pgsql' => [
'read' => [
'host' => [env('DB_READ_HOST', 'pg-replica.internal')],
],
'write' => [
'host' => [env('DB_WRITE_HOST', 'pg-primary.internal')],
],
'driver' => 'pgsql',
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME', 'app_user'),
'password' => env('DB_PASSWORD', ''),
'sticky' => true, // reads after writes go to primary (same request)
], Laravel users should pay particular attention to the sticky option in read/write splitting. When sticky is true, any request that performs a write will read from the primary for the remainder of that request. This prevents the "read your own writes" problem within a single HTTP request. It does not help across requests — if user A writes data and user B immediately reads, user B may hit the replica and not see the write. For most web applications, this is acceptable. For real-time collaborative features, it is not.
Symfony with Doctrine requires more explicit configuration. The doctrine.dbal configuration supports master-slave setups through the replicas key. Doctrine's keepSlave option (equivalent to Laravel's sticky) controls whether the connection stays on the primary after a write. One critical detail: Doctrine's entity manager caches entities in the identity map. If you read an entity from the replica, modify it, and flush, Doctrine sends the UPDATE to the primary — against an entity that may have slightly different data due to replication lag. For write-heavy entities, always read from the primary.
Raw PDO users have the most control and the most rope to hang themselves with. The key mistakes I see: forgetting to set PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION (the default is silent failure, which is a crime against debugging), not implementing connection retry logic for transient failures, and using persistent connections without understanding that they persist the connection state — including any uncommitted transactions from a previous request that threw an exception mid-transaction.
Breaking the coupling with a connection-multiplexing proxy
The entire capacity planning exercise above exists because each FPM worker connects directly to PostgreSQL. Remove that direct connection and the arithmetic changes entirely.
Gold Lapel sits between your PHP application and PostgreSQL as a transparent proxy. FPM workers connect to Gold Lapel. Gold Lapel maintains a small, properly-sized pool of connections to PostgreSQL. 150 FPM workers share 20 database connections — because at most 20 are ever executing queries simultaneously.
; Before Gold Lapel: tight coupling
; 3 app servers x 38 max_children = 114 potential connections
; max_connections must be >= 114 + reserved + monitoring
; Add a 4th server? Recalculate everything. Maybe raise max_connections.
; Black Friday traffic? Hope your math was right.
; After Gold Lapel:
; 3 app servers x 50 max_children = 150 FPM workers
; Each connects to Gold Lapel (local or sidecar)
; Gold Lapel multiplexes 150 app connections -> 20 Postgres connections
; max_connections = 30 (20 for GL + 3 reserved + 2 monitoring + headroom)
;
; Add a 4th server? GL absorbs it. No Postgres config change.
; Add a 10th server? Still 20 backend connections.
; Laravel config/database.php — just point at GL:
; 'host' => env('DB_HOST', 'localhost'),
; 'port' => env('DB_PORT', '6432'), // Gold Lapel's port
;
; That's it. No FPM math. No max_connections arithmetic.
; pm.max_children goes back to being a PHP memory decision,
; not a Postgres capacity decision. The practical effects:
pm.max_childrenbecomes a PHP decision, not a Postgres decision. Set it based on available RAM per worker (typically 30-60MB each) and desired HTTP concurrency. The database connection budget is irrelevant. Your 8 GB server can run 128 workers instead of 38.- Scaling is additive, not multiplicative. Adding a 4th or 10th application server does not change the number of PostgreSQL connections. Gold Lapel absorbs the additional workers without increasing backend load. Your capacity planning conversation for new servers is "do we have the memory and CPU?" not "can PostgreSQL handle the connections?"
max_connectionsstays small. With 20 backend connections instead of 150, PostgreSQL gets more memory per connection, higherwork_mem, less lock contention, and better throughput. You move left on the performance cliff table. Your queries literally execute faster with the same hardware.- Deployments stop being connection storms. During a rolling deployment, old and new servers run simultaneously. Without a proxy, this doubles your connection count for the duration of the deployment. With Gold Lapel, the total backend connection count does not change. Zero-downtime deployments become genuinely zero-downtime.
- The work_mem ceiling rises. With 20 connections instead of 150, you can set
work_mem = 64MBinstead of16MB. Complex queries — reports, analytics, large joins — run 4x faster because they can sort in memory instead of spilling to disk. This is not a theoretical benefit. It is measurable on your nextEXPLAIN ANALYZE.
The formula from earlier — (pools x pm.max_children) <= connection_budget — stops being a constraint you manage. It becomes an internal detail of the proxy. Your capacity planning conversation shifts from "how do we split 117 connection slots across 6 FPM pools" to "is 20 backend connections enough for our query concurrency." The second question has a simpler, more stable answer.
The capacity planning checklist
Whether or not you use a proxy, here is the audit to perform before your next deployment. I present it as a checklist because checklists are how competent operations teams prevent competent engineers from making oversights at 2 AM.
- Count your pools. List every FPM pool on every server, including queue workers, cron pools, and admin panels. Multiply
pm.max_childrenby pool count. Do not forget the staging server that someone pointed at production two months ago and never mentioned. - Count your non-application consumers. Monitoring agents, replication slots, migration tools, interactive psql sessions, background job runners. These eat into the budget silently. Audit
pg_stat_activityand account for every connection. - Calculate the budget.
max_connections - superuser_reserved - monitoring - replication = available. Divide by total pools. That is your per-poolmax_childrenceiling. - Check actual usage. Run the
pg_stat_activityquery. Compare actual connections to your budget. If idle connections dominate, your workers are holding connections they do not need. - Check the performance cliff. If your total connections exceed 4x your CPU core count, you are past the throughput plateau. More connections will make queries slower, not faster.
- Account for deployment overlap. During a rolling deploy, your connection count can briefly double. Your budget must accommodate this peak. If it cannot, you need either a proxy, graceful FPM shutdown, or permanently reduced
pm.max_children. - Check the memory interaction. Calculate your worst-case
work_memconsumption:max_connections x 2 x work_mem. If this exceeds 25% of available RAM (aftershared_buffers), either reducework_memor reduce connections. - Plan for the worst case. Deployments, autoscaling events, and traffic spikes all increase connection demand temporarily. Your budget must accommodate the peak, not the average.
- Consider decoupling. If the math does not work — if you need more FPM workers than PostgreSQL can handle — a connection pooler or a multiplexing proxy is not optional. It is load-bearing infrastructure.
The numbers in your pool.d/www.conf and your postgresql.conf are in a relationship, whether you acknowledge it or not. The formula is the acknowledgment. A proxy is the intervention that lets both sides operate at their natural capacity. And Gold Lapel, if you will permit the observation, is the household staff that ensures neither party must compromise on account of the other.
Frequently asked questions
Terms referenced in this article
Permit me one observation before we part. The connection pooling guide examines the pooler layer in greater detail — PgBouncer modes, session versus transaction pooling, and the prepared statement complications that arise with each. If the formula brought you here, the pooler guide is where the implementation lives.