Read shows data. A typical app has two pages: a list of rows and a detail page for one row.
Repository methods
<?php
final class ProductRepository
{
public function __construct(private PDO $pdo) {}
/** @return array<int, array<string, mixed>> */
public function all(): array
{
return $this->pdo
->query("SELECT id, name, price, stock, category FROM products ORDER BY id")
->fetchAll();
}
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;
}
}
$repo = new ProductRepository(require __DIR__ . "/db.php");
foreach ($repo->all() as $p) {
echo $p["id"] . " " . $p["name"] . "\n";
}
var_dump($repo->find(999));
1 Notebook
2 Pen
3 Bag
4 Marker
5 Diary
NULL
find() returns null when no row exists, so the caller can show a “not found” page.
The list page: index.php
<?php
declare(strict_types=1);
require __DIR__ . "/ProductRepository.php";
require __DIR__ . "/functions.php";
$repo = new ProductRepository(require __DIR__ . "/db.php");
$products = $repo->all();
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Products</title></head>
<body>
<h1>Products</h1>
<p><a href="product-new.php">Add a product</a></p>
<?php if ($products === []): ?>
<p>No products yet.</p>
<?php else: ?>
<table>
<thead>
<tr><th>Name</th><th>Price</th><th>Stock</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($products as $p): ?>
<tr>
<td><a href="product.php?id=<?= (int) $p["id"] ?>"><?= h($p["name"]) ?></a></td>
<td><?= number_format((float) $p["price"], 2) ?></td>
<td><?= (int) $p["stock"] ?></td>
<td><a href="product-edit.php?id=<?= (int) $p["id"] ?>">Edit</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</body>
</html>
The page always handles the empty case (“No products yet”), because a new app starts with no data.
The detail page: product.php
<?php
declare(strict_types=1);
require __DIR__ . "/ProductRepository.php";
require __DIR__ . "/functions.php";
$id = filter_var($_GET["id"] ?? null, FILTER_VALIDATE_INT);
$repo = new ProductRepository(require __DIR__ . "/db.php");
$product = $id === false ? null : $repo->find($id);
if ($product === null) {
http_response_code(404);
exit("Product not found.");
}
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title><?= h($product["name"]) ?></title></head>
<body>
<h1><?= h($product["name"]) ?></h1>
<p>Price: <?= number_format((float) $product["price"], 2) ?></p>
<p>In stock: <?= (int) $product["stock"] ?></p>
<p><a href="index.php">Back to the list</a></p>
</body>
</html>
Three important habits
1. Validate the id. $_GET["id"] is a string from the URL. filter_var(..., FILTER_VALIDATE_INT) gives an integer or false.
2. Send a real 404. A missing record should return the status 404, not an empty page with 200 OK. Search engines and monitoring depend on it.
3. Escape every value. h($p["name"]) for text. (int) $p["id"] for numbers in URLs. Data in the database can contain anything a visitor once typed.
Show only what the user may see
If the data has an owner or a status, filter by it in the SQL (WHERE status = 'published' or WHERE user_id = :user). Never fetch everything and hide it in HTML.
What about huge lists?
all() loads every row. That is fine for ten products and a disaster for a million. The next lessons add search, pagination and sorting.
Use meaningful URLs later
product.php?id=3 works. Many sites use readable URLs (/products/notebook) through a router. Routing Concepts explains how.