monkeyscloud / monkeyslegion-database
High-performance PDO connection management with read/write splitting, connection pooling, and multi-driver support (MySQL, PostgreSQL, SQLite).
Package info
github.com/MonkeysCloud/MonkeysLegion-Database
pkg:composer/monkeyscloud/monkeyslegion-database
Requires
- php: ^8.4
- ext-pdo: *
- psr/log: ^3.0
- psr/simple-cache: ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.3
Suggests
- ext-pdo_mysql: Required for MySQL/MariaDB connections
- ext-pdo_pgsql: Required for PostgreSQL connections
- ext-pdo_sqlite: Required for SQLite connections
- monkeyscloud/monkeyslegion-cache: For advanced caching features (CacheManagerBridge)
This package is auto-updated.
Last update: 2026-04-12 04:28:46 UTC
README
High-performance PDO connection management for PHP 8.4+ with read/write splitting, connection pooling, lazy connections, and multi-driver support.
Features
- ✅ Multi-Driver: MySQL, PostgreSQL, SQLite — unified API via
match()dispatch - ✅ Read/Write Splitting: Route reads to replicas, writes to primary — automatically
- ✅ Lazy Connections: PDO is not allocated until the first query (zero cost in CLI/workers)
- ✅ Connection Pooling: In-memory pool with health monitoring, idle eviction, max-lifetime enforcement
- ✅ Sticky-After-Write: Reads route to primary after a write to avoid replication lag
- ✅ 18 Typed Exceptions: Every
PDOExceptionis classified into a specific actionable type - ✅ Schema Introspection: Table/column/FK/unique-index discovery with aggressive static caching
- ✅ PHP 8.4 Native: Property hooks, asymmetric visibility, readonly classes, backed enums
- ✅ Zero Config Defaults: Sensible PDO defaults (exceptions, assoc fetch, real prepares)
- ✅ Type-Safe Config: Immutable value objects replace raw arrays
Requirements
- PHP 8.4+
ext-pdo(plusext-pdo_mysql,ext-pdo_pgsql, orext-pdo_sqliteper driver)
Installation
composer require monkeyscloud/monkeyslegion-database:^2.0
Quick Start
ConnectionManager (Recommended)
use MonkeysLegion\Database\Connection\ConnectionManager; // From a config array — the simplest setup $manager = ConnectionManager::fromArray([ 'default' => [ 'driver' => 'mysql', 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'myapp', 'username' => 'root', 'password' => 'secret', ], ]); // Get the default connection (lazy — no PDO until first use) $conn = $manager->connection(); // Execute queries $conn->execute('INSERT INTO users (name) VALUES (:name)', [':name' => 'Alice']); $stmt = $conn->query('SELECT * FROM users WHERE id = :id', [':id' => 1]); $user = $stmt->fetch();
Direct Connection
use MonkeysLegion\Database\Config\{DatabaseConfig, DsnConfig}; use MonkeysLegion\Database\Connection\Connection; use MonkeysLegion\Database\Types\DatabaseDriver; $dsn = new DsnConfig( driver: DatabaseDriver::MySQL, host: 'localhost', port: 3306, database: 'myapp', ); $config = new DatabaseConfig( name: 'primary', driver: DatabaseDriver::MySQL, dsn: $dsn, username: 'root', password: 'secret', ); $conn = new Connection($config); $conn->connect(); // PDO is available $pdo = $conn->pdo();
SQLite In-Memory (Tests)
$manager = ConnectionManager::fromArray([ 'test' => [ 'driver' => 'sqlite', 'memory' => true, ], ]); $conn = $manager->connection(); $conn->execute('CREATE TABLE tests (id INTEGER PRIMARY KEY, name TEXT)');
Read/Write Splitting
Route read queries to replicas and writes to the primary connection:
$manager = ConnectionManager::fromArray([ 'primary' => [ 'driver' => 'mysql', 'host' => 'primary-db.internal', 'database' => 'myapp', 'username' => 'root', 'password' => 'secret', 'read' => [ 'strategy' => 'round_robin', // round_robin | random | least_connections 'sticky' => true, // reads go to primary after a write 'replicas' => [ ['host' => 'replica-1.internal', 'database' => 'myapp'], ['host' => 'replica-2.internal', 'database' => 'myapp'], ], ], ], ]); // Reads go to a replica $users = $manager->read()->query('SELECT * FROM users'); // Writes go to primary $manager->write()->execute('INSERT INTO users (name) VALUES (:n)', [':n' => 'Bob']); // After a write with sticky enabled, reads also go to primary $manager->markWritePerformed(); $freshUser = $manager->read()->query('SELECT * FROM users WHERE id = :id', [':id' => 1]); // Reset sticky at request boundary $manager->resetSticky();
Transactions
$conn = $manager->connection(); // Callback-based (recommended) — auto commit/rollback $result = $conn->transaction(function ($c) { $c->execute('INSERT INTO orders (user_id, total) VALUES (:u, :t)', [':u' => 1, ':t' => 99.99]); $c->execute('UPDATE inventory SET stock = stock - 1 WHERE product_id = :p', [':p' => 42]); return 'order_placed'; }); // Manual control use MonkeysLegion\Database\Types\IsolationLevel; $conn->beginTransaction(IsolationLevel::RepeatableRead); try { $conn->execute('...'); $conn->commit(); } catch (\Throwable $e) { $conn->rollBack(); throw $e; }
Lazy Connections
Connections are lazy by default when obtained through ConnectionManager. The PDO is not created until you call pdo(), query(), execute(), or start a transaction:
$conn = $manager->connection(); // No database connection yet echo $conn->getName(); // "default" — no connection needed echo $conn->getDriver()->label(); // "MySQL / MariaDB" — still no connection $conn->query('SELECT 1'); // NOW the connection is established
This is ideal for CLI commands and queue workers where many code paths don't touch the database.
Connection Pooling
use MonkeysLegion\Database\Config\PoolConfig; $manager = ConnectionManager::fromArray([ 'default' => [ 'driver' => 'mysql', 'host' => 'localhost', 'database' => 'myapp', 'username' => 'root', 'password' => 'secret', 'pool' => [ 'min_connections' => 2, 'max_connections' => 20, 'idle_timeout' => 300, // seconds 'max_lifetime' => 3600, // seconds 'health_check_interval' => 30, 'validate_on_acquire' => true, ], ], ]);
Pool Stats
$stats = $manager->stats(); foreach ($stats as $name => $poolStats) { echo "{$name}: {$poolStats->active} active, {$poolStats->idle} idle\n"; echo " Utilization: " . round($poolStats->utilization() * 100) . "%\n"; echo " Exhausted: " . ($poolStats->isExhausted() ? 'YES' : 'no') . "\n"; }
Pool Warm-Up
Pre-warm the pool to minConnections at application boot to avoid cold-start latency:
use MonkeysLegion\Database\Connection\ConnectionPool; use MonkeysLegion\Database\Config\{DatabaseConfig, DsnConfig, PoolConfig}; $pool = new ConnectionPool($config, new PoolConfig(minConnections: 3, maxConnections: 20)); $pool->warmUp(); // opens 3 connections eagerly
Exception Handling
Every PDOException is automatically classified into a specific, actionable exception type:
use MonkeysLegion\Database\Exceptions\{ DuplicateKeyException, ForeignKeyViolationException, DeadlockException, ConnectionLostException, SyntaxException, }; try { $conn->execute('INSERT INTO users (email) VALUES (:e)', [':e' => 'dup@test.com']); } catch (DuplicateKeyException $e) { // Unique constraint violated echo "Duplicate on: {$e->constraintName}, column: {$e->duplicateColumn}"; echo "SQL: {$e->debugSql}"; // Interpolated SQL for debugging } catch (ForeignKeyViolationException $e) { echo "FK: {$e->constraintName} → {$e->referencedTable}"; } catch (DeadlockException $e) { if ($e->canRetry) { // Safe to retry — deadlocks are always retryable retry($e->retryAttempt + 1); } } catch (ConnectionLostException $e) { echo "Lost after {$e->uptimeBeforeLoss}s, retryable: " . ($e->retryable ? 'yes' : 'no'); } catch (SyntaxException $e) { // Never retryable echo "SQL error near '{$e->nearToken}' at position {$e->errorPosition}"; }
Exception Hierarchy
DatabaseException (base — extends RuntimeException)
├── ConnectionException ($host, $port, $connectionName, $endpoint hook)
│ ├── ConnectionFailedException ($attemptedHosts)
│ ├── ConnectionLostException ($uptimeBeforeLoss, $wasInTransaction, $retryable hook)
│ └── AuthenticationException ($username)
├── QueryException ($sql, $params, $sqlState, $driverErrorCode, $debugSql hook)
│ ├── DuplicateKeyException ($constraintName, $duplicateColumn)
│ ├── ForeignKeyViolationException ($constraintName, $referencingColumn, $referencedTable)
│ ├── DeadlockException ($retryAttempt, $maxRetries, $canRetry hook, $retryable hook)
│ ├── LockTimeoutException ($timeoutSeconds, $blockingProcessId)
│ └── SyntaxException ($errorPosition, $nearToken, $retryable=false)
├── SchemaException ($schema)
│ ├── TableNotFoundException ($tableName, $qualifiedName hook)
│ └── ColumnNotFoundException ($columnName, $tableName, $qualifiedName hook)
├── TransactionException ($nestingLevel, $operation)
├── ConfigurationException ($connectionName, $configKey)
└── PoolException ($poolStats, $connectionName)
Schema Introspection
Discover table structure without manual SQL:
use MonkeysLegion\Database\Support\SchemaIntrospector; // Get all tables $tables = SchemaIntrospector::listTables($conn); // ['users', 'orders', 'products', ...] // Check table existence (case-insensitive, underscore-tolerant) $exists = SchemaIntrospector::tableExists($conn, 'users'); // true // Resolve actual table name (handles case/underscore differences) $name = SchemaIntrospector::resolveTableName($conn, 'orderItems'); // 'order_items' // Get columns for a table $columns = SchemaIntrospector::listColumns($conn, 'users'); // ['id', 'email', 'name', ...] // Check if a specific column exists $has = SchemaIntrospector::columnExists($conn, 'users', 'email'); // true // Results are statically cached — repeated calls are free SchemaIntrospector::clearCache(); // flush after migrations
Configuration Reference
DatabaseConfig
use MonkeysLegion\Database\Config\{DatabaseConfig, DsnConfig, PoolConfig, ReadReplicaConfig}; use MonkeysLegion\Database\Types\DatabaseDriver; $config = new DatabaseConfig( name: 'primary', driver: DatabaseDriver::MySQL, dsn: new DsnConfig( driver: DatabaseDriver::MySQL, host: 'db.example.com', port: 3306, database: 'production', charset: 'utf8mb4', ), username: 'appuser', password: 'secret', pdoOptions: [ PDO::ATTR_TIMEOUT => 10, ], timezone: 'UTC', pool: new PoolConfig( minConnections: 2, maxConnections: 20, ), ); // Or from array (backward-compatible) $config = DatabaseConfig::fromArray('primary', [ 'driver' => 'mysql', 'host' => 'db.example.com', 'port' => 3306, 'database' => 'production', 'username' => 'appuser', 'password' => 'secret', 'timezone' => 'UTC', 'pool' => ['max_connections' => 20], ]);
DsnConfig Options
| Driver | Option | Description |
|---|---|---|
| All | host |
Server hostname |
| All | port |
Server port |
| All | database |
Database name |
| MySQL | socket |
Unix socket path |
| MySQL | charset |
Character set (default: utf8mb4) |
| PostgreSQL | sslMode |
SSL mode (disable, require, etc.) |
| SQLite | file |
Database file path |
| SQLite | memory |
Use :memory: database |
Last Insert ID
$conn->execute('INSERT INTO users (name) VALUES (:n)', [':n' => 'Alice']); $id = $conn->lastInsertId(); // '1' // PostgreSQL — pass the sequence name $pgId = $conn->lastInsertId('users_id_seq');
Retry Handler
Automatic retry on transient failures (deadlocks, lost connections) with truncated exponential back-off and jitter:
use MonkeysLegion\Database\Support\RetryHandler; use MonkeysLegion\Database\Support\RetryConfig; $result = RetryHandler::withRetry( operation: function () use ($conn): int { return $conn->execute( 'UPDATE inventory SET stock = stock - 1 WHERE id = :id', [':id' => 42], ); }, config: new RetryConfig( maxAttempts: 4, baseDelayMs: 20, multiplier: 2.0, maxDelayMs: 500, jitter: true, ), );
RetryConfig is a PHP 8.4 readonly class. Its $effectiveMaxDelay property
uses a get hook to guarantee the value is never negative.
PSR-3 Logging
Inject a PSR-3 logger directly as a property — no setter method needed:
use Monolog\Logger; use Monolog\Handler\StreamHandler; $log = new Logger('db'); $log->pushHandler(new StreamHandler('php://stderr')); // On ConnectionManager — set hook propagates to all open connections $manager->logger = $log; // On a single Connection directly $conn->logger = $log;
Logged events: connection established, connection closed, query executed (with SQL + duration), query errors.
Event Dispatcher
use MonkeysLegion\Database\Contracts\ConnectionEventDispatcherInterface; $manager->eventDispatcher = $myDispatcher; // propagates to all open connections $conn->eventDispatcher = $myDispatcher; // or on a single connection
use MonkeysLegion\Database\Support\HealthChecker; $result = HealthChecker::check($conn); echo "Healthy: " . ($result->healthy ? 'yes' : 'no') . "\n"; echo "Latency: {$result->latencyMs}ms\n"; if ($result->reason) { echo "Reason: {$result->reason}\n"; }
Connection Lifecycle
$conn = $manager->connection(); // Check state $conn->isConnected(); // false — lazy, not connected yet $conn->isAlive(); // false // Force connection $conn->connect(); $conn->isConnected(); // true $conn->isAlive(); // true // Property hooks $conn->queryCount; // 0 $conn->uptimeSeconds; // 0.001... $conn->query('SELECT 1'); $conn->queryCount; // 1 // Reconnect (new PDO, reset counters) $conn->reconnect(); $conn->queryCount; // 0 // Disconnect $conn->disconnect(); $conn->uptimeSeconds; // 0.0
Multi-Connection Management
$manager = ConnectionManager::fromArray([ 'mysql' => ['driver' => 'mysql', 'host' => 'mysql-host', 'database' => 'app', ...], 'postgres' => ['driver' => 'pgsql', 'host' => 'pg-host', 'database' => 'analytics', ...], 'sqlite' => ['driver' => 'sqlite', 'memory' => true], ]); // Access by name $app = $manager->connection('mysql'); $analytics = $manager->connection('postgres'); $cache = $manager->connection('sqlite'); // Switch default $manager->setDefaultConnection('postgres'); $conn = $manager->connection(); // now returns postgres // Disconnect specific $manager->disconnect('mysql'); // Disconnect all $manager->disconnectAll();
DI Container Setup
use MonkeysLegion\Database\Contracts\ConnectionManagerInterface; use MonkeysLegion\Database\Connection\ConnectionManager; // Register ConnectionManagerInterface::class => fn() => ConnectionManager::fromArray( require base_path('config/database.php') ), // Usage public function __construct( private readonly ConnectionManagerInterface $db, ) {} public function getUser(int $id): array { $stmt = $this->db->read()->query( 'SELECT * FROM users WHERE id = :id', [':id' => $id], ); return $stmt->fetch() ?: []; }
Testing
composer test # Run PHPUnit composer phpstan # Run PHPStan level 9 composer quality # Run both
163 tests, 361 assertions covering all components:
| Component | Tests |
|---|---|
| Types (DatabaseDriver, IsolationLevel, ReadReplicaStrategy) | 21 |
| Config (DsnConfig, PoolConfig, DatabaseConfig) | 25 |
| Connection (Connection, LazyConnection, ConnectionManager, ConnectionPool) | 54 |
| Exceptions (18 classes) | 23 |
| Support (ErrorClassifier, ConnectionPoolStats) | 22 |
Upgrade from v1
v2 is a clean-slate rewrite. Key migration points:
| v1 | v2 |
|---|---|
MySQL\Connection, PostgreSQL\Connection, SQLite\Connection |
Connection\Connection (unified) |
DSN\MySQLDsnBuilder, etc. |
Config\DsnConfig value object |
Factory\ConnectionFactory |
Connection\ConnectionManager |
Types\DatabaseType enum |
Types\DatabaseDriver enum |
$connection->pdo() |
Same — $connection->pdo() |
Raw PDOException |
Typed exceptions (DuplicateKeyException, etc.) |
| No read/write splitting | $manager->read() / $manager->write() |
| No lazy connections | Lazy by default via ConnectionManager |
| No connection pooling | ConnectionPool with health monitoring |
License
MIT — © 2026 MonkeysCloud Inc.