Skip to content

PHP Tutorial Database CRUD

Building a Simple PHP CRUD App

Lesson 279 of 369 2 min read By Deepak Paswan Updated Oct 5, 2026

Now we combine everything into one small, safe product manager. This lesson shows the architecture and the central file. You have seen each piece in the earlier lessons.

What it does

  • Lists products with search, a category filter, sorting and pagination.
  • Creates, edits and deletes products.
  • Validates input, escapes output, uses prepared statements, POST with a CSRF token, and Post/Redirect/Get.

Files

Text
PLAIN
product-manager/
├── db.php                  PDO connection (outside the web root if you can)
├── functions.php           h(), validateProduct(), paginate(), sortClause(), csrf helpers
├── ProductRepository.php   all SQL
├── index.php               the list page
├── product-new.php         create
├── product-edit.php        edit
└── product-delete.php      delete (POST only)

functions.php

functions.php
PHP
<?php

declare(strict_types=1);

function h(string $text): string
{
    return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
}

function csrfToken(): string
{
    $_SESSION["csrf_token"] ??= bin2hex(random_bytes(32));

    return $_SESSION["csrf_token"];
}

function csrfCheck(): void
{
    if (!hash_equals($_SESSION["csrf_token"] ?? "", (string) ($_POST["csrf_token"] ?? ""))) {
        http_response_code(403);
        exit("Invalid form token.");
    }
}

function paginate(int $total, int $page, int $perPage): array
{
    $pages = max(1, (int) ceil($total / $perPage));
    $page = min(max(1, $page), $pages);

    return ["page" => $page, "pages" => $pages, "offset" => ($page - 1) * $perPage];
}

function sortClause(string $column, string $direction): string
{
    $columns = ["name" => "name", "price" => "price", "stock" => "stock"];
    $directions = ["asc" => "ASC", "desc" => "DESC"];

    return "ORDER BY " . ($columns[$column] ?? "name") . " " . ($directions[strtolower($direction)] ?? "ASC") . ", id ASC";
}

function validateProduct(array $input): array
{
    $errors = [];

    $name = trim((string) ($input["name"] ?? ""));
    $price = filter_var($input["price"] ?? "", FILTER_VALIDATE_FLOAT);
    $stock = filter_var($input["stock"] ?? "", FILTER_VALIDATE_INT);
    $category = trim((string) ($input["category"] ?? ""));

    if ($name === "" || mb_strlen($name) > 150) {
        $errors["name"] = "Enter a name (up to 150 characters).";
    }
    if ($price === false || $price < 0) {
        $errors["price"] = "Enter a price of 0 or more.";
    }
    if ($stock === false || $stock < 0) {
        $errors["stock"] = "Enter a whole number of 0 or more.";
    }
    if ($category === "" || mb_strlen($category) > 50) {
        $errors["category"] = "Enter a category (up to 50 characters).";
    }

    return $errors;
}

ProductRepository.php

ProductRepository.php
PHP
<?php

declare(strict_types=1);

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

    /** @return array{rows: array, total: int} */
    public function list(string $search, string $category, string $order, int $limit, int $offset): array
    {
        $where = [];
        $params = [];

        if ($search !== "") {
            $where[] = "name LIKE :search ESCAPE '!'";
            $params["search"] = "%" . str_replace(["!", "%", "_"], ["!!", "!%", "!_"], $search) . "%";
        }

        if ($category !== "") {
            $where[] = "category = :category";
            $params["category"] = $category;
        }

        $whereSql = $where ? " WHERE " . implode(" AND ", $where) : "";

        $count = $this->pdo->prepare("SELECT COUNT(*) FROM products" . $whereSql);
        $count->execute($params);
        $total = (int) $count->fetchColumn();

        $statement = $this->pdo->prepare(
            "SELECT id, name, price, stock, category FROM products" . $whereSql . " " . $order . " LIMIT :limit OFFSET :offset"
        );
        foreach ($params as $key => $value) {
            $statement->bindValue(":" . $key, $value);
        }
        $statement->bindValue(":limit", $limit, PDO::PARAM_INT);
        $statement->bindValue(":offset", $offset, PDO::PARAM_INT);
        $statement->execute();

        return ["rows" => $statement->fetchAll(), "total" => $total];
    }

    public function categories(): array
    {
        return $this->pdo->query("SELECT DISTINCT category FROM products ORDER BY category")->fetchAll(PDO::FETCH_COLUMN);
    }

    public function find(int $id): ?array
    {
        $statement = $this->pdo->prepare("SELECT id, name, price, stock, category FROM products WHERE id = :id");
        $statement->execute(["id" => $id]);

        return $statement->fetch() ?: null;
    }

    public function create(string $name, float $price, int $stock, string $category): int
    {
        $this->pdo->prepare("INSERT INTO products (name, price, stock, category) VALUES (:name, :price, :stock, :category)")
            ->execute(["name" => $name, "price" => $price, "stock" => $stock, "category" => $category]);

        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, string $name, float $price, int $stock, string $category): void
    {
        $this->pdo->prepare("UPDATE products SET name = :name, price = :price, stock = :stock, category = :category WHERE id = :id")
            ->execute(["name" => $name, "price" => $price, "stock" => $stock, "category" => $category, "id" => $id]);
    }

    public function delete(int $id): bool
    {
        $statement = $this->pdo->prepare("DELETE FROM products WHERE id = :id");
        $statement->execute(["id" => $id]);

        return $statement->rowCount() === 1;
    }
}

Trying the repository

We can run the repository from the command line, with the sample data, to see that the search, filter, sort and pagination work together.

