Database Transactions in PHP: ACID, Rollbacks & Real-World Patterns
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.
<?php /** * ACID Demo: Transferring funds between two bank accounts. * If ANY 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 = new PDO($dsn, $dbUser, $dbPassword, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // Throw exceptions on error PDO::ATTR_EMULATE_PREPARES => false, // Use real prepared statements PDO::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) { throw new RuntimeException("Sender account {$senderAccountId} not found."); } if ($senderAccount['balance'] < $transferAmount) { throw new RuntimeException( "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( 'INSERT INTO transfer_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 transaction if ($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"; }
// 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
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.
<?php /** * MySQLi Transaction: Creating an order and reducing inventory simultaneously. * Uses mysqli_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 = new mysqli('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 connection if ($currentStock === null) { throw new RuntimeException("Product ID {$productId} does not exist."); } if ($currentStock < $orderQty) { throw new RuntimeException( "Only {$currentStock} units in stock, but {$orderQty} requested." ); } // ── Step 2: Insert the order record ─────────────────────────────────── $insertOrder = $mysqli->prepare( 'INSERT INTO orders (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( 'INSERT INTO order_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 connection if (isset($mysqli)) { $mysqli->close(); } }
// 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
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.
<?php /** * Savepoint Demo: 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 = new PDO($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' => 'Trail Sneaker', 'price' => -10.00, 'stock' => 15], // INVALID: negative price ['sku' => 'BOOT-004', 'name' => 'Running Shoe', 'price' => 65.50, 'stock' => 30], ]; $insertProduct = $pdo->prepare( 'INSERT INTO products (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 fast if (empty(trim($product['name']))) { throw new InvalidArgumentException("Row {$rowIndex}: Product name cannot be empty."); } if ($product['price'] <= 0) { throw new InvalidArgumentException("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(); echo end($skippedRows) . "\n"; } } // Commit all successfully inserted rows in one go $pdo->commit(); echo "\nImport complete. {$importedCount} products saved.\n"; if (!empty($skippedRows)) { echo count($skippedRows) . " rows skipped:\n"; echo implode("\n", $skippedRows) . "\n"; } } catch (PDOException $fatalException) { // Something catastrophic failed (connection lost, etc.) if ($pdo->inTransaction()) { $pdo->rollBack(); } echo "Fatal import error: " . $fatalException->getMessage() . "\n"; }
✗ 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.
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.
<?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. */ class Database { private PDO $pdo; public function __construct(PDO $pdo) { $this->pdo = $pdo; } /** * Execute a callable inside a database transaction. * * @template T * @param callable(): T $operation Any callable that performs DB work * @return T Returns whatever the callable returns * @throws Throwable Re-throws any exception after rolling back */ public function transactional(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 it if ($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 code if ($weStartedTransaction && $this->pdo->inTransaction()) { $this->pdo->rollBack(); } // Re-throw so the caller can handle or log the specific error throw $exception; } } public function getPdo(): PDO { return $this->pdo; } } // ─── Example: Using the wrapper to register a new user ──────────────────────── function registerNewUser(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() throw new RuntimeException("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 = new PDO('mysql:host=localhost;dbname=app_db;charset=utf8mb4', 'root', 'secret', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => false, ]); $database = new Database($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"; }
// 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.
| 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
- 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.
- 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.
- MySQL transactions only work on InnoDB tables. MyISAM silently ignores BEGIN/COMMIT/ROLLBACK — always verify your engine with SHOW CREATE TABLE.
- 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).
⚠ Common Mistakes to Avoid
- ✕Mistake 1: 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. Without this, failed queries return false instead of throwing — your try/catch never fires.
- ✕Mistake 2: 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.
- ✕Mistake 3: 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. MyISAM ignores all transaction commands completely and silently — this is one of the most confusing bugs a PHP developer can face.
Interview Questions on This Topic
- QWhat are the ACID properties, and can you give a concrete real-world example of what breaks if Atomicity is violated in a PHP application?
- QIf 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?
- QWhat 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?
Frequently Asked Questions
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.
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.
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.