Reusing database connections efficiently
Classic PHP in the shared-nothing model has no true in-memory connection pool within a single process. Anyone who still wants to avoid the overhead of constant reconnects needs either persistent connections with clear boundaries, an external connection pooler like ProxySQL, or a long-running process model like Swoole.
Table of Contents
- 1. Why connection pooling in PHP is a special case
- 2. Measuring the overhead of a database connection
- 3. Persistent connections with PDO: benefits and risks
- 4. External connection poolers: ProxySQL and PgBouncer
- 5. A real connection pool in long-running processes
- 6. Configuring connection limits and timeouts correctly
- 7. Health checks and reconnect strategies
- 8. Monitoring connection pools
- 9. Pooling strategies compared
- 10. Summary
- 11. FAQ
1. Why connection pooling in PHP is a special case
Connection pooling means reusing a limited number of already open database connections instead of opening and closing a new connection for every request. In languages with long-running processes like Java or Node.js, this is trivial: the process lives across many requests, and a pool in process memory manages connections centrally. Classic PHP under PHP-FPM, however, follows the shared-nothing model: every request starts in a fresh or reused worker process that loses its state at the end of the request.
That means a connection pool in the classic sense, managed in the memory of a single application process across multiple requests, does not naturally exist in standard PHP. Anyone wanting to implement connection pooling in PHP has three fundamentally different paths: persistent connections at the PHP-FPM worker level, an external pooler between the application and the database, or a long-running process model like Swoole that keeps PHP processes alive across requests, enabling a real in-process pool.
2. Measuring the overhead of a database connection
Before investing in connection pooling, it is worth asking how expensive a new connection actually is. Establishing a TCP connection, followed by the database handshake, authentication, and possibly TLS encryption, typically costs one to several milliseconds for MySQL, depending on network latency and server load. In an application handling a thousand requests per second, each opening a new connection, this quickly adds up to noticeable CPU and network load, both on the application and the database side.
A second, often underestimated factor: every open connection consumes memory on the database server for the thread stack, buffers, and session variables, frequently several megabytes per connection for MySQL. Under high concurrency without connection pooling, the number of simultaneous connections quickly approaches the configured max_connections limit, causing rejected connections long before the server's actual CPU or I/O capacity is exhausted.
3. Persistent connections with PDO: benefits and risks
PDO offers the simplest form of connection pooling in classic PHP through PDO::ATTR_PERSISTENT. When this attribute is set, PHP tries to keep the connection open at the end of the request and reuse it on the next request within the same PHP-FPM worker process, instead of establishing a new TCP connection. That noticeably reduces connection setup overhead, especially for databases with an expensive handshake such as PostgreSQL over SSL.
The decisive downside: persistent connections are held per PHP-FPM worker process, not shared project wide. With a hundred worker processes, up to a hundred open connections to the database can exist, even if only a few are actively used at any moment. In addition, connection state survives between requests, which for uncommitted transactions or unreset session variables can lead to hard-to-trace errors in the next request that happens to hit the same worker process.
<?php
declare(strict_types=1);
final class PersistentConnectionFactory
{
public function __construct(
private readonly string $dsn,
private readonly string $username,
private readonly string $password,
) {
}
public function create(): PDO
{
return new PDO($this->dsn, $this->username, $this->password, [
// Reuse the connection across requests within the same FPM worker
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
}
// Defensive reset at the start of every request that reuses a persistent connection
final class ConnectionResetter
{
public function resetSessionState(PDO $pdo): void
{
if ($pdo->inTransaction()) {
// A leftover open transaction from a crashed previous request
// must never leak into the next request silently
$pdo->rollBack();
}
$pdo->exec('SET SESSION sql_mode = "STRICT_TRANS_TABLES,NO_ZERO_DATE"');
}
}
4. External connection poolers: ProxySQL and PgBouncer
The more robust path for connection pooling in PHP environments is a dedicated pooling proxy between the application and the database. ProxySQL for MySQL and PgBouncer for PostgreSQL run as their own process, usually on the same host as the PHP application or as a central network service. The PHP application connects as usual via PDO, but against the proxy instead of the database server directly, while the proxy keeps a real, project-wide pool of connections open to the actual database in the background.
The advantage over ATTR_PERSISTENT: the pool is shared centrally across all PHP-FPM worker processes and even across multiple application servers, instead of existing in isolation per worker. PgBouncer additionally supports several pooling modes, where transaction mode reserves the connection only for the duration of a single transaction and releases it immediately afterward for other clients, drastically reducing the effective number of database connections needed.
# pgbouncer.ini — transaction pooling mode releases the connection
# back to the pool immediately after each transaction commits
[databases]
shop_production = host=127.0.0.1 port=5432 dbname=shop
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 40
reserve_pool_size = 10
server_idle_timeout = 300
5. A real connection pool in long-running processes
In long-running PHP environments like Swoole or RoadRunner, the PHP process keeps running across thousands of requests without being terminated in between. That allows a real, in-process implemented connection pool, as is common in Java or Node.js: a pool object opens a fixed number of connections at process start, and every coroutine or request borrows a connection from the pool and returns it afterward instead of opening a new one.
This variant offers the best performance since no additional network hop through a proxy is needed, but it requires the application to run as a long-running process, which demands adjustments to memory management, global state, and error handling. In particular, no global variable may unintentionally survive between requests, a mistake that classic PHP-FPM automatically prevents through its per-request process restart.
<?php
declare(strict_types=1);
use Swoole\Coroutine\Channel;
final class SwooleConnectionPool
{
private Channel $channel;
public function __construct(
private readonly Closure $connectionFactory,
int $poolSize = 20,
) {
$this->channel = new Channel($poolSize);
for ($i = 0; $i < $poolSize; $i++) {
$this->channel->push(($this->connectionFactory)());
}
}
// Borrow a connection, run the callback, always return it to the pool
public function withConnection(Closure $callback): mixed
{
/** @var PDO $connection */
$connection = $this->channel->pop();
try {
return $callback($connection);
} finally {
$this->channel->push($connection);
}
}
}
$pool = new SwooleConnectionPool(
fn (): PDO => new PDO('mysql:host=127.0.0.1;dbname=shop', 'app', 'secret'),
);
$result = $pool->withConnection(
fn (PDO $pdo) => $pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn()
);
6. Configuring connection limits and timeouts correctly
Regardless of the chosen connection pooling strategy, the maximum number of connections must be configured deliberately. MySQL's max_connections, often defaulting to 151, must be matched to the actual number of PHP-FPM workers plus a safety margin. If this value is set too low, connection attempts fail with Too many connections; if set too high, the server can slide into swapping under memory pressure at true full load.
Equally important are timeouts: wait_timeout and interactive_timeout in MySQL determine how long an idle connection is kept before the server closes it server side. Without values coordinated between application and database, situations arise where the application tries to reuse a connection the server already closed, resulting in MySQL server has gone away errors, one of the most common symptoms of misconfigured connection pooling.
7. Health checks and reconnect strategies
Every form of connection pooling, whether persistent PDO connections, an external proxy, or an in-process pool, needs a strategy for the case where a connection in the pool has meanwhile become invalid, for example due to a network timeout or a database failover. A simple health check before reuse verifies with a minimal query like SELECT 1 that the connection still works before it is used for actual work.
For an invalid connection, the pool implementation should automatically establish a new connection and discard the broken one instead of passing the error unhandled to the caller. External poolers like ProxySQL already come with such health checks built in and automatically flag faulty backend connections, whereas homemade in-process pools need to implement this logic themselves.
8. Monitoring connection pools
Without monitoring, connection pooling remains a black box. The most important metrics are the number of active connections, the number of requests waiting for a free connection, and the average wait time until a connection becomes available. A growing wait queue is an early warning sign that the pool is undersized for the current load, long before requests actually fail with a timeout.
MySQL offers basic figures on the database side with SHOW STATUS LIKE 'Threads_connected' and SHOW STATUS LIKE 'Max_used_connections'. ProxySQL provides considerably more detailed statistics per connection pool and backend server through its admin interface, which can be wired into Prometheus or Grafana to make trends over time visible instead of only looking at snapshots.
9. Pooling strategies compared
The three approaches presented differ considerably in implementation effort, effectiveness, and operational requirements. The following table summarizes the key differences.
| Criterion | PDO Persistent | ProxySQL / PgBouncer | Swoole In-Process Pool |
|---|---|---|---|
| Implementation effort | Minimal | Medium, separate service | High, architecture change |
| Pool truly shared | No, per worker | Yes, project wide | Yes, per process |
| Extra network hop | No | Yes, via proxy | No |
| Compatible with PHP-FPM | Yes | Yes | No |
| Risk of state leaks | Medium to high | Low | High without discipline |
In practice, an external pooler like ProxySQL is the best compromise between effectiveness and manageable migration effort for most classic PHP-FPM projects. Long-running process models bring the best performance but require a deliberate architectural decision that should not be retrofitted casually into an existing project.
Mironsoft
PHP performance and database infrastructure
Too many database connections under load?
We analyze connection patterns in PHP-FPM environments, configure ProxySQL or PgBouncer, and design pooling strategies that stay stable even under load spikes.
Analysis
Measuring connection behavior and max_connections limits under real load
Pooler Setup
Configuring ProxySQL or PgBouncer and integrating them into existing infrastructure
Monitoring
Making pool metrics visible in Prometheus and Grafana
10. Summary
Connection pooling in PHP is not a given the way it is in Java or Node.js, but requires a deliberate choice between three strategies: persistent PDO connections as the simplest but per-worker isolated approach, external poolers like ProxySQL or PgBouncer as a robust middleware solution, or an in-process pool in long-running environments like Swoole for maximum performance at the cost of architectural effort.
Regardless of the chosen strategy, correctly configured connection limits, timeouts, health checks, and monitoring are not optional details but the prerequisite for connection pooling to actually help under real load instead of creating new, harder-to-diagnose problems.
Connection Pooling in PHP Applications — The Essentials at a Glance
Root problem
PHP-FPM follows the shared-nothing model, an in-process pool across requests does not naturally exist.
Simplest approach
PDO::ATTR_PERSISTENT reduces overhead per worker but does not share the pool project wide.
Most robust solution
ProxySQL or PgBouncer as a central pooler between application and database, shared project wide.
Operations
max_connections, timeouts, health checks, and monitoring are mandatory for every pooling strategy.