Practice 10 - PHP: CRUD with a Book Wishlist
Introduction
CRUD stands for the four fundamental operations any data-driven application needs:
| Letter | Operation | HTTP analogy | PHP file |
|---|---|---|---|
| C | Create | POST | new.php |
| R | Read | GET | index.php, show.php |
| U | Update | POST | edit.php |
| D | Delete | GET (link) | delete.php |
This session builds a complete book wishlist app using a JSON file as the data store — no database required. You will see how these files connect to each other through links and form actions, and how every operation follows the same read → modify → write → redirect pattern.
1. JSON as a flat-file database
Instead of a real database, we store data in data.json:
[
{
"title": "Pride and Prejudice",
"author": "Jane Asten",
"year": 1813,
"read": false
},
{
"title": "To Kill a Mockingbird",
"author": "Harper Lee",
"year": 1960,
"read": false
}
]
PHP reads and writes this file with two functions:
| PHP | What it does |
|---|---|
file_get_contents("data.json") | Read the entire file as a string |
json_decode($str, true) | Parse JSON string → PHP array (true = associative array) |
json_encode($data, JSON_PRETTY_PRINT) | PHP array → JSON string (formatted) |
file_put_contents("data.json", $str) | Write string back to the file |
The full read-modify-write cycle used across every operation:
// 1. Read
$books = json_decode(file_get_contents("data.json"), true);
// 2. Modify
$books[] = [...]; // Create
$books[$id] = [...]; // Update
unset($books[$id]); // Delete
// 3. Write
file_put_contents("data.json", json_encode($books, JSON_PRETTY_PRINT));
2. Read all — index.php
index.php loads every book from data.json and lists them as links to show.php:
<?php
$books = json_decode(file_get_contents('data.json'), true);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Book Wishlist</title>
</head>
<body>
<h1>Book Wishlist</h1>
<ul>
<?php foreach ($books as $id => $book): ?>
<li>
<a href="show.php?id=<?= $id ?>">
<?= $book["title"] ?> - <?= $book["author"] ?> (<?= $book['year'] ?>)
</a>
</li>
<?php endforeach; ?>
</ul>
</body>
</html>
Alternative foreach syntax
Inside HTML templates, PHP offers an alternative syntax for control structures that is easier to read than mixing curly-brace blocks with HTML:
<?php foreach ($books as $id => $book): ?>
<!-- HTML here -->
<?php endforeach; ?>
This is exactly the same as foreach (...) { ... } — just a stylistic choice for mixed PHP/HTML files. The same alternative syntax exists for if / endif, for / endfor, while / endwhile.
Array indexes ($id) become the ?id= value in the link. The browser will request show.php?id=0, show.php?id=1, etc.
3. Read one — show.php
show.php reads the id from the query string and displays a single book:
<?php
$books = json_decode(file_get_contents('data.json'), true);
$id = $_GET['id'] ?? null;
if (!isset($books[$id])) {
header('location: index.php');
exit();
}
$book = $books[$id];
?>
header() — redirect
header("location: index.php") sends an HTTP redirect response to the browser, which then navigates to index.php. Always call exit() immediately after — without it PHP would continue executing code below the redirect.
header("location: index.php");
exit(); // stops execution here
This guard pattern — redirect and exit if the requested resource does not exist — is used in show.php, edit.php, and delete.php. It is the PHP equivalent of returning a 404 or navigation guard.
<body>
<h1><?= $book["title"] ?></h1>
<p><strong>Author:</strong> <?= $book["author"] ?></p>
<p><strong>Year:</strong> <?= $book["year"] ?></p>
<hr>
<a href="edit.php?id=<?= $id ?>">Edit</a>
<a href="delete.php?id=<?= $id ?>">Delete</a>
<a href="index.php">Back to list</a>
</body>
4. Create — new.php
new.php handles both displaying the empty form (first visit) and processing the submitted data (POST).
Validation
<?php
$errors = [];
if ($_POST) {
$title = trim($_POST["title"]);
if ($title == '') {
$errors["title"] = "Title is required";
}
$author = trim($_POST["author"]);
if ($author == '') {
$errors["author"] = "Author is required";
}
$year = trim($_POST["year"]);
if (filter_var($year, FILTER_VALIDATE_INT) == false || $year < 1000 || $year > 2026) {
$errors["year"] = "Year must be an integer between 1000 and 2026";
}
$read = isset($_POST['read']);
The year validation chains two conditions: filter_var checks that the string is a valid integer at all, then the range check follows. Both must pass.
isset($_POST['read']) returns true if the checkbox was ticked (the key exists in $_POST) and false if it was not (the key is absent). This stores a clean boolean rather than the raw "on" string.
Saving and redirecting
if (count($errors) == 0) {
$books = json_decode(file_get_contents("data.json"), true);
$books[] = [
"title" => $title,
"author" => $author,
"year" => intval($year),
"read" => $read,
];
file_put_contents("data.json", json_encode($books, JSON_PRETTY_PRINT));
header("location: index.php");
exit();
}
}
?>
$books[] appends a new element (like Array.push() in JavaScript).
intval($year) converts the string "1960" to the integer 1960, so it is stored as a number in JSON rather than a string. JavaScript equivalent: Number(year).
The redirect happens only when there are no errors. If there are errors, execution falls through to the HTML below, which re-renders the form with the error messages and sticky values.
The form with sticky values
<form action="new.php" method="POST">
<label>
Title:
<input type="text" name="title" value="<?= $title ?? '' ?>" />
<?= $errors['title'] ?? '' ?>
</label>
<br>
<label>
Author:
<input type="text" name="author" value="<?= $author ?? '' ?>" />
<?= $errors['author'] ?? '' ?>
</label>
<br>
<label>
Year:
<input type="number" name="year" value="<?= $year ?? '' ?>" />
<?= $errors['year'] ?? '' ?>
</label>
<br>
<label>
<input type="checkbox" name="read"
<?= isset($read) ? "checked" : "" ?> />
Already read
</label>
<br>
<button type="submit">Add book</button>
</form>
On first visit $title, $author, $year, and $read are all undefined → ?? '' provides empty fallbacks and the form appears blank.
After a failed submission the variables hold the trimmed submitted values → they are echoed back into the inputs.
For the checkbox, isset($read) checks whether $read was assigned during POST handling. If it was assigned and is true, "checked" is output; otherwise nothing, so the checkbox is unchecked.
5. Update — edit.php
Edit is new.php with two additions:
- It reads the existing record from
data.jsonfirst, to display current values before any POST - On save it replaces the existing entry at
$idinstead of appending
<?php
$books = json_decode(file_get_contents('data.json'), true);
$id = $_GET['id'] ?? null;
if (!isset($books[$id])) {
header('location: index.php');
exit();
}
$book = $books[$id];
$errors = [];
if ($_POST) {
// ... same validation as new.php ...
if (count($errors) == 0) {
$books[$id] = [ // overwrite the entry at this key
"title" => $title,
"author" => $author,
"year" => intval($year),
"read" => $read,
];
file_put_contents("data.json", json_encode($books, JSON_PRETTY_PRINT));
header("location: index.php");
exit();
}
}
?>
Pre-filling with existing values
The edit form needs to show either the submitted value (after a failed save) or the stored value (on first visit):
<input type="text" name="title"
value="<?= $title ?? $book["title"] ?>" />
$title is only defined after a POST. On first visit it is undefined, so ?? falls back to $book["title"] from the loaded record.
For the checkbox it is a bit more involved:
<input type="checkbox" name="read"
<?= isset($read)
? ($read === true ? 'checked' : '')
: ($book['read'] ? 'checked' : '') ?> />
- If
$readexists (we are re-displaying after a failed POST) → use the submitted value - Otherwise (first visit) → use
$book['read']from the stored data
6. Delete — delete.php
Delete is the simplest file: read the data, remove the entry, write it back, redirect.
<?php
$books = json_decode(file_get_contents('data.json'), true);
$id = $_GET['id'] ?? null;
if (!isset($books[$id])) {
header('location: index.php');
exit();
}
unset($books[$id]);
file_put_contents("data.json", json_encode($books, JSON_PRETTY_PRINT));
header("location: index.php");
exit();
unset($books[$id]) removes the element with that key from the array. The array_values step is not used here — the remaining entries keep their original numeric keys, which is fine as long as you always look up by key and never rely on consecutive indexes.
7. The full request cycle
Every mutating operation (create, update, delete) follows the same pattern:
Browser → GET page
← HTML form
Browser → POST or link click
PHP: validate (if POST)
read data.json
modify $books
write data.json
header("location: ...") ← redirect
exit()
Browser → GET index.php ← follows redirect
← updated list
This is called Post/Redirect/Get (PRG). Without the redirect, refreshing the browser after a form submission would resubmit the POST request, potentially adding or modifying data twice.
8. A helper class for next time
Writing json_decode(file_get_contents(...), true) and file_put_contents(..., json_encode(...)) in every single file is repetitive. For the next session you will receive a ready-made Storage class that wraps all of that boilerplate.
Instead of:
$books = json_decode(file_get_contents("data.json"), true);
$books[] = $newBook;
file_put_contents("data.json", json_encode($books, JSON_PRETTY_PRINT));
You will write:
require_once "Storage.php";
$storage = new Storage(new JsonIO("data.json"));
$storage->add($newBook);
// saved automatically when $storage goes out of scope
The methods you will have available:
| Method | What it does |
|---|---|
$s->add($record) | Append a new record, returns the generated id |
$s->findAll() | Return all records |
$s->findById($id) | Return one record by id |
$s->update($id, $record) | Replace a record |
$s->delete($id) | Remove a record |
Same CRUD operations — you just do not have to type the JSON encode/decode/file functions yourself anymore.
Summary
| Concept | PHP |
|---|---|
| Read file | file_get_contents("data.json") |
| Parse JSON → array | json_decode($str, true) |
| Array → JSON string | json_encode($data, JSON_PRETTY_PRINT) |
| Write file | file_put_contents("file", $str) |
| Append to array | $arr[] = $item |
| Replace entry | $arr[$id] = $item |
| Remove entry | unset($arr[$id]) |
| Redirect | header("location: page.php"); exit(); |
| Cast to integer | intval($str) |
| Check key exists | isset($arr[$key]) |
| Checkbox present? | isset($_POST['name']) |
| Alternative foreach | foreach (...): ... endforeach; |
| Edit pre-fill | $submitted ?? $stored |
| POST/Redirect/Get | Redirect after every write to prevent double-submit |