Skip to content

PHP Tutorial Practical PHP Projects

Project 4: Notes App

Lesson 350 of 369 2 min read By Deepak Paswan Updated Oct 5, 2026

Level: intermediate. This project stores data in a JSON file instead of a database. It is a good way to practise files, JSON, locking and arrays, and it shows the limits of file storage.

What it does

  • Create, edit and delete notes (title, text, tags).
  • Search by words in the title or text, and filter by tag.
  • Stores everything in storage/notes.json.

The store

NoteStore.php
PHP
<?php

declare(strict_types=1);

final class NoteStore
{
    public function __construct(private string $file) {}

    /** @return array<int, array{id: int, title: string, body: string, tags: string[], updated: string}> */
    public function all(): array
    {
        if (!is_file($this->file)) {
            return [];
        }

        $data = json_decode((string) file_get_contents($this->file), true);

        return is_array($data) ? $data : [];
    }

    public function find(int $id): ?array
    {
        foreach ($this->all() as $note) {
            if ($note["id"] === $id) {
                return $note;
            }
        }

        return null;
    }

    public function save(?int $id, string $title, string $body, array $tags): int
    {
        return $this->modify(function (array $notes) use ($id, $title, $body, $tags): array {
            $now = date("Y-m-d H:i:s");

            if ($id === null) {
                $id = $notes === [] ? 1 : max(array_column($notes, "id")) + 1;
                $notes[] = ["id" => $id, "title" => $title, "body" => $body, "tags" => $tags, "updated" => $now];
            } else {
                foreach ($notes as &$note) {
                    if ($note["id"] === $id) {
                        $note = ["id" => $id, "title" => $title, "body" => $body, "tags" => $tags, "updated" => $now];
                    }
                }
                unset($note);
            }

            return [$notes, $id];
        });
    }

    public function delete(int $id): void
    {
        $this->modify(fn(array $notes) => [array_values(array_filter($notes, fn($n) => $n["id"] !== $id)), $id]);
    }

    /** @return array<int, array> */
    public function search(string $term = "", string $tag = ""): array
    {
        $term = mb_strtolower(trim($term));

        $found = array_filter($this->all(), function (array $note) use ($term, $tag): bool {
            if ($tag !== "" && !in_array($tag, $note["tags"], true)) {
                return false;
            }

            return $term === ""
                || str_contains(mb_strtolower($note["title"]), $term)
                || str_contains(mb_strtolower($note["body"]), $term);
        });

        usort($found, fn($a, $b) => strcmp($b["updated"], $a["updated"]) ?: $b["id"] <=> $a["id"]);

        return $found;
    }

