Magyar zászlóMagyar

Practice 10 - PHP: CRUD with a Book Wishlist

2025-04-26 5 min read GitHub

Introduction

CRUD stands for the four fundamental operations any data-driven application needs:

LetterOperationHTTP analogyPHP file
CCreatePOSTnew.php
RReadGETindex.php, show.php
UUpdatePOSTedit.php
DDeleteGET (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:

PHPWhat 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:

  1. It reads the existing record from data.json first, to display current values before any POST
  2. On save it replaces the existing entry at $id instead 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 $read exists (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:

MethodWhat 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

ConceptPHP
Read filefile_get_contents("data.json")
Parse JSON → arrayjson_decode($str, true)
Array → JSON stringjson_encode($data, JSON_PRETTY_PRINT)
Write filefile_put_contents("file", $str)
Append to array$arr[] = $item
Replace entry$arr[$id] = $item
Remove entryunset($arr[$id])
Redirectheader("location: page.php"); exit();
Cast to integerintval($str)
Check key existsisset($arr[$key])
Checkbox present?isset($_POST['name'])
Alternative foreachforeach (...): ... endforeach;
Edit pre-fill$submitted ?? $stored
POST/Redirect/GetRedirect after every write to prevent double-submit