NightFox

NightFox

PDO and Safe Database Access

PDO is how I talk to databases from PHP: one consistent way to connect, run prepared statements, handle transactions, and get results. My main goal with the setup below is to make the unsafe thing (gluing user input into SQL, or ignoring errors) hard to do by accident.

Create one well-configured connection

<?php
declare(strict_types=1);

final class Database
{
    public static function connect(): PDO
    {
        $dsn = sprintf(
            'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
            $_ENV['DB_HOST'],
            (int) ($_ENV['DB_PORT'] ?? 3306),
            $_ENV['DB_NAME']
        );

        return new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]);
    }
}

Prepared statements

<?php
declare(strict_types=1);

$stmt = $pdo->prepare(
    'SELECT id, name, email
       FROM customers
      WHERE status = :status
        AND created_at >= :created_after
      ORDER BY name'
);
$stmt->execute([
    'status' => 'active',
    'created_after' => '2026-01-01 00:00:00',
]);

$customers = $stmt->fetchAll();

Placeholders work for values, not for column or table names. If users get to pick a sort column, check it against a fixed list of allowed columns. Never paste the request value into the SQL.

Safe dynamic sorting

<?php
declare(strict_types=1);

$allowedSorts = [
    'name' => 'name',
    'created' => 'created_at',
    'status' => 'status',
];

$sortKey = $_GET['sort'] ?? 'name';
$sortColumn = $allowedSorts[$sortKey] ?? $allowedSorts['name'];
$direction = ($_GET['direction'] ?? 'asc') === 'desc' ? 'DESC' : 'ASC';

$sql = "SELECT id, name, status FROM customers ORDER BY {$sortColumn} {$direction}";
$rows = $pdo->query($sql)->fetchAll();

Transactions

<?php
declare(strict_types=1);

$pdo->beginTransaction();

try {
    $invoice = $pdo->prepare(
        'INSERT INTO invoices (customer_id, status, created_at)
         VALUES (:customer_id, :status, UTC_TIMESTAMP())'
    );
    $invoice->execute(['customer_id' => 42, 'status' => 'draft']);
    $invoiceId = (int) $pdo->lastInsertId();

    $line = $pdo->prepare(
        'INSERT INTO invoice_lines
             (invoice_id, description, quantity, unit_price_cents)
         VALUES
             (:invoice_id, :description, :quantity, :unit_price_cents)'
    );
    $line->execute([
        'invoice_id' => $invoiceId,
        'description' => 'Service charge',
        'quantity' => 1,
        'unit_price_cents' => 12500,
    ]);

    $pdo->commit();
} catch (Throwable $exception) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    throw $exception;
}

Pagination

LIMIT ... OFFSET is easy, but it gets slow on big tables (the database still reads every skipped row), and pages shift when rows are added. Keyset pagination remembers the last ID you saw and starts from there instead.

SELECT id, created_at, subject
FROM tickets
WHERE (created_at, id) < (:created_at, :id)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Database access rules

  • Use a least-privilege database account for the application.
  • Never expose raw database errors to users.
  • Keep SQL close to the repository or query object that owns it.
  • Use transactions for multi-statement state changes.
  • Log slow operations with identifiers and duration, not secrets.
  • Design indexes around actual query patterns.

Last Modified: October 12, 2026, 11:46 am