Create has two parts: show a form, then process what the visitor sends.
The repository method
<?php
final class ProductRepository
{
public function __construct(private PDO $pdo) {}
public function create(string $name, float $price, int $stock, string $category): int
{
$statement = $this->pdo->prepare(
"INSERT INTO products (name, price, stock, category) VALUES (:name, :price, :stock, :category)"
);
$statement->execute([
"name" => $name,
"price" => $price,
"stock" => $stock,
"category" => $category,
]);
return (int) $this->pdo->lastInsertId();
}
}
$repo = new ProductRepository(require __DIR__ . "/db.php");
$id = $repo->create("Stapler", 120, 15, "office");
echo "Created product $id";
Created product 6
The method returns the new id, so the page can redirect to the new product.
Validation as a function
<?php
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;
}
print_r(validateProduct(["name" => "", "price" => "abc", "stock" => "5", "category" => "office"]));
Array
(
[name] => Enter a name (up to 150 characters).
[price] => Enter a price of 0 or more.
)
The page: product-new.php
<?php
declare(strict_types=1);
session_start();
require __DIR__ . "/ProductRepository.php";
require __DIR__ . "/functions.php"; // h() and validateProduct()
$pdo = require __DIR__ . "/db.php";
$repo = new ProductRepository($pdo);
$old = ["name" => "", "price" => "", "stock" => "0", "category" => "general"];
$errors = [];
if ($_SERVER["REQUEST_METHOD"] === "POST") {
// 1. CSRF check (see the CSRF lesson)
if (!hash_equals($_SESSION["csrf_token"] ?? "", $_POST["csrf_token"] ?? "")) {
http_response_code(403);
exit("Invalid form token.");
}
$old = array_map(fn($v) => is_string($v) ? trim($v) : "", array_intersect_key($_POST, $old));
$errors = validateProduct($old);
if ($errors === []) {
$id = $repo->create($old["name"], (float) $old["price"], (int) $old["stock"], $old["category"]);
$_SESSION["flash"] = "Product created.";
header("Location: product.php?id=" . $id);
exit;
}
}
$_SESSION["csrf_token"] ??= bin2hex(random_bytes(32));
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Add product</title></head>
<body>
<h1>Add product</h1>
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="<?= h($_SESSION["csrf_token"]) ?>">
<p>
<label for="name">Name</label><br>
<input id="name" name="name" value="<?= h($old["name"]) ?>" required>
<?php if (isset($errors["name"])): ?><br><span role="alert"><?= h($errors["name"]) ?></span><?php endif; ?>
</p>
<p>
<label for="price">Price</label><br>
<input id="price" name="price" inputmode="decimal" value="<?= h($old["price"]) ?>" required>
<?php if (isset($errors["price"])): ?><br><span role="alert"><?= h($errors["price"]) ?></span><?php endif; ?>
</p>
<p>
<label for="stock">Stock</label><br>
<input id="stock" name="stock" inputmode="numeric" value="<?= h($old["stock"]) ?>">
<?php if (isset($errors["stock"])): ?><br><span role="alert"><?= h($errors["stock"]) ?></span><?php endif; ?>
</p>
<p>
<label for="category">Category</label><br>
<input id="category" name="category" value="<?= h($old["category"]) ?>">
<?php if (isset($errors["category"])): ?><br><span role="alert"><?= h($errors["category"]) ?></span><?php endif; ?>
</p>
<button type="submit">Save product</button>
</form>
</body>
</html>
What the page does
- GET: shows an empty form (with a fresh CSRF token).
- POST: checks the token, reads only the four fields we expect (
array_intersect_key), trims them and validates. - If there are errors: shows the form again with the old values and messages.
- If valid: inserts, stores a flash message and redirects to the new product’s page.
Things to notice
- The form posts to itself (no
action), so errors can be shown right away. - Only the expected fields are used. A visitor who adds
is_admin=1changes nothing. - The price is read as text and converted only after validation.
h()is a short helper forhtmlspecialchars(see Header and Footer Components and Output Escaping).
Exercise: add a description
Add a description TEXT column (nullable) to the table, and a textarea to the form. Which three places need a change?
Show the answer
- The table:
ALTER TABLE products ADD COLUMN description TEXT NULL;. - The repository: add
descriptionto theINSERTstatement and the parameters. - The page: add the field to
$old, add a<textarea>withh($old["description"])between the tags, and (optionally) a length check invalidateProduct().