<< Home

Chapter 16: Database Access with PDO

PHP Data Objects (PDO) provides a consistent interface for working with many databases using the same set of classes and methods. You write one set of calls that target MySQL, PostgreSQL, SQLite, SQL Server, and more, then switch by altering the Data Source Name (DSN) and a few options. This chapter explains connecting, preparing queries, handling transactions, fetching rows, dealing with errors, and organizing a small repository layer.

Connecting and DSNs

A PDO connection requires a DSN string, a username, a password, and often an array of options. The DSN identifies the driver and connection parameters. You can store credentials securely outside your document root, or inject them via environment variables.

Understanding DSN formats for common drivers

Each driver has its own DSN shape. The following examples show typical forms. Replace host, port, file paths, and database names with values that match your environment.

// MySQL or MariaDB
$dsn = 'mysql:host=localhost;port=3306;dbname=example;charset=utf8mb4';

// PostgreSQL
$dsn = 'pgsql:host=localhost;port=5432;dbname=example';

// SQLite (file)
$dsn = 'sqlite:/path/to/database.sqlite';

// SQL Server (sqlsrv)
$dsn = 'sqlsrv:Server=localhost,1433;Database=example';
⚠️ Make sure you have the correct PDO driver installed and enabled for your target database (for example, pdo_mysql, pdo_pgsql, pdo_sqlite). If a driver is missing, the constructor will fail, and you will receive a useful exception if you enable exception mode.

Creating a robust connection using options

Options can improve safety and performance. Use exceptions for errors, set a sensible default fetch mode, and request native prepared statements when appropriate.

$options = [
  PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
  PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  PDO::ATTR_EMULATE_PREPARES   => false
];

$pdo = new PDO($dsn, $user, $pass, $options);
💡 Environment variables help keep secrets out of source control. Read $_ENV['DB_USER'], $_ENV['DB_PASS'], and so on, then assemble your DSN at runtime.

Prepared Statements and Binding

Prepared statements separate query structure from data, reducing the risk of SQL injection and improving performance when you execute the same statement many times. You can bind by position or by name, and you can specify parameter types to help the driver cast values correctly.

Preparing queries and executing with placeholders

Placeholders keep user data out of the SQL text. You prepare once, then execute with values that match the placeholders.

// Positional placeholders
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? AND active = ?');
$stmt->execute([$email, 1]);
$rows = $stmt->fetchAll();

// Named placeholders
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND active = :active');
$stmt->execute([':email' => $email, ':active' => 1]);

Binding values with explicit PDO::PARAM_* types

Explicit types reduce ambiguity when the driver converts PHP values to SQL values. Use bindValue() for immediate values, and bindParam() when you want to bind a variable by reference that may change before execution.

$stmt = $pdo->prepare('INSERT INTO logs (user_id, message, created_at) VALUES (:uid, :msg, :ts)');
$stmt->bindValue(':uid', $userId, PDO::PARAM_INT);
$stmt->bindValue(':msg', $message, PDO::PARAM_STR);
$stmt->bindValue(':ts', $timestamp, PDO::PARAM_STR);
$stmt->execute();
⚠️ When using booleans, some engines store them as integers. Bind with PDO::PARAM_BOOL or convert to 0 or 1 to match your schema consistently.

Executing many rows efficiently

For repetitive inserts or updates, keep the statement prepared and call execute() repeatedly inside a transaction for better throughput.

$pdo->beginTransaction();
$stmt = $pdo->prepare('INSERT INTO visits (user_id, path) VALUES (?, ?)');
foreach ($batch as [$uid, $path]) {
  $stmt->execute([$uid, $path]);
}
$pdo->commit();

Transactions and Isolation

Transactions group multiple statements into a single unit of work. If any step fails, the whole group can be rolled back. Isolation levels control how concurrent transactions interact, which affects consistency and performance.

Starting, committing, and rolling back

Use the trio of beginTransaction(), commit(), and rollBack() to protect critical sequences. Wrap the work in a try and ensure rollback occurs on failure.

try {
  $pdo->beginTransaction();

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

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

  $pdo->commit();
} catch (Throwable $e) {
  if ($pdo->inTransaction()) {
    $pdo->rollBack();
  }
  // Re-throw or log the error, then handle it upstream
  throw $e;
}

Selecting an isolation level with SQL

PDO does not set isolation levels directly. You issue an SQL command that your engine understands, then start the transaction. Choose a level that balances correctness with concurrency needs.

// Example: PostgreSQL or SQL Server style
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
$pdo->beginTransaction();
// perform work...
$pdo->commit();
💡 Many workloads do well with READ COMMITTED. Use stricter levels such as REPEATABLE READ or SERIALIZABLE when phantom reads or write skew would cause incorrect results.
⚠️ Isolation syntax varies by engine. MySQL uses SET SESSION TRANSACTION ISOLATION LEVEL …, PostgreSQL uses SET TRANSACTION ISOLATION LEVEL …, SQLite emulates levels with write locks, and actual guarantees differ. Check your database documentation when correctness is critical.

Fetching Modes and Iteration

PDO can return rows in many shapes. Associative arrays are convenient for most PHP code, numeric arrays are compact, and objects can be useful for mapping to simple data carriers or DTOs. Choose a default fetch mode that fits, then override per statement when needed.

