Configure PDO the way production does, structurally eliminate SQL injection and make your writes reliable with transactions.
Open this lesson in KodokonPDO is the unified database access interface: MySQL, PostgreSQL or SQLite are all driven with the same API. Three settings separate a naive connection from a production one: ERRMODE_EXCEPTION so that every SQL error raises an exception instead of failing silently, FETCH_ASSOC by default for clean results, and EMULATE_PREPARES set to false for prepared statements actually executed by the server. Add charset=utf8mb4 directly in the DSN - it's the only reliable place to declare it.
<?php
declare(strict_types=1);
$dsn = 'mysql:host=localhost;dbname=shop'
. ';charset=utf8mb4';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO($dsn, 'app_user', 'secret', $options);The prepared statement is your only structural defense against SQL injection. The principle: the structure of the query, with its :email or ? placeholders, is sent to the server first; the data follows separately and is never interpreted as SQL. Concatenating a variable into a query, even an "escaped" one, remains a professional mistake. Named placeholders make the code readable; positional ones are enough for short queries.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION,
);
$pdo->exec(
'CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL
)'
);
$insert = $pdo->prepare(
'INSERT INTO users (email) VALUES (:email)'
);
$insert->execute(['email' => 'lea@example.com']);
$query = $pdo->prepare(
'SELECT id, email FROM users
WHERE email = :email'
);
$query->execute(['email' => 'lea@example.com']);
var_dump($query->fetch(PDO::FETCH_ASSOC));A transaction guarantees atomicity: either all the writes succeed, or none of them do. The canonical pattern: beginTransaction(), the work inside a try, commit() at the end of the block, rollBack() in the catch - then you rethrow the exception, because hiding the failure would be worse than the failure itself. Keep transactions short: every lock held during a network call or a long process degrades concurrency across the whole application.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION,
);
$pdo->exec(
'CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
balance INTEGER NOT NULL
)'
);
$pdo->exec(
'INSERT INTO accounts (balance)
VALUES (100), (20)'
);
$pdo->beginTransaction();
try {
$debit = $pdo->prepare(
'UPDATE accounts
SET balance = balance - :n
WHERE id = :id'
);
$debit->execute(['n' => 50, 'id' => 1]);
$credit = $pdo->prepare(
'UPDATE accounts
SET balance = balance + :n
WHERE id = :id'
);
$credit->execute(['n' => 50, 'id' => 2]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
echo 'Transfer done';:param placeholder in a prepared statement?catch block?commit() to save what succeededrollBack(), then rethrow the exception