Skip to content

09: Working with Databases

Database Hero

Intermediate 75-90 min

PHP Data Objects (PDO) is PHP’s database abstraction layer—similar to JDBC in Java. It provides a consistent interface for working with multiple database systems (MySQL, PostgreSQL, SQLite, etc.). If you’re familiar with JDBC’s Connection, PreparedStatement, and ResultSet classes, you’ll find PDO’s API refreshingly similar yet more streamlined.

In this chapter, you’ll learn how to safely and efficiently work with databases in PHP, with constant comparisons to JDBC patterns you already know. We’ll start with basic connections and prepared statements, then move to advanced topics like transactions, error handling, and the repository pattern. By the end, you’ll be able to build robust data access layers that prevent SQL injection, handle errors gracefully, and maintain data consistency.

You’ll build practical examples including a connection manager, a complete repository implementation, and a query builder. These patterns form the foundation for working with databases in PHP applications, whether you’re building simple scripts or complex enterprise applications.

::: info Time Estimate ⏱️ 75-90 minutes to complete this chapter :::

What you need:

In this chapter, you’ll:

  • Set up database connections with PDO
  • Execute safe queries with prepared statements
  • Handle transactions and error modes
  • Build a complete data access layer
  • Implement repository pattern

By the end of this chapter, you’ll be able to:

  • Compare PDO to JDBC and understand their similarities
  • Create database connections with proper configuration
  • Use prepared statements to prevent SQL injection
  • Fetch results in different formats
  • Handle transactions and rollbacks
  • Implement error handling for database operations
  • Build repositories following best practices
  • Use query builders for dynamic SQL
  • Understand ORMs like Eloquent and Doctrine

Understand PDO and how it compares to JDBC.

PDO (PHP Data Objects) provides:

  • Database abstraction layer: Work with multiple databases using the same API
  • Prepared statements: Prevent SQL injection
  • Error handling: Exceptions for database errors
  • Transactions: ACID compliance

::: code-group

<?php
declare(strict_types=1);
// Connect to database
$pdo = new PDO(
'mysql:host=localhost;dbname=myapp;charset=utf8mb4',
'username',
'password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
// Prepared statement
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
echo $user['name'];
import java.sql.*;
// Connect to database
Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/myapp?useSSL=false",
"username",
"password"
);
// Prepared statement
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE email = ?"
);
stmt.setString(1, email);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
System.out.println(rs.getString("name"));
}
rs.close();
stmt.close();
conn.close();

:::

FeaturePDOJDBC
AbstractionMulti-database supportMulti-database support
Prepared statements✅ Built-in✅ Built-in
Transactions✅ Supported✅ Supported
Error handlingExceptionsExceptions
Connection pooling❌ Not built-in✅ Built-in
Fetch modesMultiple (assoc, object, etc.)ResultSet only
Auto-close✅ Automatic❌ Must close manually
Parameter binding? or :name? only

Learn to create and configure PDO connections properly.

<?php
declare(strict_types=1);
// DSN (Data Source Name) format: driver:host;dbname;charset
$dsn = 'mysql:host=localhost;dbname=myapp;charset=utf8mb4';
$username = 'root';
$password = 'secret';
$options = [
// Throw exceptions on errors
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
// Return associative arrays by default
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// Disable emulated prepared statements (use real ones)
PDO::ATTR_EMULATE_PREPARES => false,
// Persistent connections (connection pooling)
PDO::ATTR_PERSISTENT => false,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
echo "Connected successfully!\n";
} catch (PDOException $e) {
// Never expose credentials in error messages
error_log($e->getMessage());
die("Database connection failed");
}
<?php
declare(strict_types=1);
namespace App\Database;
use PDO;
use PDOException;
class Connection
{
private static ?PDO $instance = null;
/**
* Get singleton PDO instance
*/
public static function getInstance(): PDO
{
if (self::$instance === null) {
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=utf8mb4',
$_ENV['DB_HOST'] ?? 'localhost',
$_ENV['DB_NAME'] ?? 'myapp'
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
];
try {
self::$instance = new PDO(
$dsn,
$_ENV['DB_USER'] ?? 'root',
$_ENV['DB_PASS'] ?? '',
$options
);
} catch (PDOException $e) {
error_log('Database connection error: ' . $e->getMessage());
throw new \RuntimeException('Failed to connect to database');
}
}
return self::$instance;
}
/**
* Prevent cloning of singleton
*/
private function __clone() {}
/**
* Prevent unserialization of singleton
*/
public function __wakeup()
{
throw new \Exception("Cannot unserialize singleton");
}
}
// Usage
$pdo = Connection::getInstance();

