Advanced PDO

Reviewed & published by Brayan K

By the end of this lesson you'll pick the right fetch mode, reuse prepared statements in loops, and wrap multi-step writes in transactions that either fully commit or cleanly roll back — the skills behind every safe, fast database app.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Fetch Modes: ASSOC, OBJ & CLASS

A fetch mode decides the shape of each row PDO hands back. PDO::FETCH_ASSOC gives you an associative array you read by column name ($row['name']) — the everyday choice. PDO::FETCH_OBJ gives a plain object you read with arrow syntax ($row->name). The powerful one is PDO::FETCH_CLASS: it pours each row straight into an object of your class, so the data arrives with real methods attached. That last mode is the seed of every ORM.

<?php
// Every example here uses an in-memory SQLite database so it runs anywhere.
// The PDO API is IDENTICAL for MySQL/PostgreSQL — only the connection string changes.
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // throw on errors

$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)");
$pdo->exec("INSERT INTO users (name, age) VALUES ('Alice', 28), ('Bob', 35)");

// A "fetch mode" decides the SHAPE of each row you get back.

// 1) FETCH_ASSOC — each row is an associative array, keyed by column name.
$row = $pdo->query("SELECT * FROM users WHERE name = 'Alice'")
           ->fetch(PDO::FETCH_ASSOC);
echo "ASSOC:  " . $row['name'] . " is " . $row['age'] . "\n"; // use ['key']

// 2) FETCH_OBJ — each row is a plain object; read columns as ->property.
$row = $pdo->query("SELECT * FROM users WHERE name = 'Bob'")
           ->fetch(PDO::FETCH_OBJ);
echo "OBJ:    " . $row->name . " is " . $row->age . "\n";       // use ->prop

// 3) FETCH_CLASS — map each row straight into objects of YOUR class.
class User {
    public string $name;
    public int $age;
    public function greet(): string {
        return "Hi, I'm {$this->name} ({$this->age})";
    }
}
$people = $pdo->query("SELECT name, age FROM users ORDER BY name")
              ->fetchAll(PDO::FETCH_CLASS, User::class); // array of User objects
foreach ($people as $person) {
    echo "CLASS:  " . $person->greet() . "\n"; // real methods, not just data
}

Set a default once with $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC) and you won't have to pass the mode on every call. You can still override it per query when one row needs a different shape.

2️⃣ Reusing a Prepared Statement in a Loop

Calling prepare() asks the database to parse and plan the SQL. Do it once outside the loop, then call execute() with fresh values inside the loop — the plan is reused and each value is still safely escaped. Two handy facts after a write: lastInsertId() returns the auto-increment id of the most recent INSERT, and rowCount() tells you how many rows a write touched.

<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // default shape
$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, age INTEGER)");

$people = [
    ["Alice",   "[email protected]",   28],
    ["Bob",     "[email protected]",     35],
    ["Charlie", "[email protected]", 22],
];

// Prepare ONCE, execute MANY. The database parses the SQL a single time and
// reuses the plan for every row — faster and safe from SQL injection.
$stmt = $pdo->prepare("INSERT INTO users (name, email, age) VALUES (?, ?, ?)");
foreach ($people as $p) {
    $stmt->execute($p);                 // re-run the SAME statement with new values
}

// lastInsertId() returns the auto-increment id of the most recent INSERT.
echo "Last inserted id: " . $pdo->lastInsertId() . "\n"; // 3 (Charlie)

// rowCount() tells you how many rows a write affected.
$update = $pdo->prepare("UPDATE users SET age = age + 1 WHERE age < :limit");
$update->execute([":limit" => 30]);
echo "Rows updated: " . $update->rowCount() . "\n"; // Alice + Charlie = 2

echo "\nEveryone:\n";
foreach ($pdo->query("SELECT name, age FROM users ORDER BY id") as $row) {
    echo "  {$row['name']} ({$row['age']})\n"; // FETCH_ASSOC default
}

3️⃣ bindValue vs bindParam (and Types)

Both attach a value to a placeholder, but they differ in when the value is read. bindValue() takes a snapshot of the value right now. bindParam() binds a variable by reference, so PDO reads its value at execute() time — change the variable in a loop and the query changes with it. The optional third argument forces a type: PDO::PARAM_INT for integers, PDO::PARAM_STR for strings, PDO::PARAM_BOOL for booleans.

<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)");
$pdo->exec("INSERT INTO users (name, age) VALUES ('Alice', 28), ('Bob', 35), ('Cara', 41)");

