PDO Transactions

Su Subhalakshmi Chandran Updated 21 Oct 2026
6 min read ·Lesson 176 of 185
In this post

What Is a Transaction? In simple terms: Some operations consist of several steps that must all happen — or none of them. Placing an order reduces the stock of each product, creates the order, and adds

What Is a Transaction?

In simple terms: Some operations consist of several steps that must all happen — or none of them. Placing an order reduces the stock of each product, creates the order, and adds its items. If the third step fails, the first two must be undone; otherwise, stock disappears for an order that does not exist. A transaction groups such steps into one unit: either everything is saved (commit), or everything is undone (rollback).

Technically: A database transaction is a sequence of statements executed as a single unit with the ACID guarantees. PDO controls transactions with beginTransaction(), commit(), rollBack(), and inTransaction(). In MySQL, transactions require the InnoDB storage engine (the default).

PropertyMeaning
AtomicityAll statements succeed together, or none take effect
ConsistencyConstraints (foreign keys, unique keys) hold before and after
IsolationConcurrent transactions do not see each other's unfinished changes
DurabilityCommitted changes survive crashes

The Basic Pattern

<?php
$pdo->beginTransaction();

try {
    // several INSERT / UPDATE / DELETE statements
    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $e;
}

How It Works

  1. beginTransaction() turns off auto-commit: changes are not yet permanent.
  2. If every statement succeeds, commit() saves them all at once.
  3. If anything throws — a database error or a business rule violation in PHP — the catch block undoes all changes since beginTransaction().
  4. The exception is re-thrown, so the caller still learns that the operation failed.
  5. inTransaction() avoids calling rollBack() when no transaction is active (for example, if beginTransaction() itself failed).

Practical Example: Placing an Order

<?php
require __DIR__ . '/src/pdo.php';

final class InsufficientStockException extends RuntimeException {}

function placeOrder(PDO $pdo, int $customerId, array $items): int
{
    $pdo->beginTransaction();

    try {
        $lookup = $pdo->prepare('SELECT id, price, stock FROM products WHERE sku = ? FOR UPDATE');
        $reduceStock = $pdo->prepare('UPDATE products SET stock = stock - ? WHERE id = ?');

        $lines = [];
        $totalPaise = 0;

        foreach ($items as $sku => $quantity) {
            $lookup->execute([$sku]);
            $product = $lookup->fetch();

            if ($product === false) {
                throw new InvalidArgumentException("Unknown product $sku");
            }
            if ($product['stock'] < $quantity) {
                throw new InsufficientStockException("Not enough stock for $sku ({$product['stock']} available)");
            }

            $reduceStock->execute([$quantity, $product['id']]);

            $lines[] = [$product['id'], $quantity, $product['price']];
            $totalPaise += (int) round((float) $product['price'] * 100) * $quantity;
        }

        $pdo->prepare('INSERT INTO orders (customer_id, status, total) VALUES (?, ?, ?)')
            ->execute([$customerId, 'pending', number_format($totalPaise / 100, 2, '.', '')]);
        $orderId = (int) $pdo->lastInsertId();

        $insertItem = $pdo->prepare('INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)');
        foreach ($lines as [$productId, $quantity, $unitPrice]) {
            $insertItem->execute([$orderId, $productId, $quantity, $unitPrice]);
        }

        $pdo->commit();
        return $orderId;
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }
}

$pdo = pdo();

$orderId = placeOrder($pdo, 2, ['PEN-01' => 2, 'NB-A5' => 1]);
$total = $pdo->query("SELECT total FROM orders WHERE id = $orderId")->fetchColumn();
echo "Order placed for customer 2: ₹$total\n";

try {
    placeOrder($pdo, 3, ['LAMP-01' => 1, 'BAG-02' => 1]);
} catch (InsufficientStockException $e) {
    echo "Order failed: " . $e->getMessage() . "\n";
}

$lampStock = $pdo->query("SELECT stock FROM products WHERE sku = 'LAMP-01'")->fetchColumn();
echo "Desk Lamp stock after the failed order: $lampStock\n";

Output

Order placed for customer 2: ₹100.00
Order failed: Not enough stock for BAG-02 (0 available)
Desk Lamp stock after the failed order: 6

Step-by-Step Explanation

  1. Locking: SELECT ... FOR UPDATE reads each product and locks its row until the transaction ends. Another request trying to buy the same product waits, so two customers cannot both buy the last unit.
  2. Validation inside the transaction: stock is checked on the locked, current values.
  3. First order: 2 Gel Pens (2 × ₹20) and 1 Notebook (₹60) — stock is reduced, the order and its items are inserted, and commit() saves everything: total ₹100.00.
  4. Second order: the Desk Lamp's stock is reduced from 6 to 5 — but then the Backpack has no stock. The exception triggers rollBack(), which also undoes the Desk Lamp's reduction. Its stock is still 6.
  5. Calculations use integer paise to avoid floating-point rounding errors; the total is stored as a DECIMAL string.
  6. $orderId comes from lastInsertId() and is cast to int, so inserting it directly into the follow-up SELECT is safe here; values from users would still require placeholders.