::: code-group

$pdo = new PDO(
'mysql:host=localhost;dbname=myapp;charset=utf8mb4',
'username',
'password'
);
$pdo = new PDO(
'pgsql:host=localhost;port=5432;dbname=myapp',
'username',
'password'
);
$pdo = new PDO('sqlite:/path/to/database.sqlite');
$pdo = new PDO(
'sqlsrv:Server=localhost;Database=myapp',
'username',
'password'
);

:::


Master prepared statements to prevent SQL injection and improve performance.

Security: Prevent SQL injection

// ❌ NEVER DO THIS - Vulnerable to SQL injection!
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
$result = $pdo->query($sql); // DANGEROUS!
// ✅ ALWAYS use prepared statements
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]); // Safe!

Performance: Query is parsed once, executed multiple times

$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (?, ?)');
foreach ($users as $user) {
$stmt->execute([$user['name'], $user['email']]); // Reuses prepared statement
}
<?php
declare(strict_types=1);
// Single parameter
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([42]);
$user = $stmt->fetch();
// Multiple parameters
$stmt = $pdo->prepare(
'SELECT * FROM users WHERE email = ? AND status = ?'
);
$stmt->execute(['user@example.com', 'active']);
$users = $stmt->fetchAll();
// INSERT with positional parameters
$stmt = $pdo->prepare(
'INSERT INTO users (name, email, password) VALUES (?, ?, ?)'
);
$stmt->execute([
'Alice',
'alice@example.com',
password_hash('secret', PASSWORD_BCRYPT)
]);
echo "Inserted user with ID: " . $pdo->lastInsertId();
<?php
declare(strict_types=1);
// Named parameters (more readable)
$stmt = $pdo->prepare(
'SELECT * FROM users WHERE email = :email AND status = :status'
);
$stmt->execute([
':email' => 'user@example.com',
':status' => 'active'
]);
// Or without colons in array keys
$stmt->execute([
'email' => 'user@example.com',
'status' => 'active'
]);
$users = $stmt->fetchAll();
// INSERT with named parameters
$stmt = $pdo->prepare(
'INSERT INTO users (name, email, created_at)
VALUES (:name, :email, :created_at)'
);
$stmt->execute([
'name' => 'Bob',
'email' => 'bob@example.com',
'created_at' => date('Y-m-d H:i:s')
]);
<?php
declare(strict_types=1);
// Bind positional parameters with explicit types
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ? AND status = ?');
$stmt->bindValue(1, 42, PDO::PARAM_INT);
$stmt->bindValue(2, 'active', PDO::PARAM_STR);
$stmt->execute();
// Bind named parameters
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$email = 'user@example.com';
$stmt->bindParam(':email', $email, PDO::PARAM_STR);
$stmt->execute();
// bindParam vs bindValue
$id = 1;
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
// bindParam: binds variable (evaluated at execute time)
$stmt->bindParam(1, $id);
$id = 2; // Changes the bound value
$stmt->execute(); // Uses id = 2
// bindValue: binds value (evaluated at bind time)
$stmt->bindValue(1, $id);
$id = 3; // Does NOT change the bound value
$stmt->execute(); // Still uses id = 2

::: code-group

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE email = ?"
);
stmt.setString(1, email);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
// Process result
}

:::


Learn different ways to retrieve query results.

