Savepoints allow partial rollbacks within a transaction.
Always enable exception mode and guard rollback calls with inTransaction().
Plain-English First
Imagine you're at an ATM withdrawing £200. Two things must happen: the bank subtracts £200 from your account AND the machine dispenses the cash. If the power cuts out between those two steps, you can't lose your money without getting the cash — or get the cash without being charged. A database transaction is exactly that guarantee: either ALL the steps succeed together, or NONE of them happen at all. It's an all-or-nothing promise your database makes to your application.
Every serious web application eventually hits the moment where one database query just isn't enough. You're transferring money between accounts, placing an order while updating stock levels, or creating a user while assigning them a default role. In each case, multiple queries must all succeed — or the data ends up in a broken, inconsistent state that silently corrupts your application for days before anyone notices.
That's the exact problem database transactions solve. Without them, a PHP script that crashes halfway through a multi-step operation leaves your database in a half-written state. With them, the database treats your group of queries as a single atomic unit — if anything goes wrong, it rewinds everything back to where you started, as if none of it ever happened.
By the end of this article you'll understand not just the syntax of transactions in PHP using both PDO and MySQLi, but WHY each piece exists, when you actually need them, how to handle errors correctly so rollbacks fire when they should, and the subtle gotchas that trip up even experienced developers in production.
What ACID Actually Means (And Why You Should Care)
ACID is the four-property guarantee that a transaction-compliant database engine makes. You'll hear this word thrown around in interviews and architecture meetings, so let's make it concrete rather than abstract.
Atomicity — All queries in the transaction succeed, or none of them are saved. The ATM analogy from the intro. No half-written state.
Consistency — The database moves from one valid state to another valid state. Your business rules and constraints (foreign keys, unique indexes, NOT NULL) are always enforced. A transaction that would break a constraint is rejected entirely.
Isolation — Concurrent transactions don't see each other's unfinished work. While your transaction is running, another request reading the same rows won't see your uncommitted changes. This prevents 'dirty reads'.
Durability — Once a transaction is committed, it stays committed. Even if the server crashes a millisecond later, the data is on disk.
In PHP terms, you care most about Atomicity and Consistency day-to-day. Isolation and Durability are handled by MySQL's InnoDB engine — but you need to know they exist because isolation level is something you can tune, and it becomes critical in high-traffic applications dealing with inventory or financial ledgers.
acid_demo.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<?php
/**
* ACIDDemo: Transferring funds between two bank accounts.
* IfANY step fails, the entire operation is rolled back.
* Both accounts stay consistent — no money is created or destroyed.
*/
$dsn = 'mysql:host=localhost;dbname=bank_app;charset=utf8mb4';
$dbUser = 'root';
$dbPassword = 'secret';
try {
$pdo = newPDO($dsn, $dbUser, $dbPassword, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // Throw exceptions on errorPDO::ATTR_EMULATE_PREPARES => false, // Use real prepared statementsPDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$senderAccountId = 101;
$receiverAccountId = 202;
$transferAmount = 250.00;
// ── Step 1: Start the transaction ──────────────────────────────────────// MySQL auto-commits every query by default. beginTransaction() turns// that OFF so nothing is saved until we explicitly call commit().
$pdo->beginTransaction();
// ── Step 2: Debit the sender ───────────────────────────────────────────// We use SELECT ... FOR UPDATE to lock this row during the transaction.// This prevents another concurrent request from reading a stale balance.
$lockSender = $pdo->prepare(
'SELECT balance FROM accounts WHERE account_id = :id FOR UPDATE'
);
$lockSender->execute([':id' => $senderAccountId]);
$senderAccount = $lockSender->fetch();
if (!$senderAccount) {
thrownewRuntimeException("Sender account {$senderAccountId} not found.");
}
if ($senderAccount['balance'] < $transferAmount) {
thrownewRuntimeException(
"Insufficient funds. Balance: {$senderAccount['balance']}, Requested: {$transferAmount}"
);
}
$debitSender = $pdo->prepare(
'UPDATE accounts SET balance = balance - :amount WHERE account_id = :id'
);
$debitSender->execute([
':amount' => $transferAmount,
':id' => $senderAccountId,
]);
// ── Step 3: Credit the receiver ────────────────────────────────────────
$creditReceiver = $pdo->prepare(
'UPDATE accounts SET balance = balance + :amount WHERE account_id = :id'
);
$creditReceiver->execute([
':amount' => $transferAmount,
':id' => $receiverAccountId,
]);
// ── Step 4: Log the transaction for audit purposes ─────────────────────
$logTransfer = $pdo->prepare(
'INSERTINTOtransfer_log (sender_id, receiver_id, amount, transferred_at)
VALUES (:sender, :receiver, :amount, NOW())'
);
$logTransfer->execute([
':sender' => $senderAccountId,
':receiver' => $receiverAccountId,
':amount' => $transferAmount,
]);
// ── Step 5: Everything succeeded — save all three changes atomically ───
$pdo->commit();
echo"Transfer successful: £{$transferAmount} moved from account "
. "{$senderAccountId} to {$receiverAccountId}.\n";
} catch (RuntimeException $businessException) {
// Business rule violation (insufficient funds, missing account)// Roll back any partial changes made in this transactionif ($pdo->inTransaction()) {
$pdo->rollBack();
}
echo"Transfer refused: " . $businessException->getMessage() . "\n";
} catch (PDOException $dbException) {
// Database-level failure (connection dropped, constraint violated)if ($pdo->inTransaction()) {
$pdo->rollBack();
}
echo"Database error — transfer cancelled: " . $dbException->getMessage() . "\n";
}
Output
Transfer successful: £250.00 moved from account 101 to 202.
// If sender had insufficient funds:
Transfer refused: Insufficient funds. Balance: 100.00, Requested: 250.00
// If a DB constraint failed mid-way:
Database error — transfer cancelled: SQLSTATE[23000]: Integrity constraint violation
Watch Out: InnoDB Only
MySQL transactions only work with InnoDB tables. If your table was created with MyISAM (an older engine), BEGIN/COMMIT silently do nothing — your queries auto-commit immediately regardless. Run SHOW CREATE TABLE accounts; and confirm you see ENGINE=InnoDB. If you see ENGINE=MyISAM, ALTER the table: ALTER TABLE accounts ENGINE=InnoDB;.
Production Insight
In production, misidentifying the storage engine causes the most confusing transaction bug.
A developer deploys transaction code, tests on a dev server with InnoDB, but production tables are still MyISAM.
Rule: always check ENGINE in your migration scripts before relying on transactions.
Key Takeaway
Transactions are all-or-nothing.
Always use exception mode.
Guard rollback with inTransaction().
When to use which transaction element
IfSingle query that can't fail partially
→
UseNo transaction needed – autocommit is fine.
IfTwo or more related writes (e.g., debit + credit)
→
UseUse beginTransaction() + commit() + rollBack() in catch.
IfBatch of rows where some may fail independently
→
UseUse savepoints to skip bad rows without aborting the whole batch.
Transactions with MySQLi: The Procedural and OOP Approaches
PDO is the modern choice for new projects, but a huge amount of PHP code in the wild uses MySQLi — especially legacy codebases and WordPress-adjacent applications. You need to know how transactions work here too, because the mental model is identical even though the API looks different.
The critical difference with MySQLi is that autocommit is enabled by default, exactly like PDO. You have two ways to disable it: call $mysqli->autocommit(false) before your queries, or call $mysqli->begin_transaction() which does the same thing implicitly. Both work; begin_transaction() is the cleaner, more readable approach.
Error handling in MySQLi requires extra care. Unlike PDO — which throws exceptions when you set ERRMODE_EXCEPTION — older MySQLi code silently returns false on failure. If you're not checking every return value, a failed query mid-transaction won't trigger a rollback; your code will merrily continue and commit a broken state. The example below uses mysqli_report() to force MySQLi to throw exceptions, which is the pattern you should use on all new code.
mysqli_transaction.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
<?php
/**
* MySQLiTransaction: Creating an order and reducing inventory simultaneously.
* Usesmysqli_report() to ensure errors throw exceptions rather than
* returning false silently — a critical safety measure.
*/
// Force MySQLi to throw exceptions instead of returning false on failure.// Place this BEFORE creating the connection — it affects the connection setup too.mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
try {
$mysqli = newmysqli('localhost', 'root', 'secret', 'shop_db');
$mysqli->set_charset('utf8mb4'); // Always set charset explicitly
$customerId = 55;
$productId = 12;
$orderQty = 3;
$priceEach = 19.99;
$totalAmount = $orderQty * $priceEach;
// ── Start transaction ──────────────────────────────────────────────────
$mysqli->begin_transaction();
// ── Step 1: Check and lock inventory row ──────────────────────────────// FOR UPDATE locks this specific row until we commit or rollback.// Without this lock, two simultaneous orders could both read// stock_qty = 5 and both succeed, overselling the product.
$stockCheck = $mysqli->prepare(
'SELECT stock_qty FROM products WHERE product_id = ? FOR UPDATE'
);
$stockCheck->bind_param('i', $productId);
$stockCheck->execute();
$stockCheck->bind_result($currentStock);
$stockCheck->fetch();
$stockCheck->close(); // Close statement before opening another on same connectionif ($currentStock === null) {
thrownewRuntimeException("Product ID {$productId} does not exist.");
}
if ($currentStock < $orderQty) {
thrownewRuntimeException(
"Only {$currentStock} units in stock, but {$orderQty} requested."
);
}
// ── Step 2: Insert the order record ───────────────────────────────────
$insertOrder = $mysqli->prepare(
'INSERTINTOorders (customer_id, total_amount, created_at)
VALUES (?, ?, NOW())'
);
$insertOrder->bind_param('id', $customerId, $totalAmount);
$insertOrder->execute();
// Capture the auto-generated order ID immediately after insert
$newOrderId = $mysqli->insert_id;
$insertOrder->close();
// ── Step 3: Insert the order line item ────────────────────────────────
$insertLineItem = $mysqli->prepare(
'INSERTINTOorder_items (order_id, product_id, quantity, unit_price)
VALUES (?, ?, ?, ?)'
);
$insertLineItem->bind_param('iiid', $newOrderId, $productId, $orderQty, $priceEach);
$insertLineItem->execute();
$insertLineItem->close();
// ── Step 4: Decrement inventory ───────────────────────────────────────
$updateStock = $mysqli->prepare(
'UPDATE products SET stock_qty = stock_qty - ? WHERE product_id = ?'
);
$updateStock->bind_param('ii', $orderQty, $productId);
$updateStock->execute();
$updateStock->close();
// ── All steps succeeded — commit everything at once ────────────────────
$mysqli->commit();
echo "Order#{$newOrderId} placed successfully. "
. "£{$totalAmount} charged. Stock reduced by {$orderQty} units.\n";
} catch (RuntimeException $businessException) {
$mysqli->rollback();
echo"Order failed (business rule): " . $businessException->getMessage() . "\n";
} catch (mysqli_sql_exception $dbException) {
// mysqli_report() causes MySQLi errors to throw mysqli_sql_exception
$mysqli->rollback();
echo"Order failed (database error): " . $dbException->getMessage() . "\n";
} finally {
// The finally block always runs — clean up the connectionif (isset($mysqli)) {
$mysqli->close();
}
}
Output
Order #1048 placed successfully. £59.97 charged. Stock reduced by 3 units.
// If stock was insufficient:
Order failed (business rule): Only 2 units in stock, but 3 requested.
// If a DB error occurred (e.g. FK constraint):
Order failed (database error): Cannot add or update a child row: a foreign key constraint fails
Pro Tip: Always Call $statement->close() Between Queries in MySQLi
MySQLi has a limit on how many open prepared statements can exist on one connection. If you open a new prepare() while a previous statement's result is still open, you'll get a 'Commands out of sync' error. Always call $stmt->close() or $stmt->free_result() before preparing the next statement. PDO doesn't have this gotcha, which is another reason to prefer it for new projects.
Production Insight
The 'Commands out of sync' error in MySQLi is a classic production headache.
It happens when you forget to close a statement before preparing another on the same connection.
Rule: always close() prepared statements before the next prepare(), or use PDO.
Key Takeaway
MySQLi needs mysqli_report() to throw exceptions.
Close statements between queries to avoid sync errors.
Prefer PDO for new projects.
Savepoints: Partial Rollbacks for Complex Workflows
Most PHP developers only know about full rollbacks — if anything goes wrong, undo everything. But sometimes you need finer control: undo only the last part of a transaction while keeping the earlier steps intact. That's what savepoints are for.
Think of a savepoint as a bookmark inside a transaction. You can roll back to a bookmark without abandoning the whole transaction. This is invaluable in complex workflows like multi-step wizards, batch imports where you want to skip bad rows instead of aborting the entire batch, or when you're running optional sub-operations that shouldn't break the core logic if they fail.
Savepoints are part of the SQL standard and work in both PDO and MySQLi. You create one with SAVEPOINT name, roll back to it with ROLLBACK TO SAVEPOINT name, and release it with RELEASE SAVEPOINT name (which just removes the bookmark without undoing anything).
One important nuance: rolling back to a savepoint does NOT end the transaction. You're still inside the same transaction after the partial rollback. You still need to call commit() or rollBack() on the outer transaction when you're done.
savepoint_batch_import.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<?php
/**
* SavepointDemo: Importing a batch of product records.
* Valid rows are committed; individual bad rows are skipped via savepoint
* rollback without losing all the good rows we've already processed.
*/
$dsn = 'mysql:host=localhost;dbname=catalogue_db;charset=utf8mb4';
try {
$pdo = newPDO($dsn, 'root', 'secret', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// Simulated batch of products coming in from a CSV import or API feed
$productBatch = [
['sku' => 'BOOT-001', 'name' => 'Hiking Boot', 'price' => 89.99, 'stock' => 50],
['sku' => 'BOOT-002', 'name' => '', 'price' => 45.00, 'stock' => 20], // INVALID: empty name
['sku' => 'BOOT-003', 'name' => 'TrailSneaker', 'price' => -10.00, 'stock' => 15], // INVALID: negative price
['sku' => 'BOOT-004', 'name' => 'Running Shoe', 'price' => 65.50, 'stock' => 30],
];
$insertProduct = $pdo->prepare(
'INSERTINTOproducts (sku, name, price, stock_qty, created_at)
VALUES (:sku, :name, :price, :stock, NOW())'
);
$importedCount = 0;
$skippedRows = [];
// Start the outer transaction — wraps the entire batch import
$pdo->beginTransaction();
foreach ($productBatch as $rowIndex => $product) {
// ── Create a savepoint before attempting each row ──────────────────// If this specific row fails, we roll back ONLY to here,// preserving all successfully inserted rows before it.
$savepointName = 'sp_product_row_' . $rowIndex;
$pdo->exec("SAVEPOINT {$savepointName}");
try {
// Validate before hitting the DB — fail fastif (empty(trim($product['name']))) {
thrownewInvalidArgumentException("Row {$rowIndex}: Product name cannot be empty.");
}
if ($product['price'] <= 0) {
thrownewInvalidArgumentException("Row {$rowIndex}: Price must be positive, got {$product['price']}.");
}
$insertProduct->execute([
':sku' => $product['sku'],
':name' => $product['name'],
':price' => $product['price'],
':stock' => $product['stock'],
]);
// Row succeeded — release the savepoint (no longer needed)
$pdo->exec("RELEASE SAVEPOINT {$savepointName}");
$importedCount++;
echo" ✓ Imported: {$product['sku']} — {$product['name']}\n";
} catch (InvalidArgumentException | PDOException $rowException) {
// Roll back ONLY this row's changes, not the whole batch
$pdo->exec("ROLLBACK TO SAVEPOINT {$savepointName}");
$skippedRows[] = " ✗ Skipped {$product['sku']}: " . $rowException->getMessage();
echoend($skippedRows) . "\n";
}
}
// Commit all successfully inserted rows in one go
$pdo->commit();
echo"\nImport complete. {$importedCount} products saved.\n";
if (!empty($skippedRows)) {
echocount($skippedRows) . " rows skipped:\n";
echoimplode("\n", $skippedRows) . "\n";
}
} catch (PDOException $fatalException) {
// Something catastrophic failed (connection lost, etc.)if ($pdo->inTransaction()) {
$pdo->rollBack();
}
echo"Fatal import error: " . $fatalException->getMessage() . "\n";
}
Output
✓ Imported: BOOT-001 — Hiking Boot
✗ Skipped BOOT-002: Row 1: Product name cannot be empty.
✗ Skipped BOOT-003: Row 2: Price must be positive, got -10.
✓ Imported: BOOT-004 — Running Shoe
Import complete. 2 products saved.
2 rows skipped:
✗ Skipped BOOT-002: Row 1: Product name cannot be empty.
✗ Skipped BOOT-003: Row 2: Price must be positive, got -10.
Interview Gold: Nested Transactions Don't Exist in MySQL
MySQL doesn't support true nested transactions. If you call beginTransaction() while already inside a transaction, it silently issues a COMMIT first — ending your outer transaction before starting a new one. This is a notorious source of bugs in frameworks. Savepoints are the correct substitute for nested transaction behaviour. PDO's inTransaction() method lets you check whether you're already inside one before calling beginTransaction() again.
Production Insight
A long-running transaction with many savepoints can cause excessive undo log usage.
InnoDB tracks every change to support rollback — a 10,000-row batch import can fill the undo tablespace.
Rule: batch imports should commit every 500–1000 rows to reclaim undo space, even if using savepoints.
Key Takeaway
Savepoints give you partial rollback capability.
They do NOT end the transaction – you still need commit().
Release savepoints after success to reduce memory overhead.
Wrapping Transactions in a Reusable Helper
Copying try/catch/rollback blocks into every function that touches the database is a maintenance nightmare. The moment you forget the rollback in one place, you've introduced a subtle data-corruption bug. The professional solution is a single reusable transaction wrapper that handles the boilerplate for you.
The pattern is simple: accept a callable (a closure or function) and execute it inside a transaction. If the callable throws, rollback and re-throw. If it succeeds, commit. Your calling code stays clean and focused on business logic, not transaction plumbing.
This pattern also naturally composes with dependency injection and repository patterns. Your repository methods just execute queries — they don't manage transactions. The service layer wraps multiple repository calls in a transaction using the helper. Each layer has one responsibility, which is exactly how well-structured PHP applications are built.
transaction_helper.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
<?php
/**
* A production-ready transaction wrapper.
* Encapsulates all the boilerplate so your business logic stays clean.
* Returns whatever value your callback returns, so it's fully composable.
*/
classDatabase
{
privatePDO $pdo;
publicfunction__construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Execute a callable inside a database transaction.
*
* @template T
* @param callable(): T $operation Anycallable that performs DB work
* @return T Returns whatever the callable returns
* @throws ThrowableRe-throws any exception after rolling back
*/
publicfunctiontransactional(callable $operation): mixed
{
// Guard against nested beginTransaction() calls which would// silently commit the outer transaction in MySQL.
$weStartedTransaction = !$this->pdo->inTransaction();
if ($weStartedTransaction) {
$this->pdo->beginTransaction();
}
try {
$result = $operation($this->pdo); // Pass PDO in so the callable can use itif ($weStartedTransaction) {
$this->pdo->commit();
}
return $result;
} catch (Throwable $exception) {
// Catch Throwable (not just Exception) to also catch Errors// e.g. TypeError, which can happen with strict typed codeif ($weStartedTransaction && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
// Re-throw so the caller can handle or log the specific errorthrow $exception;
}
}
publicfunctiongetPdo(): PDO
{
return $this->pdo;
}
}
// ─── Example: Using the wrapper to register a new user ────────────────────────functionregisterNewUser(Database $db, string $email, string $hashedPassword, string $roleName): int
{
return $db->transactional(function (PDO $pdo) use ($email, $hashedPassword, $roleName): int {
// Insert the user account
$insertUser = $pdo->prepare(
'INSERT INTO users (email, password_hash, created_at) VALUES (:email, :hash, NOW())'
);
$insertUser->execute([':email' => $email, ':hash' => $hashedPassword]);
$newUserId = (int) $pdo->lastInsertId();
// Look up the role ID — throws PDOException if role doesn't exist
$findRole = $pdo->prepare('SELECT role_id FROM roles WHERE role_name = :name');
$findRole->execute([':name' => $roleName]);
$role = $findRole->fetch();
if (!$role) {
// Throwing here triggers the rollback in transactional()thrownewRuntimeException("Role '{$roleName}' does not exist.");
}
// Assign the role to the new user
$assignRole = $pdo->prepare(
'INSERT INTO user_roles (user_id, role_id) VALUES (:user_id, :role_id)'
);
$assignRole->execute([':user_id' => $newUserId, ':role_id' => $role['role_id']]);
return $newUserId; // Return the new user's ID to the caller
});
}
// ─── Bootstrapping ────────────────────────────────────────────────────────────
$pdo = newPDO('mysql:host=localhost;dbname=app_db;charset=utf8mb4', 'root', 'secret', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$database = newDatabase($pdo);
try {
$userId = registerNewUser(
$database,
'alice@example.com',
password_hash('securepassword', PASSWORD_BCRYPT),
'editor'
);
echo"User registered with ID: {$userId}\n";
} catch (RuntimeException $e) {
echo"Registration failed: " . $e->getMessage() . "\n";
} catch (PDOException $e) {
echo"Database error during registration: " . $e->getMessage() . "\n";
}
Output
User registered with ID: 237
// If 'editor' role doesn't exist in the roles table:
Registration failed: Role 'editor' does not exist.
// Both the user INSERT and role assignment are fully rolled back.
Pro Tip: Catch Throwable, Not Just Exception
In PHP 7+, catching Exception alone won't catch TypeError, Error, or DivisionByZeroError — all of which can occur in strictly-typed code during a transaction. Always catch Throwable in your rollback handler to ensure no error type can leave a transaction open and uncommitted. Then re-throw so your error logging layer still sees it.
Production Insight
Without a helper, one code path out of ten will forget the rollback.
I’ve seen it happen: a team had 12 transaction blocks, 3 of them omitted the rollback.
Rule: centralise transaction management in a single transactional() method to eliminate human error.
Key Takeaway
Use a reusable wrapper to reduce boilerplate.
Catch Throwable, not Exception.
Check inTransaction() before rolling back.
Transaction Isolation Levels in Practice
When two transactions run concurrently, isolation level determines what one can see of the other's uncommitted work. MySQL's default isolation is REPEATABLE READ, which prevents dirty reads and non-repeatable reads but allows phantom rows. You can change this with SET TRANSACTION ISOLATION LEVEL.
Why does this matter for PHP? Consider an inventory system: while you're running a transaction that reads stock levels and decrements inventory, another request might add stock. Under REPEATABLE READ, your transaction sees a snapshot of the stock as it was when the transaction started — it won't see the new stock added later. That's usually fine. But if you need to see the latest committed data for locking (e.g., FOR UPDATE with NOWAIT), you might want READ COMMITTED instead.
The most common production mistake is leaving the default isolation when a less strict one would cause phantom read anomalies. For example, in a reporting transaction that sums ledger entries across many rows, READ COMMITTED avoids locking overhead but may cause inconsistency if another transaction commits between reads. You have to choose: performance vs. accuracy.
isolation_level_example.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php
/**
* Demonstration of changing isolation level in PDO.
* Here we useREADCOMMITTED to allow seeing newly committed rows
* while still preventing dirty reads.
*/
namespaceIo\Thecodeforge\Transactions;
classTransactionManager
{
privatePDO $pdo;
publicfunction__construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Execute a callable with a specific isolation level.
* Resets to default after the operation.
*/
publicfunctionwithIsolationLevel(string $level, callable $operation): mixed
{
$defaultLevel = $this->getCurrentIsolation();
$this->pdo->exec("SET TRANSACTION ISOLATION LEVEL {$level}");
$result = $operation($this->pdo);
// Restore default (usually REPEATABLE READ)
$this->pdo->exec("SET TRANSACTION ISOLATION LEVEL {$defaultLevel}");
return $result;
}
privatefunctiongetCurrentIsolation(): string
{
$stmt = $this->pdo->query("SELECT @@transaction_isolation");
return $stmt->fetchColumn();
}
}
// Usage:
$manager = newTransactionManager($pdo);
$manager->withIsolationLevel('READ COMMITTED', function (PDO $pdo) {
$pdo->beginTransaction();
// perform reads that should see latest commits
$pdo->commit();
});
Output
// No output – this is a pattern example.
// The isolation level change is transparent to the caller.
Isolation Changes Require a Transaction Restart
MySQL does not let you change the isolation level in the middle of an active transaction. You must set the level before calling beginTransaction() or start a new transaction. If you try to exec() a SET TRANSACTION ISOLATION LEVEL inside a transaction, you'll get an error: 'Cannot change isolation level during a transaction'.
Production Insight
Using REPEATABLE READ for long-running reports can cause excessive MVCC overhead.
InnoDB keeps rollback segments to satisfy snapshot consistency — a 5-minute report can bloat undo tablespace.
Rule: for read-only reporting, switch to READ COMMITTED to reduce undo retention and improve concurrent write performance.
Key Takeaway
Choose isolation level based on your consistency needs.
Set it before beginning the transaction.
REPEATABLE READ is safe but heavy; READ COMMITTED is lighter.
Real-World Production Pattern: Order Fulfillment with Savepoints
Let's tie everything together with a realistic example: an e-commerce order fulfillment flow that handles payment, inventory, shipping, and notifications — each with its own failure modes. We'll use a reusable transactional wrapper, savepoints for optional steps (like sending an email), and proper error isolation.
The flow: 1. Start a transaction. 2. Insert the order (mandatory). 3. Process payment via a third-party API (optional – if it fails, we still want the order saved with 'pending_payment' status). 4. Reduce inventory (mandatory – must be atomic with order). 5. Send confirmation email (optional – savepoint, failure just logs).
This pattern shows how to keep the core order creation atomic while allowing graceful degradation of downstream steps.
order_fulfillment.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<?php
/**
* Order fulfillment with strategic savepoints.
* Core transaction: order + inventory (atomic).
* Optional steps: payment, email (each wrapped in savepoints).
*/
namespaceIo\Thecodeforge\Orders;
usePDO;
useRuntimeException;
useThrowable;
classOrderFulfillmentService
{
privatePDO $pdo;
publicfunction__construct(PDO $pdo)
{
$this->pdo = $pdo;
}
publicfunctionplaceOrder(array $cart, string $customerId, string $paymentToken): int
{
$this->pdo->beginTransaction();
try {
// 1. Create the order record
$orderId = $this->createOrder($customerId, $cart);
// 2. Reduce inventory for each item (mandatory)foreach ($cart as $item) {
$this->decrementStock($item['productId'], $item['quantity']);
}
// 3. Process payment (optional – if fails, order saved as pending)
$this->pdo->exec('SAVEPOINT sp_payment');
try {
$this->processPayment($orderId, $paymentToken);
$this->pdo->exec('RELEASE SAVEPOINT sp_payment');
} catch (Throwable $paymentError) {
$this->pdo->exec('ROLLBACK TO SAVEPOINT sp_payment');
// Mark order as pending payment – do not rethrow
$this->updateOrderStatus($orderId, 'pending_payment');
// Log the payment failureerror_log("Payment failed for order {$orderId}: " . $paymentError->getMessage());
}
// 4. Send confirmation email (optional, failure ignored)
$this->pdo->exec('SAVEPOINT sp_email');
try {
$this->sendConfirmationEmail($orderId, $customerId);
$this->pdo->exec('RELEASE SAVEPOINT sp_email');
} catch (Throwable $emailError) {
$this->pdo->exec('ROLLBACK TO SAVEPOINT sp_email');
error_log("Email failed for order {$orderId}: " . $emailError->getMessage());
}
$this->pdo->commit();
return $orderId;
} catch (Throwable $fatal) {
$this->pdo->rollBack();
throw $fatal;
}
}
privatefunctioncreateOrder(string $customerId, array $cart): int
{
// ... insert order logic ...return123;
}
privatefunctiondecrementStock(int $productId, int $qty): void { /* ... */ }
privatefunctionprocessPayment(int $orderId, string $token): void { /* ... */ }
privatefunctionupdateOrderStatus(int $orderId, string $status): void { /* ... */ }
privatefunctionsendConfirmationEmail(int $orderId, string $customerId): void { /* ... */ }
}
Output
// Order 123 placed successfully. Payment processed, email sent.
// Or if payment fails:
// Order 123 placed as pending_payment. Email sent.
The Core vs. Optional Mental Model
Mandatory steps: must succeed for the business operation to be valid. Keep them before any savepoints.
Optional steps: can fail without affecting business integrity. Wrap each in its own savepoint.
If a savepoint step fails, roll back to that savepoint, handle the failure (e.g., mark pending), and continue.
This prevents one flaky step (email, third-party API) from rolling back the entire order.
Production Insight
Third-party payment APIs are the most common source of false failures in order flows.
A network timeout during payment should not orphan an order with non-existent inventory adjustments.
Rule: separate payment processing from the core inventory transaction using savepoints or an async queue.
Key Takeaway
Use savepoints to isolate optional steps.
A flaky email should never cancel a completed order.
Log, don't throw, for non-critical failures.
● Production incidentPOST-MORTEMseverity: high
The Phantom Order: When a Missing Rollback Double-Charged Users
Symptom
Users reported being charged twice for the same order. Support tickets flooded in. The orders table showed one record, but the payments table had two entries with the same order_id.
Assumption
The team assumed that if a PDO exception was thrown, the transaction would automatically roll back. They didn't check inTransaction() before rolling back.
Root cause
The catch block caught PDOException but called $pdo->rollBack() without checking if a transaction was active. Because the exception occurred after the first INSERT succeeded (order record), MySQL had already committed that INSERT implicitly when the connection was lost. The rollBack() call threw a new exception, leaving the payment INSERT committed. The next request in the same session started fresh, but the payment was already saved.
Fix
Add explicit checks before rollback: if ($pdo->inTransaction()) { $pdo->rollBack(); }. Also set PDO::ATTR_ERRMODE to EXCEPTION and use a single try/catch wrapping the entire transaction.
Key lesson
Rollbacks must be guarded with inTransaction() – otherwise the catch block itself can throw.
Never assume that a failed query automatically reverses previous work in the same transaction.
Always log the state of the transaction when an error occurs to aid debugging.
Production debug guideSymptom → Action guide for the three most common transaction production issues4 entries
Symptom · 01
Data partially saved even though transaction block has rollBack()
→
Fix
Check if the table uses InnoDB engine: SHOW CREATE TABLE \table\;. If MyISAM, transactions are silently ignored.
Symptom · 02
PHP throws 'There is no active transaction' when calling rollBack()
→
Fix
Add guard: if ($pdo->inTransaction()) { $pdo->rollBack(); }. The transaction may have already been rolled back by a previous error.
Symptom · 03
Deadlock detected when trying to commit or roll back
→
Fix
Check which transactions are blocking: SHOW ENGINE INNODB STATUS;. Then kill the blocking process: KILL <thread_id>;.
Symptom · 04
Transaction seems to commit partially after script timeout
→
Fix
Increase max_execution_time or use set_time_limit(0) for long operations. A timeout causes PHP to die mid-transaction, and MySQL may auto-commit the partial work if autocommit was re-enabled.
★ Transaction Cheat Sheet for PHPQuick commands to diagnose and fix transaction state issues during local development or production incidents.
Transaction not committing – queries appear to do nothing−
Immediate action
Check if you forgot to call commit() or if an exception was caught without rollback.
Call $pdo->commit() or $pdo->rollBack() based on business logic.
Deadlock error 1213 when running transaction+
Immediate action
Identify the blocked transaction and kill it, then redesign query order.
Commands
SHOW ENGINE INNODB STATUS\G
SELECT trx_id, trx_state, trx_mysql_thread_id FROM information_schema.INNODB_TRX WHERE trx_state = 'RUNNING';
Fix now
KILL <thr_id>; then reorder UPDATE statements to always access tables in the same sequence.
Savepoint rollback doesn't undo changes+
Immediate action
Verify savepoint name is unique and not already released.
Commands
ROLLBACK TO SAVEPOINT sp_name; -- check if error about unknown savepoint
SELECT * FROM performance_schema.events_statements_current WHERE THREAD_ID = CONNECTION_ID();
Fix now
Use unique savepoint names per row (e.g., sp_row_{$rowIndex}) and release them after success.
PDO vs MySQLi Transaction Methods
Feature / Aspect
PDO
MySQLi
Database support
MySQL, PostgreSQL, SQLite, MSSQL and more
MySQL / MariaDB only
Exception mode setup
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION in constructor options
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT) before connecting
Start transaction
$pdo->beginTransaction()
$mysqli->begin_transaction()
Commit
$pdo->commit()
$mysqli->commit()
Rollback
$pdo->rollBack()
$mysqli->rollback()
Check if in transaction
$pdo->inTransaction() — returns bool
No built-in equivalent — must track manually
Savepoints
Via $pdo->exec('SAVEPOINT name')
Via $mysqli->savepoint('name') (PHP 8.2+) or exec()
Named parameters
Yes — :param_name syntax
No — positional ? only
Recommended for
New projects, multi-DB portability, cleaner API
Legacy codebases, WordPress-adjacent projects
Key takeaways
1
A transaction is an all-or-nothing guarantee
either every query in the group is committed, or none of them are. No partial saves, no corrupted state.
2
Always enable exception mode (PDO::ERRMODE_EXCEPTION or mysqli_report()) before using transactions
without it, a failed query won't trigger your catch block and you'll commit broken data.
3
MySQL transactions only work on InnoDB tables. MyISAM silently ignores BEGIN/COMMIT/ROLLBACK
always verify your engine with SHOW CREATE TABLE.
4
Wrap transactions in a reusable transactional() helper method to eliminate copy-pasted boilerplate and ensure rollbacks always fire correctly, even for PHP Errors (use Throwable, not Exception).
5
Use savepoints to isolate optional steps (emails, third-party calls) from the core atomic operation
a flaky external service should never roll back a completed order.
Common mistakes to avoid
5 patterns
×
Not enabling exceptions in PDO or MySQLi
Symptom
A query silently fails mid-transaction and the script calls commit(), saving a partially written, corrupted state with no error shown.
Fix
Set PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION in your PDO constructor options array, and call mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT) before creating any MySQLi connection.
×
Calling rollBack() outside a transaction
Symptom
A PDOException is thrown saying 'There is no active transaction', usually happening in error handlers that fire after a connection issue (which may have auto-rolled back the transaction).
Fix
Always guard rollback calls with if ($pdo->inTransaction()) { $pdo->rollBack(); }. This one check prevents your catch block itself from crashing.
×
Using MyISAM tables and wondering why transactions don't work
Symptom
Data from a failed multi-step operation is partially saved even though your PHP code has correct beginTransaction()/rollBack() logic. No error is thrown.
Fix
Run SHOW CREATE TABLE your_table_name and check the ENGINE value. If it says ENGINE=MyISAM, convert with ALTER TABLE your_table_name ENGINE=InnoDB.
×
Nesting beginTransaction() calls without checking inTransaction()
Symptom
The inner beginTransaction() silently commits the outer transaction, causing partial data to be saved prematurely.
Fix
Check $pdo->inTransaction() before calling beginTransaction() inside a method that might already be inside a transaction. The helper pattern in this article handles this automatically.
×
Not releasing savepoints after success
Symptom
Accumulation of unreleased savepoints consumes memory and can cause 'Too many savepoints' errors after thousands of operations.
Fix
Always call RELEASE SAVEPOINT after a successful savepoint step. Alternatively, use a unique naming scheme and let the transaction end at commit – but releasing is cleaner.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What are the ACID properties, and can you give a concrete real-world exa...
Q02SENIOR
If you call $pdo->beginTransaction() while you're already inside an acti...
Q03SENIOR
What is the difference between ROLLBACK and ROLLBACK TO SAVEPOINT, and d...
Q04SENIOR
How does MySQL's default isolation level REPEATABLE READ affect concurre...
Q01 of 04JUNIOR
What are the ACID properties, and can you give a concrete real-world example of what breaks if Atomicity is violated in a PHP application?
ANSWER
ACID stands for Atomicity, Consistency, Isolation, Durability. Atomicity ensures that either all queries in a transaction succeed or none do. If Atomicity is violated, you get partial data. For example, if an order insert succeeds but the payment insert fails without a rollback, you have an unpaid order that depletes inventory. The customer never gets charged but the item is gone from stock. That's exactly what transactions prevent.
Q02 of 04SENIOR
If you call $pdo->beginTransaction() while you're already inside an active transaction in MySQL, what happens — and how would you guard against it in a reusable database helper class?
ANSWER
In MySQL, calling beginTransaction() inside an active transaction silently commits the outer transaction first, then starts a new one. This is a common source of subtle bugs. To guard against it, check $pdo->inTransaction() before calling beginTransaction(). In a reusable helper, use a flag to track whether the helper started the transaction: if the helper didn't start it, it shouldn't commit or rollback either.
Q03 of 04SENIOR
What is the difference between ROLLBACK and ROLLBACK TO SAVEPOINT, and describe a scenario in a PHP batch processing job where savepoints would be preferable to a full rollback?
ANSWER
ROLLBACK ends the entire transaction and undoes all changes. ROLLBACK TO SAVEPOINT undoes only changes made after the savepoint, keeping earlier changes intact. In a batch job that imports 1000 rows, if row 50 fails, using a full rollback would lose the 49 successful rows. Using savepoints, you rollback to the savepoint just before row 50, skip the bad row, and continue. At the end, you commit the 999 good rows. This is much more efficient than reloading the entire batch.
Q04 of 04SENIOR
How does MySQL's default isolation level REPEATABLE READ affect concurrent inventory updates in a PHP e-commerce application?
ANSWER
REPEATABLE READ gives each transaction a snapshot of the data as of the start of the transaction. If transaction A reads stock = 10 and then transaction B reduces stock to 8 and commits, transaction A still sees stock = 10. This is fine if transaction A then does SELECT ... FOR UPDATE, because the locking read will see the latest data. But if transaction A does a plain SELECT for the stock check and then calculates an update based on the old value, it can oversell. The fix is to either use FOR UPDATE for the stock check, or use READ COMMITTED isolation and carefully handle lost updates with optimistic locking.
01
What are the ACID properties, and can you give a concrete real-world example of what breaks if Atomicity is violated in a PHP application?
JUNIOR
02
If you call $pdo->beginTransaction() while you're already inside an active transaction in MySQL, what happens — and how would you guard against it in a reusable database helper class?
SENIOR
03
What is the difference between ROLLBACK and ROLLBACK TO SAVEPOINT, and describe a scenario in a PHP batch processing job where savepoints would be preferable to a full rollback?
SENIOR
04
How does MySQL's default isolation level REPEATABLE READ affect concurrent inventory updates in a PHP e-commerce application?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
Does PHP automatically roll back an open transaction if the script ends without committing?
Yes — when a PDO or MySQLi connection is closed (either explicitly or when the script ends and PHP cleans up), any uncommitted transaction is automatically rolled back by MySQL. However, you should never rely on this behaviour as a safety net. Always explicitly call rollBack() in your catch blocks so the rollback happens immediately when an error occurs, not at the end of the script when it may be too late to log or handle the error properly.
Was this helpful?
02
Can I use transactions across multiple tables in MySQL?
Yes, absolutely — and this is one of the main reasons transactions exist. As long as all the tables involved are InnoDB, a single transaction can span INSERT, UPDATE, and DELETE statements across as many tables as you need. If any statement fails, the entire set of changes across all tables is rolled back. This is exactly how the fund transfer and order placement examples in this article work.
Was this helpful?
03
What is the difference between $pdo->rollBack() and simply not calling $pdo->commit()?
In practice, not calling commit() and calling rollBack() achieve the same result when the script finishes — the transaction is reversed. But calling rollBack() explicitly is strongly preferred because it releases the row locks immediately. Without an explicit rollBack(), your locked rows stay locked until the connection closes, which can cause other concurrent requests to stall or time out waiting for those locks — a serious performance issue under load.
Was this helpful?
04
What isolation level should I use for a reporting PHP script that runs alongside live transactions?
Usually READ COMMITTED is best for reporting. It prevents dirty reads and still allows you to see the latest committed data. REPEATABLE READ would give you a consistent snapshot but adds overhead and can cause longer locks. For a read-only report that doesn't hold locks (no FOR UPDATE), READ COMMITTED is the standard choice. Avoid SERIALIZABLE unless you absolutely need to prevent phantoms, because it can cause deadlocks much more easily.