    /**
     * Read, change and write the file while holding an exclusive lock.
     *
     * @param callable(array): array{0: array, 1: int} $change returns [new notes, result]
     */
    private function modify(callable $change): int
    {
        $dir = dirname($this->file);

        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $handle = fopen($this->file, "c+");

        if ($handle === false || !flock($handle, LOCK_EX)) {
            throw new RuntimeException("Could not lock the notes file.");
        }

        try {
            $json = stream_get_contents($handle);
            $notes = $json === "" ? [] : (json_decode($json, true, 512, JSON_THROW_ON_ERROR) ?: []);

            [$notes, $result] = $change($notes);

            ftruncate($handle, 0);
            rewind($handle);
            fwrite($handle, json_encode($notes, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
            fflush($handle);

            return $result;
        } finally {
            flock($handle, LOCK_UN);
            fclose($handle);
        }
    }
}

function parseTags(string $input): array
{
    $tags = array_map(fn($t) => mb_strtolower(trim($t)), explode(",", $input));
    $tags = array_filter($tags, fn($t) => $t !== "" && mb_strlen($t) <= 30);

    return array_slice(array_values(array_unique($tags)), 0, 10);
}

Why the lock? Two visitors saving at the same moment could overwrite each other’s changes. flock(LOCK_EX) makes the second one wait until the first has finished writing. The modify() method reads, changes and writes while holding the lock.

Test it

index.php
PHP
<?php
require __DIR__ . "/NoteStore.php";

$store = new NoteStore(__DIR__ . "/storage/notes.json");

$a = $store->save(null, "Shopping", "Milk, eggs, bread", parseTags("home, Errands, home"));
$b = $store->save(null, "PHP ideas", "Build a notes app with JSON", parseTags("php, ideas"));
$store->save($a, "Shopping list", "Milk, eggs, bread, rice", ["home"]);

echo count($store->all()) . " notes\n";
echo $store->find($a)["title"] . " / tags: " . implode(",", $store->find($a)["tags"]) . "\n";

echo "search 'json': " . implode(", ", array_column($store->search("json"), "title")) . "\n";
echo "tag 'home': " . implode(", ", array_column($store->search("", "home"), "title")) . "\n";

$store->delete($b);

echo count($store->all()) . " note left\n";
echo json_encode(parseTags(" PHP , php, , web,   "));
Output
OUTPUT
2 notes
Shopping list / tags: home
search 'json': PHP ideas
tag 'home': Shopping list
1 note left
["php","web"]

The page

index.php
PHP
<?php
declare(strict_types=1);

session_start();

require __DIR__ . "/functions.php";
require __DIR__ . "/NoteStore.php";

$store = new NoteStore(dirname(__DIR__) . "/storage/notes.json");   // outside the public folder
$errors = [];
$editing = null;

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    csrfCheck();

    $action = (string) ($_POST["action"] ?? "");
    $id = filter_var($_POST["id"] ?? null, FILTER_VALIDATE_INT);

    if ($action === "delete" && $id !== false) {
        $store->delete($id);
        header("Location: index.php");
        exit;
    }

    $title = trim((string) ($_POST["title"] ?? ""));
    $body = trim((string) ($_POST["body"] ?? ""));

    if ($title === "" || mb_strlen($title) > 120) {
        $errors["title"] = "Please enter a title (up to 120 characters).";
    }

    if (mb_strlen($body) > 5000) {
        $errors["body"] = "A note can have up to 5000 characters.";
    }

    if ($errors === []) {
        $store->save($id === false ? null : $id, $title, $body, parseTags((string) ($_POST["tags"] ?? "")));
        header("Location: index.php");
        exit;
    }
}

if (isset($_GET["edit"]) && filter_var($_GET["edit"], FILTER_VALIDATE_INT) !== false) {
    $editing = $store->find((int) $_GET["edit"]);
}

$q = trim((string) ($_GET["q"] ?? ""));
$tag = trim((string) ($_GET["tag"] ?? ""));
$notes = $store->search($q, $tag);
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Notes</title></head>
<body>
  <h1>Notes</h1>

  <form method="get" role="search">
    <label for="q">Search</label>
    <input id="q" type="search" name="q" value="<?= h($q) ?>">
    <button>Search</button>
  </form>

  <h2><?= $editing ? "Edit note" : "New note" ?></h2>
  <form method="post">
    <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
    <?php if ($editing): ?><input type="hidden" name="id" value="<?= (int) $editing["id"] ?>"><?php endif; ?>

    <p><label for="title">Title</label><br>
      <input id="title" name="title" value="<?= h($editing["title"] ?? "") ?>" required>
      <?php if (isset($errors["title"])): ?><span role="alert"><?= h($errors["title"]) ?></span><?php endif; ?></p>

    <p><label for="body">Text</label><br>
      <textarea id="body" name="body" rows="6"><?= h($editing["body"] ?? "") ?></textarea></p>

    <p><label for="tags">Tags (separated by commas)</label><br>
      <input id="tags" name="tags" value="<?= h(implode(", ", $editing["tags"] ?? [])) ?>"></p>

    <button type="submit">Save note</button>
  </form>

  <?php foreach ($notes as $n): ?>
    <article>
      <h3><?= h($n["title"]) ?></h3>
      <p><?= nl2br(h($n["body"])) ?></p>
      <p>
        <?php foreach ($n["tags"] as $t): ?><a href="?tag=<?= urlencode($t) ?>">#<?= h($t) ?></a> <?php endforeach; ?>
        <a href="?edit=<?= (int) $n["id"] ?>">Edit</a>
      </p>
      <form method="post" onsubmit="return confirm('Delete this note?');">
        <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
        <input type="hidden" name="id" value="<?= (int) $n["id"] ?>">
        <button name="action" value="delete">Delete</button>
      </form>
    </article>
  <?php endforeach; ?>
</body>
</html>

What you practised

  • Reading and writing JSON files with json_encode() and json_decode().
  • File locking (flock) to avoid lost updates.
  • Filtering and sorting arrays, searching text with mb_ functions.
  • Storing the file outside the public folder.
  • Escaping every value, and nl2br(h($text)) for multi-line text.

The limits of a JSON file

It reads and rewrites the whole file on every save, has no real search index and handles only modest traffic. For thousands of notes or many users, use MySQL (PHP and MySQL). The NoteStore class has the same public methods a database version would have, so the page would not need to change. That is the value of separating storage from the page.

Challenges

  1. Add a database version of NoteStore with the same methods.
  2. Add per-user notes (after the login project).
  3. Export all notes as a JSON download.
  4. Add Markdown formatting with a safe library (A Practical Composer Example).