// bindValue — binds the value AS IT IS RIGHT NOW. Use it for plain values.
// PDO::PARAM_INT forces the parameter to be treated as an integer.
$stmt = $pdo->prepare("SELECT name, age FROM users WHERE age >= :min ORDER BY age");
$stmt->bindValue(":min", 30, PDO::PARAM_INT); // snapshot: 30, as an int
$stmt->execute();
echo "Aged 30+:\n";
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
    echo "  {$r['name']} ({$r['age']})\n";
}

// bindParam — binds a VARIABLE BY REFERENCE. The value is read at execute()
// time, so the SAME bound statement can be re-run as the variable changes.
$minAge = 0;
$stmt = $pdo->prepare("SELECT COUNT(*) AS n FROM users WHERE age >= :min");
$stmt->bindParam(":min", $minAge, PDO::PARAM_INT); // bind the variable, not its value
foreach ([28, 35, 41] as $minAge) {                // changing $minAge changes the query
    $stmt->execute();
    $n = $stmt->fetch(PDO::FETCH_ASSOC)['n'];
    echo "age >= {$minAge}: {$n}\n";
}

4️⃣ Transactions: All or Nothing

A transaction bundles several writes into one atomic unit: every query commits together, or none of them do. You open it with beginTransaction(), do the work inside a try block, commit() on success, and rollBack() in catch so any failure cleanly undoes everything. This is essential for money transfers, order processing, or any multi-step update where a half-finished result would corrupt your data.

<?php
// A transaction groups queries into ONE atomic operation: either every query
// commits, or rollBack() undoes them all. Perfect for money transfers.
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec("CREATE TABLE accounts (name TEXT PRIMARY KEY, balance INTEGER)");
$seed = $pdo->prepare("INSERT INTO accounts (name, balance) VALUES (?, ?)");
foreach (["Alice" => 1000, "Bob" => 500] as $name => $balance) {
    $seed->execute([$name, $balance]);
}

function transfer(PDO $pdo, string $from, string $to, int $amount): void
{
    $pdo->beginTransaction();                 // open the transaction
    try {
        $debit = $pdo->prepare("UPDATE accounts SET balance = balance - ? WHERE name = ?");
        $debit->execute([$amount, $from]);

        // Check the rule INSIDE the transaction, before committing.
        $check = $pdo->prepare("SELECT balance FROM accounts WHERE name = ?");
        $check->execute([$from]);
        if ($check->fetchColumn() < 0) {
            throw new RuntimeException("Insufficient funds for $from");
        }

        $credit = $pdo->prepare("UPDATE accounts SET balance = balance + ? WHERE name = ?");
        $credit->execute([$amount, $to]);

        $pdo->commit();                       // make all changes permanent
        echo "OK:   \$$amount $from -> $to\n";
    } catch (Throwable $e) {
        $pdo->rollBack();                     // undo EVERYTHING on any failure
        echo "FAIL: {$e->getMessage()} (rolled back)\n";
    }
}

transfer($pdo, "Alice", "Bob", 200);   // succeeds -> committed
transfer($pdo, "Bob", "Alice", 9999);  // overdraws -> rolled back

echo "\nFinal balances:\n";
foreach ($pdo->query("SELECT name, balance FROM accounts ORDER BY name") as $row) {
    printf("  %-6s \$%d\n", $row["name"], $row["balance"]);
}

5️⃣ A Safe IN (...) Clause

You can't bind a whole array to one placeholder, and you must never glue user values into the SQL string yourself — that's a classic injection hole. Instead build one ? per value, splice them into the IN (...), and pass the matching array to execute(). The placeholder count always lines up with the value count, and PDO escapes every value for you.

<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
$pdo->exec("INSERT INTO users (name) VALUES ('Alice'), ('Bob'), ('Cara'), ('Dan')");

// You CANNOT bind an array to a single placeholder. Build one ? per value,
// then pass the matching array — the count always lines up automatically.
$wanted = ["Alice", "Cara", "Dan"];

// str_repeat("?,", 3) = "?,?,?," — trim the trailing comma to get "?,?,?".
$placeholders = rtrim(str_repeat("?,", count($wanted)), ",");
$sql = "SELECT name FROM users WHERE name IN ($placeholders) ORDER BY name";

$stmt = $pdo->prepare($sql);   // SELECT ... WHERE name IN (?,?,?)
$stmt->execute($wanted);        // one value per placeholder, safely escaped

echo "Matched:\n";
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $name) { // flat list of one column
    echo "  $name\n";
}

6️⃣ Your Turn

Now you drive. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel. First, fetch a row as an object.

<?php
// 🎯 YOUR TURN — fill in each blank marked ___ , then run it.
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pets (name TEXT, kind TEXT)");
$pdo->exec("INSERT INTO pets VALUES ('Rex', 'dog'), ('Milo', 'cat')");