<?php
declare(strict_types=1);
$stmt = $pdo->prepare('SELECT * FROM users WHERE status = ?');
$stmt->execute(['active']);
// 1. Fetch associative array (default with our config)
$user = $stmt->fetch(PDO::FETCH_ASSOC);
// ['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com']
// 2. Fetch numeric array
$user = $stmt->fetch(PDO::FETCH_NUM);
// [1, 'Alice', 'alice@example.com']
// 3. Fetch both (associative + numeric)
$user = $stmt->fetch(PDO::FETCH_BOTH);
// [0 => 1, 'id' => 1, 1 => 'Alice', 'name' => 'Alice', ...]
// 4. Fetch as object
$user = $stmt->fetch(PDO::FETCH_OBJ);
// object { id: 1, name: 'Alice', email: 'alice@example.com' }
echo $user->name; // 'Alice'
// 5. Fetch into class
class User
{
public int $id;
public string $name;
public string $email;
}
$user = $stmt->fetchObject(User::class);
// User instance with properties set
<?php
declare(strict_types=1);
$stmt = $pdo->prepare('SELECT * FROM users');
$stmt->execute();
// 1. Fetch all rows at once
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
// [['id' => 1, 'name' => 'Alice'], ['id' => 2, 'name' => 'Bob'], ...]
// 2. Fetch all as objects
$users = $stmt->fetchAll(PDO::FETCH_CLASS, User::class);
// [User, User, User, ...]
// 3. Iterate over results (memory efficient for large datasets)
foreach ($stmt as $row) {
echo $row['name'] . "\n";
}
// 4. Fetch single column
$stmt = $pdo->prepare('SELECT email FROM users');
$stmt->execute();
$emails = $stmt->fetchAll(PDO::FETCH_COLUMN);
// ['alice@example.com', 'bob@example.com', ...]
// 5. Fetch key-value pairs
$stmt = $pdo->prepare('SELECT id, name FROM users');
$stmt->execute();
$users = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
// [1 => 'Alice', 2 => 'Bob', ...]
// 6. Fetch grouped by column
$stmt = $pdo->prepare('SELECT status, id, name FROM users');
$stmt->execute();
$usersByStatus = $stmt->fetchAll(PDO::FETCH_GROUP);
// ['active' => [['id' => 1, 'name' => 'Alice'], ...], 'inactive' => [...]]
<?php
declare(strict_types=1);
// Fetch single value
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users');
$stmt->execute();
$count = $stmt->fetchColumn();
echo "Total users: $count\n";
// Fetch single row
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([1]);
$user = $stmt->fetch();
if ($user) {
echo "Found: {$user['name']}\n";
} else {
echo "User not found\n";
}
// Check if row exists
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE email = ?');
$stmt->execute(['test@example.com']);
$exists = (bool) $stmt->fetchColumn();

Section 5: INSERT, UPDATE, DELETE Operations

Section titled “Section 5: INSERT, UPDATE, DELETE Operations”

Perform data modification operations safely.

<?php
declare(strict_types=1);
// Single insert
$stmt = $pdo->prepare(
'INSERT INTO users (name, email, password, created_at)
VALUES (:name, :email, :password, :created_at)'
);
$stmt->execute([
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => password_hash('secret', PASSWORD_BCRYPT),
'created_at' => date('Y-m-d H:i:s'),
]);
$userId = $pdo->lastInsertId();
echo "Created user with ID: $userId\n";
// Affected rows
$affectedRows = $stmt->rowCount();
echo "Rows affected: $affectedRows\n";
// Batch insert (reuse prepared statement)
$stmt = $pdo->prepare(
'INSERT INTO users (name, email) VALUES (?, ?)'
);
$users = [
['Alice', 'alice@example.com'],
['Bob', 'bob@example.com'],
['Charlie', 'charlie@example.com'],
];
foreach ($users as $user) {
$stmt->execute($user);
}
echo "Inserted " . count($users) . " users\n";
<?php
declare(strict_types=1);
// Update single record
$stmt = $pdo->prepare(
'UPDATE users SET name = :name, updated_at = :updated_at WHERE id = :id'
);
$stmt->execute([
'name' => 'Alice Updated',
'updated_at' => date('Y-m-d H:i:s'),
'id' => 1,
]);
$affected = $stmt->rowCount();
if ($affected > 0) {
echo "User updated successfully\n";
} else {
echo "User not found or no changes made\n";
}
// Update multiple records
$stmt = $pdo->prepare('UPDATE users SET status = ? WHERE created_at < ?');
$stmt->execute(['inactive', date('Y-m-d', strtotime('-1 year'))]);
echo "Deactivated " . $stmt->rowCount() . " inactive users\n";
// Increment counter
$stmt = $pdo->prepare('UPDATE posts SET views = views + 1 WHERE id = ?');
$stmt->execute([42]);
<?php
declare(strict_types=1);
// Delete single record
$stmt = $pdo->prepare('DELETE FROM users WHERE id = ?');
$stmt->execute([1]);
if ($stmt->rowCount() > 0) {
echo "User deleted successfully\n";
} else {
echo "User not found\n";
}
// Delete multiple records
$stmt = $pdo->prepare('DELETE FROM users WHERE status = ?');
$stmt->execute(['inactive']);
echo "Deleted " . $stmt->rowCount() . " inactive users\n";
// Soft delete (update instead of delete)
$stmt = $pdo->prepare(
'UPDATE users SET deleted_at = :deleted_at WHERE id = :id'
);
$stmt->execute([
'deleted_at' => date('Y-m-d H:i:s'),
'id' => 1,
]);

