First PHP Projects

Small CRUD App With PDO

bin/migrate.php
migrations/001_create_products.sql
public/products.php
public/product-create.php
public/product-edit.php
public/product-delete.php
src/Database.php
src/ProductInput.php
src/ProductRepository.php
storage/app.sqlite

SQLite keeps the first database project local. The SQL and lastInsertId() details in this lesson are therefore SQLite-specific; another PDO driver can require different schema and identifier behavior.

Create And Apply The Schema

Put this migration in migrations/001_create_products.sql:

CREATE TABLE products (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
    price_cents INTEGER NOT NULL CHECK (price_cents BETWEEN 0 AND 99999999),
    status TEXT NOT NULL CHECK (status IN ('draft', 'published'))
);

Create PDO in src/Database.php:

PHP example
<?php

declare(strict_types=1);

function openDatabase(string $path): PDO
{
    $pdo = new PDO('sqlite:' . $path, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_STRINGIFY_FETCHES => false,
    ]);
    $pdo->exec('PRAGMA foreign_keys = ON');

    return $pdo;
}

ERRMODE_EXCEPTION prevents ignored SQL failures. An explicit fetch mode keeps repository methods predictable. Apply the migration once from bin/migrate.php:

PHP example
<?php

declare(strict_types=1);

// no-execute: creates the project database and requires local project files.
require dirname(__DIR__) . '/src/Database.php';

$pdo = openDatabase(dirname(__DIR__) . '/storage/app.sqlite');
$sql = file_get_contents(dirname(__DIR__) . '/migrations/001_create_products.sql');
if ($sql === false) {
    throw new RuntimeException('Migration could not be read.');
}
$pdo->exec($sql);
echo "Migration applied.\n";

Keep SQL In A Repository

Put the complete persistence boundary in src/ProductRepository.php:

PHP example
<?php

declare(strict_types=1);

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

    public function page(int $limit, int $offset): array
    {
        $statement = $this->pdo->prepare(
            'SELECT id, name, price_cents, status
             FROM products ORDER BY id DESC LIMIT :limit OFFSET :offset'
        );
        $statement->bindValue(':limit', $limit, PDO::PARAM_INT);
        $statement->bindValue(':offset', $offset, PDO::PARAM_INT);
        $statement->execute();

        return $statement->fetchAll();
    }

    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();

        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,
        ]);

        $id = $this->pdo->lastInsertId();
        if ($id === false) {
            throw new RuntimeException('Inserted product ID was unavailable.');
        }

        return (int) $id;
    }

    public function update(int $id, string $name, int $priceCents, string $status): bool
    {
        $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,
        ]);

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

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

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

Bind LIMIT and OFFSET as integers rather than interpolating request text. Every value in a data predicate is also bound. The handler still validates the ID before calling the repository.

Parse Money Without Floating Point

HTTP form values are strings. Convert a decimal price to integer cents without passing through a binary float. Put this in src/ProductInput.php:

PHP example
<?php

declare(strict_types=1);

function parsePriceCents(string $price): ?int
{
    if (preg_match('/\A(0|[1-9][0-9]*)(?:\.([0-9]{1,2}))?\z/', $price, $matches) !== 1) {
        return null;
    }

    $whole = (int) $matches[1];
    $fraction = str_pad($matches[2] ?? '', 2, '0');
    if ($whole > 999_999) {
        return null;
    }

    $cents = ($whole * 100) + (int) $fraction;

    return $cents <= 99_999_999 ? $cents : null;
}

function validateProduct(array $input): array
{
    $name = is_string($input['name'] ?? null) ? trim($input['name']) : '';
    $price = is_string($input['price'] ?? null) ? trim($input['price']) : '';
    $status = is_string($input['status'] ?? null) ? $input['status'] : '';
    $errors = [];

    if ($name === '' || strlen($name) > 100) {
        $errors['name'] = 'Enter a name up to 100 bytes.';
    }
    $priceCents = parsePriceCents($price);
    if ($priceCents === null) {
        $errors['price'] = 'Enter a price from 0.00 to 999999.99.';
    }
    if (!in_array($status, ['draft', 'published'], true)) {
        $errors['status'] = 'Choose draft or published.';
    }

    return [
        ['name' => $name, 'price' => $price, 'price_cents' => $priceCents, 'status' => $status],
        $errors,
    ];
}