// 1) Fetch ONE row as an OBJECT so you can read columns with ->
$stmt = $pdo->query("SELECT * FROM pets WHERE name = 'Rex'");
$pet  = $stmt->fetch(___);          // 👉 use the fetch mode for objects: PDO::FETCH_OBJ

// 2) Print the pet using the -> property syntax (NOT ['name'])
echo $pet->name . " is a " . ___ . "\n";   // 👉 read the kind column: $pet->kind

// ✅ Expected output:
//    Rex is a dog
?>

One more, and it's the big one: finish a transaction so the transfer is atomic. Add the line that opens it and the line that makes it permanent.

<?php
// 🎯 YOUR TURN — a transfer is half-written. Finish the transaction.
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE accounts (name TEXT, balance INTEGER)");
$pdo->exec("INSERT INTO accounts VALUES ('Alice', 100), ('Bob', 0)");

___;                               // 👉 1) open the transaction: $pdo->beginTransaction()
try {
    $pdo->prepare("UPDATE accounts SET balance = balance - ? WHERE name = ?")
        ->execute([40, "Alice"]);
    $pdo->prepare("UPDATE accounts SET balance = balance + ? WHERE name = ?")
        ->execute([40, "Bob"]);
    ___;                           // 👉 2) make it permanent: $pdo->commit()
    echo "Transfer committed\n";
} catch (Throwable $e) {
    $pdo->rollBack();              // 👉 3) on error, undo everything (already done for you)
    echo "Rolled back\n";
}

foreach ($pdo->query("SELECT name, balance FROM accounts ORDER BY name") as $r) {
    echo "  {$r['name']}: {$r['balance']}\n";
}

// ✅ Expected output:
//    Transfer committed
//      Alice: 60
//      Bob: 40
?>

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Advanced PDO

Method / ConstantExampleWhat It Does
beginTransaction()$pdo->beginTransaction()Open a transaction
commit()$pdo->commit()Save all changes permanently
rollBack()$pdo->rollBack()Undo every change in the transaction
bindValue(p, v, type)bindValue(":age", 28, PDO::PARAM_INT)Bind a value snapshot with a type
bindParam(p, $var, type)bindParam(":age", $age, PDO::PARAM_INT)Bind a variable by reference
lastInsertId()$pdo->lastInsertId()Id of the most recent INSERT
rowCount()$stmt->rowCount()Rows affected by a write
FETCH_ASSOC / OBJ / CLASSfetch(PDO::FETCH_OBJ)Choose the row's shape
IN (?,?,?)str_repeat("?,", count($v))One placeholder per value

Mini-Challenge: Bulk Insert + Filtered Read

No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is exactly the prepare-loop-commit pattern you'll reach for on real data.

<?php
// 🎯 MINI-CHALLENGE: bulk insert + filtered read
// No logic is filled in — work from the steps, then run it.
//
// Setup (copy this as-is):
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec("CREATE TABLE scores (player TEXT, points INTEGER)");

$rows = [["Ana", 50], ["Ben", 90], ["Cleo", 70]];

// 1. prepare ONE INSERT statement: "INSERT INTO scores VALUES (?, ?)"
// 2. wrap the inserts in a transaction (beginTransaction / commit)
// 3. loop over $rows and execute() the prepared statement for each
// 4. SELECT player, points WHERE points >= 70 ORDER BY points DESC
// 5. echo each surviving row as "player: points"
//
// ✅ Expected output:
//    Ben: 90
//    Cleo: 70

// your code here
?>

🎉 Lesson Complete!

Practice quiz

Which fetch mode hydrates each row into an object of your own class, complete with its methods?

  • PDO::FETCH_ASSOC
  • PDO::FETCH_OBJ
  • PDO::FETCH_CLASS
  • PDO::FETCH_BOTH

Answer: PDO::FETCH_CLASS. FETCH_CLASS pours each row into an instance of your class, so the data arrives with real methods attached — the seed of every ORM.

What does PDO::FETCH_ASSOC return for each row?

  • A plain object you read with arrow syntax
  • An associative array keyed by column name
  • An array with both numeric and string keys
  • An instance of your own class

Answer: An associative array keyed by column name. FETCH_ASSOC gives an associative array you read by column name, e.g. $row['name'] — the everyday choice.

Why prepare a statement once and call execute() inside the loop?

  • It makes the SQL case-insensitive
  • The database parses/plans the SQL once and reuses it, and values stay injection-safe
  • It automatically opens a transaction
  • It converts the rows to JSON

Answer: The database parses/plans the SQL once and reuses it, and values stay injection-safe. prepare() asks the database to parse and plan the SQL; do it once outside the loop and only execute() inside to reuse the plan.

