PHP PDO Advanced

Transactions, savepoints, batch inserts, stored procedures, lazy generator-backed cursors - the PDO techniques that separate beginners from senior PHP developers.

Advanced 9 min read 10 examples

Transactions

Group multiple statements so they all succeed or all roll back as a unit:

PHP
<?php
try {
    $pdo->beginTransaction();

    $pdo->prepare("UPDATE accounts SET balance = balance - ? WHERE id = ?")
        ->execute([100, $fromId]);

    $pdo->prepare("UPDATE accounts SET balance = balance + ? WHERE id = ?")
        ->execute([100, $toId]);

    $pdo->prepare("INSERT INTO transfers (from_id, to_id, amount) VALUES (?, ?, ?)")
        ->execute([$fromId, $toId, 100]);

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

If a statement fatal-errored, the transaction may already have rolled back. Calling rollBack() on a closed transaction throws another error, hiding the original. inTransaction() guards against this.

Savepoints

For partial rollbacks inside a larger transaction:

PHP
<?php
$pdo->beginTransaction();

try {
    saveOrder($pdo, $order);

    $pdo->exec("SAVEPOINT before_refund");
    try {
        applyRefund($pdo, $order);
    } catch (RefundException $e) {
        // Roll back just the refund - keep the order saved
        $pdo->exec("ROLLBACK TO SAVEPOINT before_refund");
        notifyOps($e);
    }

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

Batch Inserts (Prepared, Looped)

Prepare once, execute many - inside a transaction:

PHP
<?php
$stmt = $pdo->prepare(
    "INSERT INTO log_entries (level, message, created_at)
     VALUES (?, ?, NOW())"
);

$pdo->beginTransaction();
try {
    foreach ($events as $event) {
        $stmt->execute([$event->level, $event->message]);
    }
    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}
// 10-100x faster than auto-committing each insert

Multi-Row INSERT

Even faster for large batches - one INSERT with many value rows:

PHP
<?php
function bulkInsert(PDO $pdo, string $table, array $rows): int {
    if (!$rows) return 0;

    $columns = array_keys($rows[0]);
    $colList = "(" . implode(",", $columns) . ")";

    // Build "(?,?,?), (?,?,?), ..." with one tuple per row
    $tuple   = "(" . implode(",", array_fill(0, count($columns), "?")) . ")";
    $tuples  = implode(",", array_fill(0, count($rows), $tuple));

    $sql = "INSERT INTO $table $colList VALUES $tuples";

    // Flatten all values in row-major order
    $values = [];
    foreach ($rows as $row) {
        foreach ($columns as $c) $values[] = $row[$c];
    }

    $stmt = $pdo->prepare($sql);
    $stmt->execute($values);
    return $stmt->rowCount();
}

bulkInsert($pdo, "users", [
    ["name" => "Alice", "email" => "a@x.com"],
    ["name" => "Bob",   "email" => "b@x.com"],
]);

Stored Procedures

SQL
DELIMITER //
CREATE PROCEDURE add_credit(IN user_id INT, IN amount DECIMAL(10,2))
BEGIN
    UPDATE accounts SET balance = balance + amount WHERE id = user_id;
    INSERT INTO credits (user_id, amount, created_at)
    VALUES (user_id, amount, NOW());
END //
DELIMITER ;
PHP
<?php
$stmt = $pdo->prepare("CALL add_credit(?, ?)");
$stmt->execute([$userId, 50.00]);

// OUT parameter procedures need session vars on MySQL
$pdo->exec("CALL get_balance($userId, @bal)");
$row = $pdo->query("SELECT @bal AS balance")->fetch();
echo $row["balance"];

Fetch Into Objects

PHP
<?php
class User {
    public int $id;
    public string $name;
    public string $email;
    public ?DateTimeImmutable $createdAt = null;
}

$stmt = $pdo->prepare("SELECT id, name, email, created_at FROM users WHERE id = ?");
$stmt->execute([$id]);
$stmt->setFetchMode(PDO::FETCH_CLASS, User::class);
$user = $stmt->fetch();

// All rows
$stmt = $pdo->query("SELECT * FROM users");
$users = $stmt->fetchAll(PDO::FETCH_CLASS, User::class);

// Constructor mode - pass row values into __construct
$stmt = $pdo->query("SELECT id, name FROM users");
$users = $stmt->fetchAll(PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE, User::class);

Lazy Cursor with Generator

PHP
<?php
function rowGenerator(PDOStatement $stmt): Generator {
    while ($row = $stmt->fetch()) {
        yield $row;
    }
}

// Process millions of rows without loading them all
$stmt = $pdo->query("SELECT * FROM huge_table");
foreach (rowGenerator($stmt) as $row) {
    process($row);
}

For MySQL specifically, also set unbuffered queries to avoid the driver buffering the whole result set:

PHP
<?php
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query("SELECT * FROM huge_table");
foreach (rowGenerator($stmt) as $row) { /* ... */ }

Connection Pooling

PHP-FPM creates one PHP process per request. PDO connections die at request end - there\'s no built-in cross-request pool. Options:

  • Persistent connections: add PDO::ATTR_PERSISTENT => true in DSN options - reuses the TCP socket across requests in the same FPM worker
  • ProxySQL / PgBouncer: a middleware pool sitting between PHP and the database
  • RoadRunner / Swoole: long-lived PHP workers that keep PDO alive between requests
Persistent connections caveats

State leaks between requests if your code leaves transactions open, session variables set, or temporary tables defined. Only use persistent connections when you understand and control the connection state.

Next Steps

Frequently Asked Questions

No. By default each statement auto-commits. Call $pdo->beginTransaction() explicitly when you need multi-statement atomicity, and wrap in try/catch to ensure rollback on error.

Test with your dataset. Typical sweet spot is 500-1000 rows per INSERT statement. Larger packets risk hitting max_allowed_packet on MySQL. With a transaction around many smaller batches, you get the speedup without the risk.

Almost always missing a transaction. InnoDB flushes to disk on every commit. Wrap 1000 inserts in beginTransaction/commit for a 10-100x speedup.

Rarely in modern apps. Logic in the database is harder to version-control, test, and refactor. Use them when the database vendor handles it dramatically better (heavy aggregations, bulk transformations) or when crossing service boundaries.

Yes. PHP doesn't share PDO across requests (each request gets a fresh PHP process). But within one request, create the PDO once at bootstrap, inject it everywhere via DI. Reconnecting mid-request is wasteful.