Use transactions to ensure data consistency.

<?php
declare(strict_types=1);
try {
// Start transaction
$pdo->beginTransaction();
// Perform multiple operations
$stmt = $pdo->prepare('INSERT INTO orders (user_id, total) VALUES (?, ?)');
$stmt->execute([1, 99.99]);
$orderId = $pdo->lastInsertId();
$stmt = $pdo->prepare('INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)');
$stmt->execute([$orderId, 100, 2]);
$stmt->execute([$orderId, 101, 1]);
$stmt = $pdo->prepare('UPDATE products SET stock = stock - ? WHERE id = ?');
$stmt->execute([2, 100]);
$stmt->execute([1, 101]);
// Commit if all successful
$pdo->commit();
echo "Order created successfully!\n";
} catch (PDOException $e) {
// Rollback on error
$pdo->rollBack();
echo "Order failed: " . $e->getMessage() . "\n";
}
<?php
declare(strict_types=1);
namespace App\Database;
use PDO;
use Closure;
class Transaction
{
public function __construct(
private PDO $pdo
) {}
/**
* Execute callback within transaction
*/
public function execute(Closure $callback): mixed
{
$this->pdo->beginTransaction();
try {
$result = $callback($this->pdo);
$this->pdo->commit();
return $result;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* Check if currently in transaction
*/
public function inTransaction(): bool
{
return $this->pdo->inTransaction();
}
}
// Usage
$transaction = new Transaction($pdo);
$orderId = $transaction->execute(function($pdo) {
$stmt = $pdo->prepare('INSERT INTO orders (user_id, total) VALUES (?, ?)');
$stmt->execute([1, 99.99]);
$orderId = $pdo->lastInsertId();
$stmt = $pdo->prepare('INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)');
$stmt->execute([$orderId, 100, 2]);
return $orderId;
});
echo "Order created: $orderId\n";
<?php
declare(strict_types=1);
class SavepointTransaction
{
private array $savepoints = [];
public function __construct(private PDO $pdo) {}
public function begin(): void
{
if (!$this->pdo->inTransaction()) {
$this->pdo->beginTransaction();
} else {
$savepoint = 'savepoint_' . count($this->savepoints);
$this->pdo->exec("SAVEPOINT $savepoint");
$this->savepoints[] = $savepoint;
}
}
public function commit(): void
{
if (empty($this->savepoints)) {
$this->pdo->commit();
} else {
$savepoint = array_pop($this->savepoints);
$this->pdo->exec("RELEASE SAVEPOINT $savepoint");
}
}
public function rollback(): void
{
if (empty($this->savepoints)) {
$this->pdo->rollBack();
} else {
$savepoint = array_pop($this->savepoints);
$this->pdo->exec("ROLLBACK TO SAVEPOINT $savepoint");
}
}
}
// Usage
$transaction = new SavepointTransaction($pdo);
$transaction->begin(); // Start outer transaction
try {
// Insert user
$stmt = $pdo->prepare('INSERT INTO users (name) VALUES (?)');
$stmt->execute(['Alice']);
$transaction->begin(); // Create savepoint
try {
// Insert profile (might fail)
$stmt = $pdo->prepare('INSERT INTO profiles (user_id, bio) VALUES (?, ?)');
$stmt->execute([$pdo->lastInsertId(), 'Bio text']);
$transaction->commit(); // Release savepoint
} catch (PDOException $e) {
$transaction->rollback(); // Rollback to savepoint (user still inserted)
}
$transaction->commit(); // Commit outer transaction
} catch (PDOException $e) {
$transaction->rollback(); // Rollback everything
}

::: code-group

try {
$pdo->beginTransaction();
// Execute queries
$stmt->execute([...]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
throw $e;
}
try {
conn.setAutoCommit(false);
// Execute queries
stmt.executeUpdate();
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
}

:::


Handle database errors gracefully.

<?php
declare(strict_types=1);
// 1. Silent mode (default - not recommended)
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);
$stmt = $pdo->prepare('SELECT * FROM nonexistent_table');
if ($stmt === false) {
$error = $pdo->errorInfo();
echo "Error: " . $error[2];
}
// 2. Warning mode (emits PHP warnings)
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
$stmt = $pdo->prepare('SELECT * FROM nonexistent_table'); // Triggers warning
// 3. Exception mode (recommended)
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
$stmt = $pdo->prepare('SELECT * FROM nonexistent_table');
} catch (PDOException $e) {
echo "Database error: " . $e->getMessage();
}
<?php
declare(strict_types=1);
namespace App\Database;
use PDOException;
class DatabaseException extends \RuntimeException
{
private ?string $query;
private array $bindings;
public function __construct(
string $message,
?string $query = null,
array $bindings = [],
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
$this->query = $query;
$this->bindings = $bindings;
}
public function getQuery(): ?string
{
return $this->query;
}
public function getBindings(): array
{
return $this->bindings;
}
public function toArray(): array
{
return [
'message' => $this->getMessage(),
'query' => $this->query,
'bindings' => $this->bindings,
'file' => $this->getFile(),
'line' => $this->getLine(),
];
}
}
// Wrapper class for safe query execution
class QueryExecutor
{
public function __construct(private \PDO $pdo) {}
public function execute(string $query, array $bindings = []): \PDOStatement
{
try {
$stmt = $this->pdo->prepare($query);
$stmt->execute($bindings);
return $stmt;
} catch (PDOException $e) {
throw new DatabaseException(
'Query execution failed: ' . $e->getMessage(),
$query,
$bindings,
(int) $e->getCode(),
$e
);
}
}
}
// Usage
$executor = new QueryExecutor($pdo);
try {
$stmt = $executor->execute(
'SELECT * FROM users WHERE email = ?',
['user@example.com']
);
$user = $stmt->fetch();
} catch (DatabaseException $e) {
error_log(json_encode($e->toArray()));
throw $e;
}

Implement data access layer using repository pattern.

<?php
declare(strict_types=1);
namespace App\Repositories;
use PDO;
abstract class Repository
{
public function __construct(
protected PDO $pdo
) {}
abstract protected function getTable(): string;
public function findById(int $id): ?array
{
$table = $this->getTable();
$stmt = $this->pdo->prepare("SELECT * FROM {$table} WHERE id = ?");
$stmt->execute([$id]);
$result = $stmt->fetch();
return $result ?: null;
}
public function findAll(): array
{
$table = $this->getTable();
$stmt = $this->pdo->query("SELECT * FROM {$table}");
return $stmt->fetchAll();
}
public function findBy(array $criteria): array
{
$table = $this->getTable();
$conditions = [];
$bindings = [];
foreach ($criteria as $column => $value) {
$conditions[] = "$column = ?";
$bindings[] = $value;
}
$where = implode(' AND ', $conditions);
$stmt = $this->pdo->prepare("SELECT * FROM {$table} WHERE {$where}");
$stmt->execute($bindings);
return $stmt->fetchAll();
}
public function create(array $data): int
{
$table = $this->getTable();
$columns = array_keys($data);
$placeholders = array_fill(0, count($columns), '?');
$sql = sprintf(
"INSERT INTO %s (%s) VALUES (%s)",
$table,
implode(', ', $columns),
implode(', ', $placeholders)
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute(array_values($data));
return (int) $this->pdo->lastInsertId();
}
public function update(int $id, array $data): bool
{
$table = $this->getTable();
$sets = [];
$bindings = [];
foreach ($data as $column => $value) {
$sets[] = "$column = ?";
$bindings[] = $value;
}
$bindings[] = $id;
$sql = sprintf(
"UPDATE %s SET %s WHERE id = ?",
$table,
implode(', ', $sets)
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($bindings);
return $stmt->rowCount() > 0;
}
public function delete(int $id): bool
{
$table = $this->getTable();
$stmt = $this->pdo->prepare("DELETE FROM {$table} WHERE id = ?");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}
<?php
declare(strict_types=1);
namespace App\Repositories;
class UserRepository extends Repository
{
protected function getTable(): string
{
return 'users';
}
public function findByEmail(string $email): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
$result = $stmt->fetch();
return $result ?: null;
}
public function findActiveUsers(): array
{
$stmt = $this->pdo->query('SELECT * FROM users WHERE status = "active"');
return $stmt->fetchAll();
}
public function searchByName(string $search): array
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE name LIKE ?');
$stmt->execute(["%{$search}%"]);
return $stmt->fetchAll();
}
public function createWithProfile(array $userData, array $profileData): int
{
$this->pdo->beginTransaction();
try {
// Create user
$userId = $this->create($userData);
// Create profile
$profileData['user_id'] = $userId;
$stmt = $this->pdo->prepare(
'INSERT INTO profiles (user_id, bio, avatar) VALUES (?, ?, ?)'
);
$stmt->execute([
$profileData['user_id'],
$profileData['bio'] ?? null,
$profileData['avatar'] ?? null,
]);
$this->pdo->commit();
return $userId;
} catch (\PDOException $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function countByStatus(string $status): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM users WHERE status = ?');
$stmt->execute([$status]);
return (int) $stmt->fetchColumn();
}
}
// Usage
$userRepo = new UserRepository($pdo);
// Find user
$user = $userRepo->findById(1);
$user = $userRepo->findByEmail('alice@example.com');
// Create user
$userId = $userRepo->create([
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => password_hash('secret', PASSWORD_BCRYPT),
'created_at' => date('Y-m-d H:i:s'),
]);
// Update user
$userRepo->update($userId, [
'name' => 'Alice Updated',
'updated_at' => date('Y-m-d H:i:s'),
]);
// Delete user
$userRepo->delete($userId);
// Custom queries
$activeUsers = $userRepo->findActiveUsers();
$users = $userRepo->searchByName('Alice');
$count = $userRepo->countByStatus('active');

Build dynamic queries safely without string concatenation.

<?php
declare(strict_types=1);
namespace App\Database;
use PDO;
class QueryBuilder
{
private string $table;
private array $wheres = [];
private array $bindings = [];
private array $select = ['*'];
private ?int $limit = null;
private ?int $offset = null;
private array $orderBy = [];
public function __construct(
private PDO $pdo
) {}
public function table(string $table): self
{
$this->table = $table;
return $this;
}
public function select(array $columns): self
{
$this->select = $columns;
return $this;
}
public function where(string $column, string $operator, mixed $value): self
{
$this->wheres[] = "$column $operator ?";
$this->bindings[] = $value;
return $this;
}
public function whereIn(string $column, array $values): self
{
$placeholders = implode(', ', array_fill(0, count($values), '?'));
$this->wheres[] = "$column IN ($placeholders)";
$this->bindings = array_merge($this->bindings, $values);
return $this;
}
public function orderBy(string $column, string $direction = 'ASC'): self
{
$this->orderBy[] = "$column $direction";
return $this;
}
public function limit(int $limit): self
{
$this->limit = $limit;
return $this;
}
public function offset(int $offset): self
{
$this->offset = $offset;
return $this;
}
public function get(): array
{
$sql = $this->buildSelectQuery();
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->bindings);
return $stmt->fetchAll();
}
public function first(): ?array
{
$this->limit(1);
$results = $this->get();
return $results[0] ?? null;
}
public function count(): int
{
$sql = $this->buildCountQuery();
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->bindings);
return (int) $stmt->fetchColumn();
}
private function buildSelectQuery(): string
{
$sql = sprintf(
'SELECT %s FROM %s',
implode(', ', $this->select),
$this->table
);
if (!empty($this->wheres)) {
$sql .= ' WHERE ' . implode(' AND ', $this->wheres);
}
if (!empty($this->orderBy)) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= ' LIMIT ' . $this->limit;
}
if ($this->offset !== null) {
$sql .= ' OFFSET ' . $this->offset;
}
return $sql;
}
private function buildCountQuery(): string
{
$sql = "SELECT COUNT(*) FROM {$this->table}";
if (!empty($this->wheres)) {
$sql .= ' WHERE ' . implode(' AND ', $this->wheres);
}
return $sql;
}
}
// Usage
$builder = new QueryBuilder($pdo);
// Simple query
$users = $builder->table('users')
->where('status', '=', 'active')
->get();
// Complex query
$users = $builder->table('users')
->select(['id', 'name', 'email'])
->where('status', '=', 'active')
->where('created_at', '>', '2024-01-01')
->whereIn('role', ['admin', 'moderator'])
->orderBy('name', 'ASC')
->limit(10)
->offset(20)
->get();
// Get first result
$user = $builder->table('users')
->where('email', '=', 'alice@example.com')
->first();
// Count
$count = $builder->table('users')
->where('status', '=', 'active')
->count();
echo "Found $count active users\n";