index.php
PHP
<?php
require __DIR__ . "/functions.php";
require __DIR__ . "/ProductRepository.php";

$repo = new ProductRepository(require __DIR__ . "/db.php");

$perPage = 2;

// first call: find the total, so we can clamp the page
$first = $repo->list("", "", sortClause("price", "desc"), $perPage, 0);
$p = paginate($first["total"], 2, $perPage);

$result = $repo->list("", "", sortClause("price", "desc"), $perPage, $p["offset"]);

echo "Page {$p['page']} of {$p['pages']} (total {$result['total']})\n";
foreach ($result["rows"] as $row) {
    echo $row["name"] . "\n";
}

echo "---\n";

$writing = $repo->list("", "writing", sortClause("name", "asc"), 10, 0);
echo implode(", ", array_column($writing["rows"], "name")) . "\n";

$search = $repo->list("ar", "", sortClause("name", "asc"), 10, 0);
echo implode(", ", array_column($search["rows"], "name"));
Output
OUTPUT
Page 2 of 3 (total 5)
Notebook
Marker
---
Marker, Pen
Diary, Marker

index.php: the list page

index.php
PHP
<?php
declare(strict_types=1);

session_start();

require __DIR__ . "/functions.php";
require __DIR__ . "/ProductRepository.php";

$repo = new ProductRepository(require __DIR__ . "/db.php");

$search = trim((string) ($_GET["q"] ?? ""));
$category = trim((string) ($_GET["category"] ?? ""));
$sort = (string) ($_GET["sort"] ?? "name");
$dir = (string) ($_GET["dir"] ?? "asc");
$perPage = 10;
$order = sortClause($sort, $dir);

$first = $repo->list($search, $category, $order, $perPage, 0);
$p = paginate($first["total"], (int) ($_GET["page"] ?? 1), $perPage);
$result = $p["offset"] === 0 ? $first : $repo->list($search, $category, $order, $perPage, $p["offset"]);

$flash = $_SESSION["flash"] ?? null;
unset($_SESSION["flash"]);
?>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Products</title>
</head>
<body>
  <h1>Products</h1>

  <?php if ($flash): ?><p role="status"><?= h($flash) ?></p><?php endif; ?>

  <form method="get" role="search">
    <label for="q">Search</label>
    <input type="search" id="q" name="q" value="<?= h($search) ?>">

    <label for="category">Category</label>
    <select id="category" name="category">
      <option value="">All</option>
      <?php foreach ($repo->categories() as $c): ?>
        <option value="<?= h($c) ?>" <?= $c === $category ? "selected" : "" ?>><?= h($c) ?></option>
      <?php endforeach; ?>
    </select>

    <button type="submit">Apply</button>
  </form>

  <p><a href="product-new.php">Add a product</a></p>

  <?php if ($result["rows"] === []): ?>
    <p>No products found.</p>
  <?php else: ?>
    <table>
      <thead>
        <tr>
          <th><a href="?<?= h(http_build_query([...$_GET, "sort" => "name", "dir" => $sort === "name" && $dir === "asc" ? "desc" : "asc"])) ?>">Name</a></th>
          <th><a href="?<?= h(http_build_query([...$_GET, "sort" => "price", "dir" => $sort === "price" && $dir === "asc" ? "desc" : "asc"])) ?>">Price</a></th>
          <th>Stock</th>
          <th><span class="visually-hidden">Actions</span></th>
        </tr>
      </thead>
      <tbody>
        <?php foreach ($result["rows"] as $row): ?>
          <tr>
            <td><?= h($row["name"]) ?></td>
            <td><?= number_format((float) $row["price"], 2) ?></td>
            <td><?= (int) $row["stock"] ?></td>
            <td>
              <a href="product-edit.php?id=<?= (int) $row["id"] ?>">Edit</a>
              <form method="post" action="product-delete.php" style="display:inline" onsubmit="return confirm('Delete this product?');">
                <input type="hidden" name="id" value="<?= (int) $row["id"] ?>">
                <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
                <button type="submit">Delete</button>
              </form>
            </td>
          </tr>
        <?php endforeach; ?>
      </tbody>
    </table>

    <nav aria-label="Pagination">
      <?php if ($p["page"] > 1): ?>
        <a href="?<?= h(http_build_query([...$_GET, "page" => $p["page"] - 1])) ?>">Previous</a>
      <?php endif; ?>
      Page <?= $p["page"] ?> of <?= $p["pages"] ?>
      <?php if ($p["page"] < $p["pages"]): ?>
        <a href="?<?= h(http_build_query([...$_GET, "page" => $p["page"] + 1])) ?>">Next</a>
      <?php endif; ?>
    </nav>
  <?php endif; ?>
</body>
</html>

The create, edit and delete pages are the ones you saw in Create, Update and Delete. They use the same helpers.

A checklist before you ship it

  • Every value goes through a placeholder. The only SQL built from text is the ORDER BY from an allow-list.
  • Every printed value is escaped.
  • Changes use POST with a CSRF token, then redirect.
  • Missing rows answer 404.
  • There is no login yet: anyone can edit. The next chapter adds authentication, and then you protect these pages.

What to improve next

  • Require login before create, edit and delete.
  • Add a description and an image (see Secure File Uploads).
  • Move the page layout into a header and footer file.
  • Show product images and a nicer design with CSS.
info
Why this matters in Laravel and WordPress
This is the shape of every admin panel. Laravel generates it from a few commands. WordPress’s own admin lists (posts, users) use the same search, filter, sort and pagination ideas, and the WP_List_Table class builds the tables.