What is the key difference between bindValue() and bindParam()?

  • bindValue is for integers, bindParam is for strings
  • bindValue takes a snapshot now; bindParam binds a variable by reference, read at execute() time
  • bindParam is unsafe against SQL injection
  • They are identical aliases

Answer: bindValue takes a snapshot now; bindParam binds a variable by reference, read at execute() time. bindValue snapshots the value immediately; bindParam binds the variable by reference, so PDO reads its current value when execute() runs.

In a transaction, which method permanently saves every queued change?

  • commit()
  • rollBack()
  • beginTransaction()
  • lastInsertId()

Answer: commit(). commit() makes all the changes in the transaction permanent; rollBack() undoes them all on failure.

Inside a try/catch transaction, where does rollBack() belong?

  • Before beginTransaction()
  • In the catch block, to undo everything on any failure
  • Right after commit()
  • Inside the SQL string

Answer: In the catch block, to undo everything on any failure. You commit() on the happy path and call rollBack() in catch so any failure cleanly undoes the whole transaction.

Which method returns the auto-increment id of the most recent INSERT?

  • rowCount()
  • lastInsertId()
  • fetchColumn()
  • errorInfo()

Answer: lastInsertId(). lastInsertId() returns the auto-increment id of the most recent INSERT; rowCount() reports how many rows a write affected.

How do you safely build a WHERE name IN (...) clause for a variable-length array in PDO?

  • Bind the whole array to a single placeholder
  • Concatenate the values into the SQL string
  • Generate one ? per value with rtrim(str_repeat('?,', count($v)), ',') and pass the array to execute()
  • Use FETCH_COLUMN to expand the array

Answer: Generate one ? per value with rtrim(str_repeat('?,', count($v)), ',') and pass the array to execute(). You cannot bind an array to one placeholder; build one ? per value so the placeholder count always matches the value count.

Why does setting PDO::ATTR_EMULATE_PREPARES to false improve safety?

  • It disables transactions
  • It makes the database server parse real prepared statements instead of quoting on the client
  • It speeds up FETCH_ASSOC
  • It caches the connection

Answer: It makes the database server parse real prepared statements instead of quoting on the client. Emulated prepares quote on the client; turning them off sends real prepared statements parsed by the server — better security and accurate types.

Which fetch mode is best avoided because it returns every column twice and wastes memory?

  • PDO::FETCH_BOTH
  • PDO::FETCH_ASSOC
  • PDO::FETCH_OBJ
  • PDO::FETCH_COLUMN

Answer: PDO::FETCH_BOTH. FETCH_BOTH (the default) returns each column under both a numeric and a string key, doubling memory; set FETCH_ASSOC instead.

Continue this course

Frequently asked questions

What is the difference between bindValue and bindParam?

bindValue binds a value as it is at that moment — a snapshot. bindParam binds a variable by reference, so PDO reads the variable's current value at execute() time, not when you bind it. That makes bindParam useful when you re-run the same statement in a loop and change the variable between runs. For most one-off queries bindValue (or just passing an array to execute()) is simpler and clearer. bindParam is also required for output parameters from stored procedures.

Why should I prepare a statement once and reuse it in a loop?

Each prepare() asks the database to parse and plan the SQL. If you prepare inside the loop you pay that cost on every iteration; if you prepare once outside the loop and only call execute() inside, the database reuses the plan. Wrapping the loop in a single transaction makes it dramatically faster still — often 50 to 100 times faster for large batches — because the database commits once at the end instead of after every row.

How do I safely use an IN (...) clause with PDO?

You cannot bind a whole array to one placeholder, and you must never paste the values into the SQL string yourself. Build one placeholder per value with rtrim(str_repeat('?,', count($values)), ','), splice that into IN (...), prepare the query, then pass the array straight to execute(). The number of placeholders always matches the number of values, and every value is escaped by PDO, so there is no injection risk.

Which fetch mode should I use — ASSOC, OBJ, or CLASS?

Use FETCH_ASSOC for plain data you loop over and read by column name ($row['name']); it is the most common choice. Use FETCH_OBJ when you prefer object syntax ($row->name) but do not need methods. Use FETCH_CLASS when you want each row hydrated into a real object of your own class so it carries behaviour (methods), which is the foundation of how ORMs map tables to objects. Avoid the default FETCH_BOTH, which returns every column twice (numeric and string keys) and wastes memory.

Do I always have to call rollBack() myself?

You call rollBack() in your catch block to undo a transaction when something goes wrong. If the script simply ends or the connection closes before commit(), the database discards the open transaction for you, so uncommitted changes are not saved. The danger is the opposite: forgetting to commit(), which silently throws away work that looked like it succeeded. Always commit() on the happy path and rollBack() on failure.

Related lessons