PHP PDO is the safest and most flexible way to talk to a database in modern PHP applications. Used correctly, it virtually eliminates SQL injection, one of the most common and most dangerous attacks against web applications. In this guide you will learn how to open a PDO connection, how prepared statements actually work, and the practical patterns you will reach for every day.
Why is SQL injection so dangerous?
SQL injection happens when user-supplied data is pasted straight into an SQL query. Instead of a normal value, an attacker sends text that changes the meaning of the query. Here is the classic, broken example:
// NEVER DO THIS
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
$result = $pdo->query($sql);
If the user types ' OR '1'='1 into the email field, the condition is always true and every user record can leak. Worse, an attacker might drop tables or escalate privileges. The fix is simple: never mix data directly into the SQL text.
Setting up the PDO connection correctly
A good PDO connection includes a few settings that stop errors from being silently swallowed and make behavior predictable. Always wrap the connection in a try/catch block, and read credentials from environment variables instead of hardcoding them.
$dsn = 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
// Log the error, never show the raw message to the user
error_log($e->getMessage());
exit('Could not connect to the database.');
}
Three settings matter: ERRMODE_EXCEPTION throws errors as exceptions instead of failing silently, FETCH_ASSOC returns readable associative arrays, and EMULATE_PREPARES = false makes the driver use real prepared statements, which is better for both security and type correctness.
How prepared statements work
A prepared statement separates the structure of a query from its data. First you prepare the query template with placeholders, then you send the values separately. As a result, the data, no matter how dangerous it looks, is never interpreted as an SQL command; it is always treated as a value.
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
Here ? is a placeholder. The array you pass to execute() is safely bound into the placeholders. The driver handles the value strictly as data, and the possibility of injection disappears.
Named parameters
When a query has several parameters, named placeholders make the code far more readable than positional ? marks. Named parameters start with a colon:
$stmt = $pdo->prepare(
'INSERT INTO posts (title, body, user_id) VALUES (:title, :body, :user_id)'
);
$stmt->execute([
':title' => $title,
':body' => $body,
':user_id' => $userId,
]);
If you want more control, bindValue() lets you set the type of each parameter:
$stmt = $pdo->prepare('SELECT * FROM products WHERE stock >= :min');
$stmt->bindValue(':min', $min, PDO::PARAM_INT);
$stmt->execute();
Reading the results
PDO offers several ways to read results. Use fetch() for a single row and fetchAll() for all rows. When processing many rows, the memory-friendly approach is to pull them one at a time in a loop:
$stmt = $pdo->prepare('SELECT id, title FROM posts WHERE user_id = ?');
$stmt->execute([$userId]);
foreach ($stmt as $row) {
echo htmlspecialchars($row['title']) . "\n";
}
Note: use htmlspecialchars() when printing data that came from the database. PDO protects you from SQL injection, but XSS (cross-site scripting) is a separate concern that must be handled on the output side.
Common mistakes
- Treating a table or column name as a parameter. Placeholders are for values only;
ORDER BY ?will not work. Validate column and table names against a fixed whitelist. - Leaving EMULATE_PREPARES on. When enabled, PDO assembles the query on its own side; it is safe in most cases, but real prepared statements are always more robust.
- Showing errors to the user. Raw error messages reveal your database structure. Log them and return a generic message.
- Re-preparing the same query in a loop. Call
prepare()once, then onlyexecute()inside the loop.
Transactions
When you run several writes that depend on each other, use a transaction so they all succeed or all fail together. If one step fails, rollBack() undoes everything:
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')
->execute([$amount, $fromId]);
$pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')
->execute([$amount, $toId]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
throw $e;
}
Frequently Asked Questions
Should I use PDO or MySQLi?
Both support prepared statements and are secure. PDO's advantage is that it supports a dozen different database drivers through a single interface and offers a more readable API, including named parameters. For most new projects, PDO is the more flexible choice.
Do prepared statements hurt performance?
No, usually the opposite. When the same query runs repeatedly with different values, the database can reuse its query plan. For one-off queries the difference is negligible, and the security you gain is worth far more than the cost.
Is using prepared statements alone enough?
Against SQL injection, yes, when used correctly it is enough. But security is holistic: you also need XSS protection on output, input validation, authorization, and the principle of least privilege.
Take database security seriously. If you want me to review an existing PHP project or build a safe data layer for you, get in touch and let's lay a solid foundation together.