first php projects

Small CRUD App With PDO

Start With One Schema

Create migrations/001_create_products.sql:

CREATE TABLE products (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
    status TEXT NOT NULL CHECK (status IN ('draft', 'published'))
);

Use integer cents for money in this small project. The database constraint supports the PHP validation instead of replacing it.

Add A Repository

Put SQL in src/ProductRepository.php:

PHP example
<?php

declare(strict_types=1);

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

    public function find(int $id): ?array
    {
        $statement = $this->pdo->prepare(
            'SELECT id, name, price_cents, status FROM products WHERE id = :id'
        );
        $statement->execute(['id' => $id]);
        $row = $statement->fetch(PDO::FETCH_ASSOC);

        return $row === false ? null : $row;
    }

    public function insert(string $name, int $priceCents, string $status): int
    {
        $statement = $this->pdo->prepare(
            'INSERT INTO products (name, price_cents, status)
             VALUES (:name, :price_cents, :status)'
        );
        $statement->execute([
            'name' => $name,
            'price_cents' => $priceCents,
            'status' => $status,
        ]);

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

Add list(), update(), and delete() in the same style. Use a small limit for the list route so a large table is not loaded without bounds.

Add HTTP Routes One At A Time

Build vertical slices:

  1. GET /products lists escaped product rows.
  2. GET /products/create renders the form.
  3. POST /products validates and inserts.
  4. GET /products/{id}/edit loads one row or returns 404.
  5. POST /products/{id} validates and updates.
  6. POST /products/{id}/delete checks CSRF and deletes.

Do not use GET for deletion. Do not concatenate route values into SQL. Parse the ID as an integer and bind it.

Verify Before Adding More Features

Check successful create, edit, and delete flows. Then check an empty name, negative price, unknown status, missing row, bad CSRF token, and HTML characters in a product name.

Practice

Practice: Build A Product CRUD App

Implement the PDO-backed product manager increment from the lesson.

Requirements

  • List products with pagination or a small limit.
  • Create and update products through prepared statements.
  • Delete through an explicit POST action with CSRF protection.
  • Return not-found and validation outcomes clearly.
  • Never concatenate request values into SQL.
  • Do not use GET for deletion.
  • Keep persistence code out of templates.

Add one repository-level verification script or test that inserts, reads, updates, and deletes a product. Exercise the browser routes for normal and rejected requests.

Show solution
PHP example
<?php

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

    public function update(int $id, string $name, int $priceCents, string $status): void
    {
        $statement = $this->pdo->prepare(
            'UPDATE products
             SET name = :name, price_cents = :price_cents, status = :status
             WHERE id = :id'
        );
        $statement->execute([
            'id' => $id,
            'name' => $name,
            'price_cents' => $priceCents,
            'status' => $status,
        ]);
    }

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

Keep validation before repository calls, escape values when rendering, and require POST plus CSRF protection for deletion. Verify success, invalid input, unknown ID, and rejected-token paths.