Home PHP Database Transactions in PHP: ACID, Rollbacks & Real-World Patterns

Database Transactions in PHP: ACID, Rollbacks & Real-World Patterns

In Plain English 🔥
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.
⚡ Quick Answer
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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
<?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";
}
▶ 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 OnlyMySQL 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;`.

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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
<?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();
    }
}
▶ 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 MySQLiMySQLi 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.

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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
<?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";
}
▶ 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 MySQLMySQL 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.

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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
<?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";
}
▶ 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 ExceptionIn 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.
Feature / AspectPDOMySQLi
Database supportMySQL, PostgreSQL, SQLite, MSSQL and moreMySQL / MariaDB only
Exception mode setupPDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION in constructor optionsmysqli_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 boolNo built-in equivalent — must track manually
SavepointsVia $pdo->exec('SAVEPOINT name')Via $mysqli->savepoint('name') (PHP 8.2+) or exec()
Named parametersYes — :param_name syntaxNo — positional ? only
Recommended forNew projects, multi-DB portability, cleaner APILegacy 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.

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousPHP Fibers — Async PHPNext →Laravel Testing with PHPUnit
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged