PHP PDO Tutorial: The Complete Guide to Safe Database Code (2026)
A complete PHP PDO tutorial: connect, prepared statements, fetch modes, insert and lastInsertId, transactions, password hashing and secure forms, with linked guides.
Use PHP PDO transactions safely: beginTransaction, commit, rollBack in try/catch, a bank transfer example, inTransaction(), and the MySQL implicit commit trap.
A transaction makes several queries behave like one: either all of them succeed, or none of them do. That matters any time a half-finished change would leave bad data behind, such as moving money between accounts, creating an order with its items, or importing a file. Here is how to do it properly with PHP PDO, including the traps that make transactions silently not work.
try {
$pdo->beginTransaction();
// ...your queries...
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
This only works if PDO throws exceptions on errors. That is the default from PHP 8.0, but set PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION anyway, as shown in my PDO prepared statements guide.
function transfer(PDO $pdo, int $from, int $to, int $pence): void
{
$pdo->beginTransaction();
try {
$take = $pdo->prepare(
'UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?'
);
$take->execute([$pence, $from, $pence]);
if ($take->rowCount() !== 1) {
throw new RuntimeException('Insufficient funds');
}
$give = $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?');
$give->execute([$pence, $to]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
Storing money as whole pence (an integer) avoids floating point rounding errors. The balance >= ? check inside the UPDATE stops two simultaneous requests from overdrawing the account.
A common case is inserting a parent row, reading its ID, then inserting children:
$pdo->beginTransaction();
try {
$pdo->prepare('INSERT INTO orders (user_id) VALUES (?)')->execute([$userId]);
$orderId = (int) $pdo->lastInsertId();
$item = $pdo->prepare('INSERT INTO order_items (order_id, sku, qty) VALUES (?, ?, ?)');
foreach ($cart as $line) {
$item->execute([$orderId, $line['sku'], $line['qty']]);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
More on reading the new ID in PDO insert and lastInsertId().
MyISAM tables do not support transactions. rollBack() returns without error but the rows stay. Check with SHOW TABLE STATUS and convert with ALTER TABLE accounts ENGINE=InnoDB;.
In MySQL, statements like CREATE TABLE, ALTER TABLE, DROP TABLE and TRUNCATE cause an implicit commit. Everything before them is saved, and on PHP 8 a later commit() or rollBack() throws "There is no active transaction". Keep schema changes out of transactions on MySQL.
With the old silent error mode, a failed query returns false, your code carries on, and commit() saves a partial change. Use exception mode.
$pdo->inTransaction() returns true while a transaction is open. Use it before calling rollBack() in shared error handlers.beginTransaction() twice throws. If you need partial rollbacks, use SQL savepoints: SAVEPOINT s1 and ROLLBACK TO SAVEPOINT s1.function tx(PDO $pdo, callable $fn): mixed
{
$pdo->beginTransaction();
try {
$result = $fn($pdo);
$pdo->commit();
return $result;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
$orderId = tx($pdo, fn(PDO $db) => createOrder($db, $cart));
No. A single statement is already atomic in InnoDB. Use transactions when two or more statements must succeed together.
Yes. If the connection closes with an open transaction, the database rolls it back. Relying on that is sloppy, though; roll back explicitly.
Usually much faster, because the database commits once instead of once per row.
Yes, the same PDO methods work. PostgreSQL even allows DDL inside a transaction, unlike MySQL.
See every PDO guide in one place on the PHP PDO tutorial hub.