tereta / pdo
PDO decorator with lazy connection and automatic reconnect on disconnect-class errors (MySQL gone-away, PostgreSQL connection failure). For long-running workers and daemons.
Requires
- php: >=8.2
- ext-pdo: *
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- squizlabs/php_codesniffer: ^3.13
Suggests
- ext-pdo_mysql: To use the wrapper with MySQL/MariaDB (DSN mysql:) and its disconnect-code detection
- ext-pdo_pgsql: To use the wrapper with PostgreSQL (DSN pgsql:) and its SQLSTATE disconnect detection
Provides
None
Conflicts
None
Replaces
None
README
π English | Π ΡΡΡΠΊΠΈΠΉ | Π£ΠΊΡΠ°ΡΠ½ΡΡΠΊΠ°
Introduction
This is a virtual proxy over PDO with lazy connection initialization and automatic reconnection on "connection lost" errors (MySQL gone away and connect-class errors - codes 1053/2002/2003/2005/2006/2013/4031; PostgreSQL connection/operator-intervention SQLSTATE - 08xxx, 25P03, 57P0x).
It is designed for long-running workers and daemons whose TCP connection to the database may time out between queries. Transactions are not silently restarted on disconnect - the exception is propagated upwards so the calling code can decide what to do.
- A bare PDO proxy with no dependency on Doctrine/Laravel/any framework - for plain PDO.
- Covers both MySQL (1053/2002/2003/2005/2006/2013/4031) and PostgreSQL SQLSTATE codes (
08000/08001/08003/08004/08006/08P01,25P03,57P01/57P02/57P03/57P05). - Explicitly propagates the exception if the disconnect happens inside a transaction - the wrapper never silently retries a transactional statement.
- Requires
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION(the wrapper relies onPDOExceptionto detect disconnects;ERRMODE_SILENTwill not trigger reconnect).
Requirements
- PHP 8.2+
- ext-pdo
Installation
Install via Composer:
composer require tereta/pdo
Git repository
https://gitlab.com/tereta/library/pdo.git π
Usage
Example of configuring a database connection:
use Tereta\Pdo\Connection as PdoConnection;
new PdoConnection(
'mysql:host=127.0.0.1;port=3306;dbname=developer;charset=utf8mb4',
'developer',
'developer'
);
The Tereta\Pdo\Connection object mirrors the PDO API (same method signatures). It does extend \PDO so that $conn instanceof \PDO is true and existing \PDO type-hints keep working, but it does not call parent::__construct() - the native PDO state inside the parent class is intentionally uninitialized; every operation is delegated to an internal lazy-built \PDO instance. Code that relies on native \PDO internals (e.g. via reflection) should type-hint against Tereta\Pdo\Interfaces\Pdo instead. For semantics of the individual methods see the PDO manual: https://www.php.net/manual/en/book.pdo.php
Example of a long-running worker that reads jobs and reconnects to the database transparently for the application code:
<?php
declare(strict_types=1);
use Tereta\Pdo\Connection;
require __DIR__ . '/vendor/autoload.php';
$pdo = new Connection(
dsn: 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
username: 'app',
password: 'secret',
options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
while (true) {
$statement = $pdo->prepare('SELECT id, payload FROM jobs WHERE status = :status LIMIT 10');
$statement->execute([':status' => 'pending']);
foreach ($statement->fetchAll() as $row) {
$pdo->beginTransaction();
try {
$update = $pdo->prepare('UPDATE jobs SET status = :status WHERE id = :id');
$update->execute([':status' => 'done', ':id' => $row['id']]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
sleep(5);
}
Reconnect Configuration
The number of reconnect attempts and the pauses between them can be tuned via fluent setters. Pauses are in seconds:
$pdo
->setReconnectAttempts(120) // How many times to reconnect (default: 120), 0 - do not reconnect
->setReconnectTimeInitial(1) // Pause before the first attempt (default: 1 second)
->setReconnectInterval(15) // Pause between the next attempts (default: 15 seconds)
;
With the defaults the wrapper tries to reconnect for about 30 minutes. When all attempts fail, it raises ConnectionLost (event EVENT_RECONNECT_EXHAUSTED).
setReconnectAttempts(3)means exactly 3 reconnects. Every attempt counts, including a failed connect and a failed prepare after reconnect.setReconnectInterval(0)andsetReconnectTimeInitial(0)only remove the pause. The attempt limit still works, so the wrapper never loops forever. It is handy in tests.- These defaults suit workers and daemons. In a web request 30 minutes of waiting is too long, so set fewer attempts there.
Negative values passed to the setters raise Tereta\Pdo\Exceptions\Argument, it extends InvalidArgumentException.
Every exception of the package implements Tereta\Pdo\Interfaces\Exception, so one catch handles all of them.
Known Limitations
A write is not repeated if the connection drops while it may have been running. The server can drop a session after it has applied a write but before the client gets the answer. The error code is then the same as for a session dropped while idle, so the wrapper cannot tell "not executed" from "executed, answer lost". After a lost connection outside a transaction:
- a
SELECTis repeated on a new connection; INSERT/UPDATE/DELETEand any other statement are not repeated. The wrapper closes the connection, firesEVENT_RECONNECT_STATEMENTand throwsConnectionLost. The next call opens a new connection, and a preparedStatementprepares itself again, so you decide whether to run it again;- any statement is repeated when the error shows it never reached the server: a failed connect, MySQL
4031(closed for inactivity,wait_timeout), PostgreSQL idle-session timeout or "no connection to the server".
A statement counts as SELECT when it starts with SELECT (comments and brackets are skipped) and has no INTO. WITH ... does not count. A SELECT that calls a function with side effects is still repeated.
use Tereta\Pdo\Exceptions\ConnectionLost;
$insert = $pdo->prepare('INSERT INTO payments (order_id, amount) VALUES (?, ?)');
try {
$insert->execute([$orderId, $amount]);
} catch (ConnectionLost $e) {
// The row may or may not be stored: check it before running the insert again.
}
For writes that must survive a disconnect, make them idempotent (INSERT ... ON DUPLICATE KEY UPDATE, a WHERE guard, a dedup key) and repeat them yourself.
Reconnect only triggers on prepare and execute. If the connection drops between execute() and fetch()/fetchAll()/iteration, the server-side cursor is gone and the wrapper cannot replay the fetch safely (a fresh execute may return different rows due to concurrent transactions). Instead of a transparent retry, the wrapper raises a typed Tereta\Pdo\Exceptions\ConnectionLost (which extends PDOException):
try {
foreach ($statement as $row) {
process($row);
}
} catch (Tereta\Pdo\Exceptions\ConnectionLost $e) {
// Connection died mid-fetch. Decide per business logic:
// re-run the whole query, skip the batch, bubble up.
} catch (PDOException $e) {
// Application-level SQL error - not a disconnect.
}
The same exception is raised when the connection drops inside a transaction - the server has already rolled the transaction back, transparent retry would be unsafe, so the wrapper hands you a typed signal instead.
Reconnect is wired only into write/query entry points. The reconnect loop is invoked from exec(), prepare(), query() and Statement::execute(). The metadata/utility methods quote(), lastInsertId(), errorCode() and errorInfo() go through the underlying PDO directly - if the connection is dead they will raise a raw PDOException (not ConnectionLost). This is intentional: lastInsertId() and the error accessors are bound to the dead session and have no meaningful value on a fresh connection, so a transparent retry would be misleading.
setAttribute() / getAttribute() are managed by the wrapper. setAttribute() applies the value to the live connection, if there is one, and also records it. The recorded attributes (together with the constructor $options) are applied again every time a PDO is built, so they survive reconnects. getAttribute() returns a recorded value and PDO::ATTR_DRIVER_NAME without connecting. For any other attribute it reads the live PDO and opens the connection if needed, so you get the real driver default instead of null.
The list of disconnect codes is hard-coded. If your driver returns a code that is not in the list (MySQL: 1053/2002/2003/2005/2006/2013/4031; PostgreSQL SQLSTATE: 08000/08001/08003/08004/08006/08P01/25P03/57P01/57P02/57P03/57P05), no reconnect will happen. Extending the list currently requires a fork or a custom strategy via Tereta\Pdo\Factories\Strategy\Disconnection::setDriver(). A strategy implements matches() (the error is a disconnect) and beforeExecution() (the statement surely did not reach the server).
Other drivers (sqlsrv, odbc, oci and so on) work too, but without reconnect: every error is passed on as is.
getPdo() and reconnect() are internal. getPdo() is protected (accessible to subclasses, e.g. for the test seam), reconnect() is private. The retry loop is reachable only through the standard methods exec(), prepare(), query() and Statement::execute(). There is intentionally no public escape hatch to the native PDO from outside - every operation that goes through the wrapper must go through the reconnect logic.
Observer
For integration with logging, metrics, tracing or alerts the library exposes a single observation point - setObserver(). It accepts one Closure invoked on every meaningful event in the reconnect cycle:
$pdo->setObserver(function (int $eventCode, mixed $dataObject): void {
// route to whatever you need
});
Events (constants of Tereta\Pdo\Connection):
| Constant | When | $dataObject |
|---|---|---|
EVENT_RECONNECT | After a successful reconnect, before the next attempt of the query. | PDOException - the original error that triggered the reconnect. |
EVENT_RECONNECT_EXHAUSTED | Retry budget exhausted, right before throw ConnectionLostException. | PDOException - the last error. |
EVENT_RECONNECT_TRANSACTION | Disconnect happened inside a transaction, right before throw ConnectionLostException. | PDOException - the disconnect error. |
EVENT_RECONNECT_STATEMENT | Disconnect while a non-SELECT statement may have been running, right before throw ConnectionLostException. | PDOException - the disconnect error. |
The default observer is a no-op. Setting one is optional.
Example: bridge to a PSR-3 logger:
$pdo->setObserver(function (int $eventCode, mixed $e) use ($logger): void {
[$level, $msg] = match ($eventCode) {
Connection::EVENT_RECONNECT => ['warning', 'PDO reconnect performed'],
Connection::EVENT_RECONNECT_EXHAUSTED => ['error', 'PDO reconnect attempts exhausted'],
Connection::EVENT_RECONNECT_TRANSACTION => ['error', 'PDO disconnect inside transaction'],
};
$logger->log($level, $msg, [
'sqlstate' => $e->errorInfo[0] ?? null,
'errorCode' => $e->errorInfo[1] ?? null,
'message' => $e->getMessage(),
]);
});
Example: metrics counter (Prometheus):
$pdo->setObserver(fn(int $code) => match ($code) {
Connection::EVENT_RECONNECT => $metrics->inc('pdo_reconnect_total'),
Connection::EVENT_RECONNECT_EXHAUSTED => $metrics->inc('pdo_reconnect_exhausted_total'),
Connection::EVENT_RECONNECT_TRANSACTION => $metrics->inc('pdo_reconnect_in_tx_total'),
});
ΠΠΈΡΠ΅Π½Π·ΠΈΡ ΠΈ Π°Π²ΡΠΎΡ
Tereta Alexander tereta.alexander@gmail.com Web: https://tereta.dev Copyright Β©2008-2026 Tereta Alexander License https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 www.ββββββββββββββββββββββββ βββββββββββββββββ ββββββ ββββββββββββββββββββββββββββββββββββββββββββββββββ βββ ββββββ ββββββββββββββ βββ ββββββββ βββ ββββββ ββββββββββββββ βββ ββββββββ βββ βββββββββββ βββββββββββ βββ βββ βββ βββ βββββββββββ βββββββββββ βββ βββ βββ .dev