Understand when to use ORMs vs raw PDO.

1. Eloquent (Laravel)

<?php
// Install: composer require illuminate/database
use Illuminate\Database\Capsule\Manager as Capsule;
$capsule = new Capsule;
$capsule->addConnection([
'driver' => 'mysql',
'host' => 'localhost',
'database' => 'myapp',
'username' => 'root',
'password' => 'secret',
'charset' => 'utf8mb4',
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
// Define model
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password'];
}
// Usage
$user = User::find(1);
$user = User::where('email', 'alice@example.com')->first();
$users = User::where('status', 'active')->get();
$user = new User();
$user->name = 'Alice';
$user->email = 'alice@example.com';
$user->save();
$user->update(['name' => 'Alice Updated']);
$user->delete();

2. Doctrine (Symfony)

<?php
// Install: composer require doctrine/orm
use Doctrine\ORM\Tools\Setup;
use Doctrine\ORM\EntityManager;
$paths = [__DIR__ . '/src/Entity'];
$isDevMode = true;
$dbParams = [
'driver' => 'pdo_mysql',
'host' => 'localhost',
'user' => 'root',
'password' => 'secret',
'dbname' => 'myapp',
];
$config = Setup::createAnnotationMetadataConfiguration($paths, $isDevMode);
$entityManager = EntityManager::create($dbParams, $config);
// Define entity
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
class User
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*/
private int $id;
/**
* @ORM\Column(type="string")
*/
private string $name;
// Getters and setters...
}
// Usage
$user = $entityManager->find(User::class, 1);
$users = $entityManager->getRepository(User::class)->findAll();
$user = new User();
$user->setName('Alice');
$entityManager->persist($user);
$entityManager->flush();
FeaturePDOORM (Eloquent/Doctrine)
Learning curveLowHigh
PerformanceFastSlightly slower
FlexibilityHighMedium
BoilerplateMoreLess
Type safetyManualBuilt-in
RelationshipsManual joinsAutomatic
MigrationsManualBuilt-in
CachingManualBuilt-in
Best forComplex queries, performance-criticalCRUD, rapid development

