Docs

Database

A razor-thin PDO wrapper that gives every MonkeysLegion package a fast, consistent way to talk to MySQL 8.4+.

It handles DSN construction, lazy connection, automatic reconnection, and convenience helpers that keep raw SQL readable.

1 · Installation

composer require monkeyscloud/monkeyslegion-database

Nothing else to install—just PHP 8.4 and the native pdo_mysql extension.

2 · Creating a Connection

use MonkeysLegion\Database\Connection;

$config = [
    'host'     => '127.0.0.1',
    'port'     => 3306,
    'dbname'   => 'monkeys_app',
    'user'     => 'root',
    'pass'     => '',
    // optional
    'charset'  => 'utf8mb4',
    'options'  => [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ],
];

$conn = new Connection($config);   // lazy-connects on first query

Config keys

Key

Required

Default

Notes

host, dbname, user

n/a

Standard MySQL creds

pass

''

Empty string for unix-socket auth

port

3306

 

charset

utf8mb4

Sets SET NAMES … after connect

options

reasonable PDO defaults

Merge/override freely

3 · Everyday API

Method

What it does

pdo()

Returns the underlying PDO instance (for libraries that need raw access)

run(string $sql, array $params = [])

Prepares & executes, returns PDOStatement

fetchAll(string $sql, array $params = [], ?string $into = null)

Runs and hydrates rows; pass a class name to hydrate objects

fetchOne(...)

Same as fetchAll but returns a single row / object or null

column(...)

Shorthand for SELECT col FROM … LIMIT 1

All placeholders are bound by name (:id, :slug, …) to eliminate SQL injection mistakes.

4 · Transactions

$conn->pdo()->beginTransaction();
try {
    $conn->run('INSERT INTO orders ...', $orderData);
    $conn->run('INSERT INTO invoices ...', $invoiceData);
    $conn->pdo()->commit();
} catch (\Throwable $e) {
    $conn->pdo()->rollBack();
    throw $e;              // bubble up for logging / 500 handler
}

Because QueryBuilder and EntityRepository both accept a Connection, their writes automatically participate in the same transaction.

5 · Convenience Helpers

// Auto-reconnect after “MySQL server has gone away”
$conn->ping();                 // returns true if connection is healthy

// Dump last statement for debugging
$sql = $conn->lastQuery();     // e.g. "SELECT * FROM users WHERE id = ?"

// Simple pagination helper
[$rows, $total] = $conn->paginate(
    'SELECT * FROM posts WHERE status = :status',
    ['status' => 'published'],
    page: 3,
    perPage: 20,
);

Exact helper names may evolve; check the doc-blocks in Connection.php for the definitive list.

6 · Integrating with DI

return [
    // Share one Connection across the app
    MonkeysLegion\Database\Connection::class => fn() =>
        new Connection(require base_path('config/database.php')),
];

Now anything that type-hints ConnectionQueryBuilder, repositories, custom services—gets the same pooled PDO instance.

7 · Working with QueryBuilder

use MonkeysLegion\Query\QueryBuilder;

/** @var QueryBuilder $qb */
$qb = new QueryBuilder($conn);

$posts = $qb->select('*')
            ->from('posts')
            ->where('author_id', '=', 42)
            ->orderBy('published_at', 'DESC')
            ->limit(10)
            ->fetchAll(App\Entity\Post::class);   // hydrated objects

8 · Extending

8.1 · Query-level events (metrics / logging)

Connection exposes a simple event system: register any callable against beforeQuery or afterQuery and it will fire for every run()/fetch*() call.

use MonkeysLegion\Database\Connection;

// bootstrap
$conn->on('beforeQuery', static function (string $sql, array $params): float {
    return microtime(true);              // return something to pass to afterQuery
});

$conn->on('afterQuery', static function (string $sql, array $params, $start) {
    $t = (microtime(true) - $start) * 1000;
    printf("[db] %.1f ms – %s\n", $t, $sql);
});

Emit to stdout (like above), push to Prometheus, or dispatch to any PSR-3 logger.

8.2 · Custom fetch modes (DTOs / JSON columns)

Need objects that aren’t just mapped rows? Wrap fetchAll() in a trait or helper:

trait JsonAwareFetch
{
    public function fetchAllJson(string $sql, array $params = []): array
    {
        return array_map(
            fn ($row) => new UserSummary(
                id:      (int) $row['id'],
                name:    $row['name'],
                profile: json_decode($row['profile_json'], true, flags: JSON_THROW_ON_ERROR),
            ),
            parent::fetchAll($sql, $params)   // <- original method
        );
    }
}

final class MyConnection extends Connection
{
    use JsonAwareFetch;
}

// usage
$users = $conn->fetchAllJson('SELECT * FROM users');

Because the wrapper calls parent::fetchAll(), you still benefit from named-parameter binding and automatic reconnection.

8.3 · Read/Write splitting

For high-traffic apps you can point writes to the primary but reads to replicas by overriding pdo():

final class SplitConnection extends Connection
{
    private ?PDO $reader = null;

    protected function pdo(bool $write = true): PDO
    {
        if ($write) {                     // INSERT/UPDATE/DELETE
            return parent::pdo(true);
        }

        // lazy-build replica connection
        return $this->reader ??= new PDO(
            'mysql:host=replica-1;dbname=monkeys_app;charset=utf8mb4',
            'app', 'secret',
            $this->options
        );
    }

    /** Select helpers call the read pool */
    public function fetchAll(string $sql, array $params = [], ?string $into = null): array
    {
        $stmt = $this->pdo(false)->prepare($sql);
        $stmt->execute($params);
        return $into ? $stmt->fetchAll(PDO::FETCH_CLASS, $into) : $stmt->fetchAll();
    }
}

Override fetchOne(), column(), and any other read helpers the same way. All writes (run(), insertId() …) continue to hit the primary.

License

MIT © 2025 MonkeysCloud