Skip to content

PHP Tutorial Practical PHP Projects

Project 8: Product Catalogue with Categories

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

Level: intermediate to advanced. This project adds relations: every product belongs to a category. You will use a JOIN, a foreign key, and a safe stock update.

The tables

query.sql
SQL
CREATE TABLE categories (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(80) NOT NULL,
    slug VARCHAR(100) NOT NULL UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE items (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    category_id INT UNSIGNED NOT NULL,
    name VARCHAR(150) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    stock INT NOT NULL DEFAULT 0,
    image VARCHAR(80) NULL,
    FOREIGN KEY (category_id) REFERENCES categories(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Each item has a category_id. The foreign key stops you from creating an item in a category that does not exist.

The repository

CatalogRepository.php
PHP
<?php

declare(strict_types=1);

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

    public function categories(): array
    {
        return $this->pdo->query(
            "SELECT c.id, c.name, c.slug, COUNT(i.id) AS item_count
               FROM categories c
          LEFT JOIN items i ON i.category_id = c.id
           GROUP BY c.id, c.name, c.slug
           ORDER BY c.name"
        )->fetchAll();
    }

    public function items(?string $categorySlug = null): array
    {
        $sql = "SELECT i.id, i.name, i.price, i.stock, c.name AS category
                  FROM items i
                  JOIN categories c ON c.id = i.category_id";
        $params = [];

        if ($categorySlug !== null) {
            $sql .= " WHERE c.slug = :slug";
            $params["slug"] = $categorySlug;
        }

        $stmt = $this->pdo->prepare($sql . " ORDER BY i.name");
        $stmt->execute($params);

        return $stmt->fetchAll();
    }

    public function create(int $categoryId, string $name, float $price, int $stock): int
    {
        if (!$this->categoryExists($categoryId)) {
            throw new InvalidArgumentException("The category does not exist.");
        }

        if (trim($name) === "" || $price < 0 || $stock < 0) {
            throw new InvalidArgumentException("Check the name, price and stock.");
        }

        $this->pdo->prepare("INSERT INTO items (category_id, name, price, stock) VALUES (:category, :name, :price, :stock)")
            ->execute(["category" => $categoryId, "name" => trim($name), "price" => $price, "stock" => $stock]);

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

    /** Takes stock away only if enough is available. Returns false when there is not. */
    public function sell(int $itemId, int $quantity): bool
    {
        if ($quantity < 1) {
            throw new InvalidArgumentException("Quantity must be at least 1.");
        }

        $stmt = $this->pdo->prepare("UPDATE items SET stock = stock - :qty WHERE id = :id AND stock >= :needed");
        $stmt->execute(["qty" => $quantity, "id" => $itemId, "needed" => $quantity]);

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

    public function lowStock(int $threshold = 5): array
    {
        $stmt = $this->pdo->prepare("SELECT name, stock FROM items WHERE stock <= :threshold ORDER BY stock, name");
        $stmt->bindValue(":threshold", $threshold, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll();
    }

    public function inventoryValue(): float
    {
        return (float) $this->pdo->query("SELECT COALESCE(SUM(price * stock), 0) FROM items")->fetchColumn();
    }

    private function categoryExists(int $id): bool
    {
        $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM categories WHERE id = :id");
        $stmt->execute(["id" => $id]);

        return (int) $stmt->fetchColumn() === 1;
    }
}

Two points to notice:

  • LEFT JOIN keeps categories that have no items (their count is 0). A plain JOIN would hide them.
  • sell() subtracts stock inside the SQL, with the condition stock >= :needed. Two customers buying the last item at the same moment cannot both succeed: the second update changes zero rows.

Test it

index.php
PHP
<?php
require __DIR__ . "/CatalogRepository.php";

$catalog = new CatalogRepository(require __DIR__ . "/db.php");

foreach ($catalog->categories() as $c) {
    echo $c["name"] . ": " . $c["item_count"] . " item(s)\n";
}

echo "--\n";
echo implode(", ", array_column($catalog->items("stationery"), "name")) . "\n";
echo implode(", ", array_map(fn($i) => $i["name"] . " (" . $i["category"] . ")", $catalog->items())) . "\n";

$id = $catalog->create(2, "SQL Basics", 299.0, 3);
echo "created item $id\n";

try {
    $catalog->create(99, "Ghost", 1, 1);
} catch (InvalidArgumentException $e) {
    echo $e->getMessage() . "\n";
}

var_dump($catalog->sell($id, 2));
var_dump($catalog->sell($id, 2));       // only 1 left
var_dump($catalog->sell(4, 1));         // the backpack has 0 in stock

echo implode(", ", array_map(fn($i) => $i["name"] . "=" . $i["stock"], $catalog->lowStock())) . "\n";
echo "inventory value: " . $catalog->inventoryValue();
Output
OUTPUT
Bags: 1 item(s)
Books: 1 item(s)
Stationery: 2 item(s)
--
Marker, Pen
Atlas (Books), Backpack (Bags), Marker (Stationery), Pen (Stationery)
created item 5
The category does not exist.
bool(true)
bool(false)
bool(false)
Backpack=0, SQL Basics=1
inventory value: 5649

The inventory value is the sum of price x stock over all items: 12 x 100 + 45 x 30 + 350 x 8 + 299 x 1 + 999 x 0 = 5649. (The new item had 3 in stock, and sell() took 2.)

The catalogue page

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

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

$catalog = new CatalogRepository(require __DIR__ . "/db.php");

$categories = $catalog->categories();
$slugs = array_column($categories, "slug");
$slug = in_array($_GET["category"] ?? "", $slugs, true) ? $_GET["category"] : null;

$items = $catalog->items($slug);
?>
<h1>Catalogue</h1>

<nav aria-label="Categories">
  <a href="index.php">All</a>
  <?php foreach ($categories as $c): ?>
    | <a href="?category=<?= h($c["slug"]) ?>"><?= h($c["name"]) ?> (<?= (int) $c["item_count"] ?>)</a>
  <?php endforeach; ?>
</nav>

<ul>
  <?php foreach ($items as $i): ?>
    <li>
      <strong><?= h($i["name"]) ?></strong> in <?= h($i["category"]) ?>:
      <?= number_format((float) $i["price"], 2) ?>
      <?= (int) $i["stock"] > 0 ? "(" . (int) $i["stock"] . " in stock)" : "(sold out)" ?>
    </li>
  <?php endforeach; ?>
</ul>

The category filter is checked against the list of real slugs from the database, so unknown values are ignored.

Images

Store only the file name (the random name from the file upload project) in the image column, and keep the files outside the web root. Serve them through a small script, or copy approved, resized images to a public folder.

What you practised

  • Foreign keys, JOIN and LEFT JOIN with GROUP BY.
  • Safe stock updates in a single SQL statement.
  • Validation that checks related data (the category exists).
  • Using values from the database to validate the URL.

Challenges

  1. Add category management (create, rename, delete only if empty).
  2. Add a shopping cart in the session, and an order table with transactions.
  3. Add search, sorting and pagination from the CRUD chapter.
  4. Add product images with thumbnails.