::: tip When to Use Each

Use PDO when:

  • You need maximum performance
  • You have complex queries
  • You want full control
  • Your project is small/simple

Use ORM when:

  • You have many relationships
  • You want rapid development
  • You need migrations/seeds
  • Your team prefers Active Record pattern :::

Create a complete user management system:

Requirements:

  • User repository with full CRUD operations
  • Prepared statements for all queries
  • Transaction support for complex operations
  • Custom search methods (by email, by name, by status)
  • Pagination support

Exercise 2: Blog System with Relationships

Section titled “Exercise 2: Blog System with Relationships”

Build a blog system with posts, comments, and categories:

Requirements:

  • Post repository with category relationship
  • Comment repository with post/user relationships
  • Methods to fetch posts with their comments
  • Transaction-based post creation (post + categories)
  • Search and filtering capabilities

Extend the QueryBuilder class:

Requirements:

  • Add orWhere() support
  • Add join() support
  • Add groupBy() and having() support
  • Add insert(), update(), delete() methods
  • Add query logging for debugging

Before moving to the next chapter, ensure you can:

  • Create PDO connections with proper configuration
  • Use prepared statements to prevent SQL injection
  • Fetch results in different formats (assoc, object, etc.)
  • Perform INSERT, UPDATE, DELETE operations safely
  • Use transactions for data consistency
  • Handle database errors with exceptions
  • Implement repository pattern for data access
  • Build dynamic queries with query builder
  • Compare PDO to JDBC
  • Understand when to use ORM vs raw PDO

::: tip Ready for More? In Chapter 10: Building REST APIs, we’ll build a complete RESTful API using everything we’ve learned so far. :::


Official Documentation:

ORMs: