PHP PDO and Prepared Statements: Secure Database Access Complete Guide
A complete guide to PHP Data Objects — configuring a connection that fails loudly, writing prepared statements that genuinely prevent injection, and handling transactions, fetch modes and the awkward cases like IN clauses.
PDO is PHP's database abstraction layer, and the reason to use it is not portability across engines — most applications never switch database. It is that PDO's prepared statements separate SQL from data at the protocol level, which is the only reliable defence against SQL injection.
The catch is that PDO's defaults are wrong for modern use. A connection created without options emulates prepared statements, hides errors, and returns every column twice. Fixing those three defaults is the first thing to do.
The Three Options That Matter
Every PDO connection should set the error mode, the default fetch mode, and disable emulated prepares. Without them, failures are silent and prepared statements are not truly prepared.
<?php
declare(strict_types=1);
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
// charset=utf8mb4 in the DSN is required — utf8 in MySQL is only 3 bytes
// and cannot store emoji or many CJK characters.
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=shop;charset=utf8mb4';
$pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
// Throw exceptions instead of returning false silently
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
// Return associative arrays, not duplicated numeric + named keys
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// Use real server-side prepares, not client-side string interpolation
PDO::ATTR_EMULATE_PREPARES => false,
// Keep integers as integers rather than strings
PDO::ATTR_STRINGIFY_FETCHES => false,
]);
return $pdo;
}
ATTR_EMULATE_PREPARES => false is the security-relevant one. With emulation on, PDO builds the final SQL string in PHP and escapes the values itself — which has historically been bypassable under certain connection charsets. With it off, the values never enter the SQL string at all.Never Expose Connection Errors
A PDOException message can contain the DSN, username and query. Catch it, log it, and show the user something generic.
<?php
try {
$pdo = db();
} catch (PDOException $e) {
// The message may include credentials — log it, never echo it
error_log('DB connection failed: ' . $e->getMessage());
http_response_code(503);
exit('Service temporarily unavailable.');
}
Why Concatenation Cannot Be Made Safe
A prepared statement sends the SQL and the parameters to the server separately. The query plan is built before any value arrives, so a value can never be parsed as SQL — regardless of what it contains.
<?php
// VULNERABLE: the input becomes part of the SQL statement
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
// email = "' OR '1'='1" returns every user
// email = "'; DROP TABLE users; --" is worse
$rows = $pdo->query($sql)->fetchAll();
// SAFE: named placeholders, values sent separately
$stmt = $pdo->prepare('SELECT id, email, name FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email']]);
$user = $stmt->fetch();
// Positional placeholders work too
$stmt = $pdo->prepare(
'SELECT id FROM users WHERE status = ? AND created_at > ?'
);
$stmt->execute(['active', '2026-01-01']);
$ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
ASC. Those must be validated against an allow-list, because there is no parameter form for them.<?php
// A user-supplied sort column cannot be a placeholder.
// Map it through an allow-list instead of interpolating it.
$sortable = [
'name' => 'name',
'date' => 'created_at',
'price' => 'price_cents',
];
$column = $sortable[$_GET['sort'] ?? 'date'] ?? 'created_at';
$direction = strtoupper($_GET['dir'] ?? '') === 'ASC' ? 'ASC' : 'DESC';
// Safe: $column and $direction can only hold values we chose
$sql = "SELECT id, name FROM products ORDER BY {$column} {$direction} LIMIT :limit";
$stmt = $pdo->prepare($sql);
$stmt->bindValue('limit', 20, PDO::PARAM_INT);
$stmt->execute();
The LIMIT Integer Problem
With emulation disabled, parameters are sent as strings by default. MySQL rejects a string in LIMIT, so those parameters need an explicit integer type — which means bindValue rather than passing an array to execute.
<?php
// Fails with a syntax error: LIMIT '20'
// $stmt = $pdo->prepare('SELECT * FROM posts LIMIT :limit');
// $stmt->execute(['limit' => 20]);
// Correct: bind with an explicit integer type
$stmt = $pdo->prepare('SELECT * FROM posts LIMIT :limit OFFSET :offset');
$stmt->bindValue('limit', (int) $perPage, PDO::PARAM_INT);
$stmt->bindValue('offset', (int) $offset, PDO::PARAM_INT);
$stmt->execute();
IN Clauses with a Variable Number of Values
A single placeholder cannot expand to a list. The idiomatic solution is to generate exactly as many placeholders as there are values.
<?php
$ids = [4, 8, 15, 16];
// Produces "?,?,?,?" — one placeholder per value
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare(
"SELECT id, title FROM posts WHERE id IN ({$placeholders})"
);
$stmt->execute($ids);
$posts = $stmt->fetchAll();
// Guard the empty case: "IN ()" is a syntax error
if ($ids === []) {
$posts = [];
}
Fetch Modes and Iteration
PDO offers several result shapes. Choosing the right one removes a loop of manual array reshaping.
<?php
$stmt = $pdo->query('SELECT id, name, email FROM users');
// One row at a time as an associative array
while ($row = $stmt->fetch()) {
echo $row['name'];
}
// All rows
$all = $stmt->fetchAll();
// A single column, flattened to a list
$names = $pdo->query('SELECT name FROM users')->fetchAll(PDO::FETCH_COLUMN);
// Key/value pairs from the first two columns
$map = $pdo->query('SELECT id, name FROM users')->fetchAll(PDO::FETCH_KEY_PAIR);
// One scalar
$count = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
// Hydrate objects of a class directly
class UserRow { public int $id; public string $name; }
$users = $pdo->query('SELECT id, name FROM users')
->fetchAll(PDO::FETCH_CLASS, UserRow::class);
// A PDOStatement is iterable — stream large results
foreach ($pdo->query('SELECT id FROM big_table') as $row) {
process($row['id']);
}
fetchAll() loads the entire result set into memory. For large tables, iterate the statement or use fetch() in a loop instead.On writes, rowCount() reports affected rows and lastInsertId() returns the generated key. Note that rowCount() on a SELECT is not portable — use COUNT(*) for that.
All-or-Nothing Writes
Any operation that writes to more than one table needs a transaction, otherwise a failure halfway through leaves inconsistent data. The pattern is always try, commit, roll back on exception.
<?php
function placeOrder(PDO $pdo, int $userId, array $items): int
{
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'INSERT INTO orders (user_id, status, created_at)
VALUES (:user_id, :status, NOW())'
);
$stmt->execute(['user_id' => $userId, 'status' => 'pending']);
$orderId = (int) $pdo->lastInsertId();
$line = $pdo->prepare(
'INSERT INTO order_items (order_id, sku, qty, price_cents)
VALUES (:order_id, :sku, :qty, :price)'
);
$stock = $pdo->prepare(
'UPDATE products SET stock = stock - :qty
WHERE sku = :sku AND stock >= :qty'
);
foreach ($items as $item) {
$line->execute([
'order_id' => $orderId,
'sku' => $item['sku'],
'qty' => $item['qty'],
'price' => $item['price_cents'],
]);
$stock->execute(['qty' => $item['qty'], 'sku' => $item['sku']]);
// The WHERE guard means 0 affected rows => insufficient stock
if ($stock->rowCount() === 0) {
throw new RuntimeException("Out of stock: {$item['sku']}");
}
}
$pdo->commit();
return $orderId;
} catch (Throwable $e) {
// Roll back only if a transaction is still open
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
The stock update is worth a second look. Putting stock >= :qty in the WHERE clause makes the check and the decrement a single atomic statement, so two concurrent orders cannot both pass a separate check and oversell. Checking in PHP first would be a race.
CREATE TABLE or ALTER TABLE cause an implicit commit in MySQL. Running schema changes inside a transaction does not give you the rollback you expect.• Put charset=utf8mb4 in the DSN, not utf8.
• Placeholders bind values only — allow-list table and column names.
• Bind LIMIT and OFFSET with PDO::PARAM_INT.
• Generate one placeholder per value for IN clauses, and handle the empty array.
• Wrap multi-table writes in a transaction and roll back inside a catch.
• Never echo a PDOException message to the user.
Summary
PDO used properly makes SQL injection structurally impossible rather than merely unlikely, because parameters never become part of the statement the server parses. That guarantee depends on real prepared statements, which means disabling emulation explicitly.
Set the three connection options once in a single factory, use placeholders for every value, allow-list anything that cannot be a placeholder, and wrap multi-statement writes in transactions with rollback on exception.
The remaining sharp edges are few and predictable: integer binding for LIMIT, placeholder generation for IN, and exception messages that must be logged rather than displayed. Handle those and PDO is a solid foundation for any PHP application.