Without the transaction, the failed order would have left the Desk Lamp's stock permanently reduced.


Things to Know About Transactions

No Nested Transactions

Calling beginTransaction() while a transaction is active throws a PDOException ("There is already an active transaction"). Structure code so that one outer layer (for example, a service method) owns the transaction, and inner functions run inside it without starting their own. MySQL savepoints (SAVEPOINT name / ROLLBACK TO name) can roll back part of a transaction, executed as plain SQL statements.

Implicit Commits

Some statements commit the current transaction automatically in MySQL — notably DDL such as CREATE TABLE, ALTER TABLE, DROP TABLE, and TRUNCATE. Never mix schema changes with data changes inside one transaction expecting a rollback to undo them.

Keep Transactions Short

Locked rows block other requests until commit() or rollBack(). Do not perform slow work inside a transaction:

Avoid Inside a TransactionDo Instead
HTTP calls to payment gateways or APIsCall them before or after, and record the result
Sending emailsSend after a successful commit (ideally via a queue)
Waiting for user inputNever hold transactions across requests

Isolation Level

InnoDB's default isolation level is REPEATABLE READ: within a transaction, repeated reads of the same rows return the same data. Explicit locks (FOR UPDATE, or FOR SHARE for reading without allowing changes) give precise control where it matters.


Handling Deadlocks

When two transactions each wait for a row locked by the other, MySQL detects a deadlock, aborts one of them (error 1213), and lets the other continue. The aborted transaction can simply be retried:

<?php
function withDeadlockRetry(callable $operation, int $maxAttempts = 3): mixed
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $operation();
        } catch (PDOException $e) {
            $isDeadlock = ($e->errorInfo[1] ?? null) === 1213;

            if (!$isDeadlock || $attempt >= $maxAttempts) {
                throw $e;
            }

            usleep(random_int(50, 200) * 1000);   // short random pause before retrying
        }
    }
}

// $orderId = withDeadlockRetry(fn() => placeOrder($pdo, 2, ['PEN-01' => 1]));

errorInfo[1] contains MySQL's own error code (see PDO Error Handling). Because placeOrder() rolls back on failure, retrying it is safe. Accessing rows in a consistent order (for example, sorted by product ID) reduces the chance of deadlocks.


Common Mistakes

Forgetting to Roll Back

Effect: The transaction stays open; locks are held until the connection closes, and later statements run inside the failed transaction.

Fix: Always roll back in a catch (or finally) block.

Checking Stock Before the Transaction

Effect: Overselling: the stock can change between the check and the update.

Fix: Lock and check inside the transaction (FOR UPDATE), or use conditional updates (see MySQL Update Data).

Swallowing the Exception After Rollback

Effect: The caller believes the operation succeeded.

Fix: Re-throw, or return a clear failure result.

Long-Running Transactions

Effect: Other requests wait or time out (error 1205, lock wait timeout).

Fix: Keep transactions short; move external calls outside.

Using MyISAM Tables

Effect: No transaction support — rollbacks silently do nothing.

Fix: Use InnoDB.


Best Practices

  • Wrap every multi-step change that must be all-or-nothing in a transaction.
  • Use the beginTransaction() / commit() / rollBack() pattern with try/catch.
  • Lock rows you read for decisions with SELECT ... FOR UPDATE.
  • Keep transactions short, without external calls or emails inside.
  • Retry transactions aborted by deadlocks a limited number of times.

Official reference: https://www.php.net/manual/en/pdo.transactions.php

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning MySQL Database MySQL Connect MySQL Create DB MySQL Create Table MySQL Insert Data MySQL Get Last ID MySQL Insert Multiple MySQL Prepared MySQL Select Data MySQL Where Debugging PHP Project: Image Upload Gallery $_SESSION $_ENV PHP RegEx RegEx Patterns Heredoc and Nowdoc RegEx Modifiers preg_match preg_replace preg_split PHP RegEx Functions Nullable Types Mixed and Never Types Nullsafe Operator PHP Attributes PHP Fibers Readonly Classes Property Hooks (PHP 8.4) Asymmetric Visibility (PHP 8.4) Reflection API SPL Data Structures Composer PHP Form Handling PDO Transactions PDO Error Handling PHP XML Parsers PHP SimpleXML Parser PHP SimpleXML - Get PHP XML Expat Parser PHP DOM Parser PHP XMLReader PHP XMLWriter AJAX Intro
Ask about this post
AI Ask about this post

Ask questions about PDO Transactions and get answers drawn from it.

Signed-in readers only.