Senior 5 min · March 06, 2026

PHP Transactions — The inTransaction() Guard You're Missing

A missing inTransaction() check caused double-charges in production.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • A database transaction groups multiple queries into one atomic unit.
  • ACID guarantees: Atomicity, Consistency, Isolation, Durability.
  • PDO: beginTransaction(), commit(), rollBack().
  • MySQLi: begin_transaction(), commit(), rollback().
  • 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
/**
 * 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 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
/**
 * 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 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
/**
 * 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 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.
 */

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 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 use READ COMMITTED to allow seeing newly committed rows
 * while still preventing dirty reads.
 */

namespace Io\Thecodeforge\Transactions;

class TransactionManager
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    /**
     * Execute a callable with a specific isolation level.
     * Resets to default after the operation.
     */
    public function withIsolationLevel(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;
    }

    private function getCurrentIsolation(): string
    {
        $stmt = $this->pdo->query("SELECT @@transaction_isolation");
        return $stmt->fetchColumn();
    }
}

// Usage:
$manager = new TransactionManager($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).
 */

namespace Io\Thecodeforge\Orders;

use PDO;
use RuntimeException;
use Throwable;

class OrderFulfillmentService
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function placeOrder(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 failure
                error_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;
        }
    }

    private function createOrder(string $customerId, array $cart): int
    {
        // ... insert order logic ...
        return 123;
    }

    private function decrementStock(int $productId, int $qty): void { /* ... */ }
    private function processPayment(int $orderId, string $token): void { /* ... */ }
    private function updateOrderStatus(int $orderId, string $status): void { /* ... */ }
    private function sendConfirmationEmail(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.
Commands
echo 'Transaction active: ' . ($pdo->inTransaction() ? 'yes' : 'no') . PHP_EOL;
SELECT * FROM information_schema.INNODB_TRX\G
Fix now
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 / 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

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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Does PHP automatically roll back an open transaction if the script ends without committing?
02
Can I use transactions across multiple tables in MySQL?
03
What is the difference between $pdo->rollBack() and simply not calling $pdo->commit()?
04
What isolation level should I use for a reporting PHP script that runs alongside live transactions?
🔥

That's PHP & MySQL. Mark it forged?

5 min read · try the examples if you haven't

Previous
PHP and MongoDB
6 / 6 · PHP & MySQL
Next
Introduction to Laravel