Database Connection Pooling in PHP Applications
AI generated
<?php
8.4
PHP · Connection Pooling · Performance · Databases
Connection Pooling in PHP Applications
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.

17 min read Connection Pooling · ProxySQL · PgBouncer · Swoole PHP 8.4 · PDO · PHP-FPM

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.

11. FAQ: Connection Pooling in PHP Applications

1No true pooling in classic PHP?
Shared-nothing model in PHP-FPM: every worker loses its state after the request.
2What does ATTR_PERSISTENT do?
Keeps the connection open in the same worker and avoids reconnecting each time.
3Biggest downside of persistent connections?
Pool is isolated per worker, state can unintentionally survive between requests.
4What is ProxySQL?
Standalone proxy in front of MySQL, manages a project-wide shared connection pool.
5What is transaction pooling mode?
Connection reserved only per transaction, released immediately afterward for other clients.
6Why does Swoole enable real pooling?
Process lives across many requests, a pool object can truly share connections.
7How to recognize an undersized pool?
Growing wait queue and rising wait times are early warning signs.
8What causes MySQL server has gone away?
Mismatch between wait_timeout and actual usage duration of the connection.
9Health checks before reuse needed?
Yes, a SELECT 1 before actual work prevents errors from dead connections.
10Which metric shows overload best?
Waiting requests combined with average wait time for a free connection.