PDO and Safe Database Access
PDO provides a consistent interface for database connections, prepared statements, transactions, and result handling. The main goal is to make unsafe SQL and inconsistent error handling difficult.
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 bind values, not SQL identifiers. Whitelist dynamic column names and sort directions instead of inserting raw request data.
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
Offset pagination is easy but becomes slower and less stable on large changing data sets. Keyset pagination uses the last seen indexed value.
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.