Build One Complete HTTP Slice

For POST /product-create.php, reuse the configured session and CSRF helper from the contact project. Then validate, insert, and redirect:

PHP example
<?php

declare(strict_types=1);

// no-execute: requires the project bootstrap, active session, and HTTP request.
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
    header('Allow: POST');
    http_response_code(405);
    exit('Method Not Allowed');
}

$token = $_POST['_csrf'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
    http_response_code(403);
    exit('Invalid request token.');
}

[$values, $errors] = validateProduct($_POST);
if ($errors !== []) {
    http_response_code(422);
    require dirname(__DIR__) . '/templates/product-form.php';
    exit;
}

$id = $repository->insert($values['name'], $values['price_cents'], $values['status']);
header('Location: /product-edit.php?id=' . $id, true, 303);
exit;

The edit handler first parses a positive integer ID with filter_var($rawId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]), loads the row, and returns 404 when absent. Update and delete are POST actions protected by both authorization and CSRF. Each successful write redirects with 303.

For GET /products.php, clamp page to a positive integer, use a fixed page size such as 20, calculate the offset, and call page(20, $offset). Escape product names and statuses in the template. Format cents for display with number_format($priceCents / 100, 2, '.', ''); do not use the formatted value for arithmetic or persistence.

Verify The Repository And Browser Flow

Run a repository script against an in-memory SQLite database. It should apply the schema, insert, find, update, page, and delete while asserting each result. Then verify create, rejected validation, edit, missing ID, delete, bad CSRF, and HTML escaping through HTTP.

Practice

Practice: Build A Product CRUD App

Implement the complete SQLite and PDO product manager from the lesson.

Requirements

  • Create PDO with exception mode, associative fetches, and native scalar types.
  • Apply the schema migration explicitly.
  • Parse decimal prices into integer cents without floating-point persistence.
  • Reject array-shaped, missing, overlong, or invalid form values.
  • List products in stable order with a fixed page size and bound integer pagination.
  • Create, find, update, and delete through prepared statements.
  • Parse route IDs as positive integers and return 404 for missing rows.
  • Require POST, authorization, and CSRF for every state-changing action.
  • Redirect with 303 after successful writes.
  • Escape database values only when rendering HTML.
  • Keep SQL out of request handlers and templates.

Add a repository verification script using SQLite :memory:. It must apply the schema and assert insert, find, update, bounded list, delete, and missing-row behavior. Also verify normal and rejected browser requests.

Show solution

Use openDatabase(), ProductRepository, and the input functions from the lesson. Implement update and delete as POST-only handlers. Load the record before rendering an edit form; after validation, treat a failed update as 404 because the row may have been removed concurrently.

The required repository verification can be a plain PHP script:

PHP example
<?php

declare(strict_types=1);

// no-execute: requires Database.php, ProductRepository.php, and the migration file.
require dirname(__DIR__) . '/src/Database.php';
require dirname(__DIR__) . '/src/ProductRepository.php';

$pdo = openDatabase(':memory:');
$sql = file_get_contents(dirname(__DIR__) . '/migrations/001_create_products.sql');
if ($sql === false) {
    throw new RuntimeException('Migration could not be read.');
}
$pdo->exec($sql);
$products = new ProductRepository($pdo);

$id = $products->insert('Notebook', 1299, 'draft');
assert($products->find($id)['price_cents'] === 1299);
assert($products->update($id, 'Notebook Pro', 1599, 'published'));
assert($products->find($id)['name'] === 'Notebook Pro');
assert(count($products->page(20, 0)) === 1);
assert($products->delete($id));
assert($products->find($id) === null);
assert(!$products->delete($id));

echo "Repository lifecycle passed.\n";

Run assertions explicitly so production configuration cannot disable them silently:

php -d zend.assertions=1 -d assert.exception=1 bin/verify-products.php
php -S localhost:8000 -t public

For browser verification, submit prices such as 12, 12.5, and 12.50, then reject 12.345, -1, scientific notation, an array-shaped price, and values beyond the schema maximum. Verify that an unchanged or concurrently removed record produces a deliberate outcome rather than an unexplained blank page.