Selecting a suitable PDO::FETCH_* mode

The following table summarizes common fetch modes. You can set a default on the connection, then change per statement with setFetchMode() or by passing the mode to fetch().

Mode Description
PDO::FETCH_ASSOC Returns an associative array keyed by column names, convenient and readable.
PDO::FETCH_NUM Returns a numeric array keyed by column position, compact but less explicit.
PDO::FETCH_BOTH Returns both associative and numeric keys, useful when you need either form.
PDO::FETCH_OBJ Returns an anonymous object with properties named after columns.
PDO::FETCH_CLASS Maps rows into instances of a class, optionally calling the constructor.
PDO::FETCH_COLUMN Returns a single column from the next row, handy for one-column queries.

Iterating rows efficiently

Use a forward loop that calls fetch() repeatedly to keep memory usage low. For large exports, stream rows rather than loading everything at once.

$stmt = $pdo->query('SELECT id, email FROM users ORDER BY id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
  // process $row['id'], $row['email']
}

Fetching into classes with PDO::FETCH_CLASS

Mapping directly to a simple class can reduce array indexing noise in your code. For complex domains, prefer an explicit mapper or a repository that constructs entities carefully.

class User {
  public int $id;
  public string $email;
}

$stmt = $pdo->query('SELECT id, email FROM users');
$stmt->setFetchMode(PDO::FETCH_CLASS, User::class);
foreach ($stmt as $user) {
  // $user is a User instance
}

Error Modes and Exceptions

PDO supports several error modes. Exception mode pairs well with structured error handling and keeps your control flow clear. Silent and warning modes can hide problems or spread checks across your codebase, which increases the chance of missed failures.

Choosing an error mode with PDO::ATTR_ERRMODE

Set the error mode on the connection, then rely on exceptions to signal failure. The summary table helps you pick a strategy that matches your application style.

Error Mode Behavior
PDO::ERRMODE_EXCEPTION Throws PDOException on errors, recommended for most apps.
PDO::ERRMODE_WARNING Emits a PHP warning, then continues. You must inspect return values.
PDO::ERRMODE_SILENT Sets errors on the statement object only. You have to check codes manually.

Handling PDOException safely

Catch exceptions at boundaries where you can recover or translate them into user-friendly messages. Log the details, avoid exposing SQL text to end users, and ensure transactions are closed properly.

try {
  $stmt = $pdo->prepare('SELECT * FROM orders WHERE id = :id');
  $stmt->execute([':id' => $orderId]);
  $order = $stmt->fetch();
  if (!$order) {
    // handle not found
  }
} catch (PDOException $e) {
  // log $e->getMessage() securely
  http_response_code(500);
  echo 'Database error occurred.';
}
💡 Normalize database errors into a small set of application error codes. Your web layer can map those codes to HTTP status responses and concise messages for users.

Lightweight Repository Pattern

A small repository module centralizes SQL and reduces duplication. The repository receives a PDO instance, exposes intent-focused methods, and returns arrays or simple objects. Keep it light, keep it testable, and avoid hiding SQL so deeply that performance tuning becomes difficult.

Defining a minimal repository API

The class below wraps a user table with a few operations. It uses prepared statements, typed parameters, and concise return shapes. Expand the pattern gradually rather than inventing a complex abstraction early.

final class UserRepository
{
  public function __construct(private PDO $pdo) {}

  public function findById(int $id): ?array
  {
    $stmt = $this->pdo->prepare('SELECT id, email, active FROM users WHERE id = :id');
    $stmt->execute([':id' => $id]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ?: null;
  }

  public function findActiveByEmail(string $email): ?array
  {
    $stmt = $this->pdo->prepare(
      'SELECT id, email, active FROM users WHERE email = :email AND active = 1'
    );
    $stmt->execute([':email' => $email]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ?: null;
  }

  public function create(string $email): int
  {
    $stmt = $this->pdo->prepare('INSERT INTO users (email, active) VALUES (:email, 1)');
    $stmt->bindValue(':email', $email, PDO::PARAM_STR);
    $stmt->execute();
    return (int)$this->pdo->lastInsertId();
  }

  public function deactivate(int $id): void
  {
    $stmt = $this->pdo->prepare('UPDATE users SET active = 0 WHERE id = :id');
    $stmt->bindValue(':id', $id, PDO::PARAM_INT);
    $stmt->execute();
  }
}
⚠️ Keep SQL visible and close to the repository boundaries. If you hide it behind many layers, troubleshooting slow queries becomes harder, and adding an index later becomes guesswork.

Integrating the repository in application code

Construct the repository once per request or per container scope, then call the intent methods. When a unit of work involves many changes, bracket the operations in a transaction for atomic updates.

$repo = new UserRepository($pdo);

try {
  $pdo->beginTransaction();

  $id = $repo->create('new.user@example.com');
  $user = $repo->findById($id);

  if ($user && !$user['active']) {
    // handle inactive user …
  }

  $pdo->commit();
} catch (Throwable $e) {
  if ($pdo->inTransaction()) {
    $pdo->rollBack();
  }
  // translate and log the error here
}
💡 Add a thin interface for repositories only when you need multiple implementations (for example, a fake or in-memory version for tests). Avoid adding abstractions until a concrete need appears.