PHP CRUD with PDO

Build a complete CRUD layer with PDO: Create, Read, Update, Delete - plus pagination, search, and a clean Repository pattern you can drop into any project.

Intermediate 10 min read 12 examples

Sample Schema

SQLschema.sql
CREATE TABLE users (
    id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    email       VARCHAR(255) NOT NULL UNIQUE,
    role        ENUM('admin', 'editor', 'user') DEFAULT 'user',
    active      TINYINT(1) DEFAULT 1,
    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Create (INSERT)

PHP
<?php
$stmt = $pdo->prepare(
    "INSERT INTO users (name, email, role) VALUES (:name, :email, :role)"
);

$stmt->execute([
    ":name"  => $data["name"],
    ":email" => $data["email"],
    ":role"  => $data["role"] ?? "user",
]);

$id = (int) $pdo->lastInsertId();
echo "Created user #$id";

Read (SELECT)

PHP
<?php
// Single row by id
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);
$user = $stmt->fetch();
if ($user === false) {
    http_response_code(404);
    die("User not found");
}

// All rows
$users = $pdo->query("SELECT * FROM users ORDER BY name")->fetchAll();

// Filter
$stmt = $pdo->prepare(
    "SELECT * FROM users WHERE role = :role AND active = 1"
);
$stmt->execute([":role" => "editor"]);
$editors = $stmt->fetchAll();

// Count
$total = (int) $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn();

Update (UPDATE)

PHP
<?php
$stmt = $pdo->prepare(
    "UPDATE users
        SET name = :name, email = :email
      WHERE id = :id"
);

$stmt->execute([
    ":id"    => $id,
    ":name"  => $data["name"],
    ":email" => $data["email"],
]);

echo "Updated " . $stmt->rowCount() . " row(s)";

// Dynamic update - only changed fields
$allowed = ["name", "email", "role"];
$updates = [];
$params  = [":id" => $id];
foreach ($allowed as $field) {
    if (array_key_exists($field, $data)) {
        $updates[] = "$field = :$field";
        $params[":$field"] = $data[$field];
    }
}
if ($updates) {
    $sql = "UPDATE users SET " . implode(", ", $updates) . " WHERE id = :id";
    $pdo->prepare($sql)->execute($params);
}

Delete (DELETE)

PHP
<?php
// Hard delete
$stmt = $pdo->prepare("DELETE FROM users WHERE id = ?");
$stmt->execute([$id]);

if ($stmt->rowCount() === 0) {
    http_response_code(404);
    echo "User not found";
}

// Soft delete - safer for production
$pdo->prepare("UPDATE users SET deleted_at = NOW() WHERE id = ?")
    ->execute([$id]);

Pagination

PHP
<?php
$perPage = 20;
$page    = max(1, (int) ($_GET["page"] ?? 1));
$offset  = ($page - 1) * $perPage;

// Page of users (cast to int - LIMIT does not accept strings)
$stmt = $pdo->prepare(
    "SELECT * FROM users
      ORDER BY id DESC
      LIMIT :limit OFFSET :offset"
);
$stmt->bindValue(":limit",  $perPage, PDO::PARAM_INT);
$stmt->bindValue(":offset", $offset,  PDO::PARAM_INT);
$stmt->execute();
$users = $stmt->fetchAll();

// Total count for "Page X of Y"
$total      = (int) $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn();
$totalPages = (int) ceil($total / $perPage);
PHP
<?php
$q = trim($_GET["q"] ?? "");

// Escape LIKE wildcards in user input
$like = "%" . str_replace(["%", "_"], ["\%", "\_"], $q) . "%";

$stmt = $pdo->prepare(
    "SELECT id, name, email FROM users
      WHERE name LIKE :q OR email LIKE :q
      ORDER BY name LIMIT 50"
);
$stmt->execute([":q" => $like]);
$results = $stmt->fetchAll();

Repository Pattern

Wrap your CRUD calls in a class to keep controllers clean and tests fast:

PHPUserRepository.php
<?php
class UserRepository {
    public function __construct(private PDO $db) {}

    public function find(int $id): ?array {
        $s = $this->db->prepare("SELECT * FROM users WHERE id = ?");
        $s->execute([$id]);
        return $s->fetch() ?: null;
    }

    public function create(array $data): int {
        $s = $this->db->prepare(
            "INSERT INTO users (name, email) VALUES (:name, :email)"
        );
        $s->execute([
            ":name"  => $data["name"],
            ":email" => $data["email"],
        ]);
        return (int) $this->db->lastInsertId();
    }

    public function update(int $id, array $data): bool {
        $s = $this->db->prepare(
            "UPDATE users SET name = :name WHERE id = :id"
        );
        return $s->execute([":id" => $id, ":name" => $data["name"]]);
    }

    public function delete(int $id): bool {
        $s = $this->db->prepare("DELETE FROM users WHERE id = ?");
        return $s->execute([$id]);
    }
}

// Usage
$repo = new UserRepository($pdo);
$user = $repo->find(42);

Next Steps

Frequently Asked Questions

For larger apps yes - ORMs save boilerplate. For small projects or learning, raw PDO is faster to set up and easier to debug. Most senior PHP devs know both and choose based on project size and team familiarity.

Always use POST for destructive actions (with a CSRF token). Add an "Are you sure?" client-side confirmation. For irreversible deletes, consider soft-delete: a deleted_at timestamp column instead of a real DELETE.

Build the SQL dynamically from an array of allowed columns: $cols = array_intersect_key($data, array_flip($allowed));. This pattern is what underpins ORM create() methods.

After INSERT, call $pdo->lastInsertId() for the auto-increment key, then SELECT that row. PostgreSQL's RETURNING clause and MySQL's missing equivalent mean PDO doesn't standardize this.

Yes - the database is your last line of defense, not your first. Validate types, ranges, and business rules in PHP before reaching for SQL. Use NOT NULL, UNIQUE, and CHECK constraints in the schema as a safety net.