Personal Notes
Everything comes together. Auth, htmx, SQLite, components — a real app.
You will learn
- Build a full CRUD app with SQLite and htmx
- Return HTML fragments from API endpoints with App::renderToString()
- Wire htmx to add, list, and delete notes without page reloads
- Reuse components across different contexts (list, create, chat history)
1. Overview — what you’re building
Users can log in. Now they want to store things — each user seeing only their own notes, adding new ones, deleting old ones. No page reloads. This is the lesson where everything you’ve learned comes together.
Every data-driven app follows the CRUD loop: Create, Read, Update, Delete. Build this once, and you can build any data app — a todo list, a blog, an inventory system.
Notes, Auth,
DB, WS) ship with the framework library — they’re already in
your vendor/ directory after composer create-project. This lesson walks you
through creating the glue files that wire them into a working app:
api/learn/notes.php— CRUD endpointroute/learn.php— path-param routes + WebSocket registrationtemplate/components/_notes_widget.php— the notes UItemplate/components/_note_card.php— individual note display
php app.php restart) after
adding route files.
2. Component extraction — the same widget, two places
Before we wire anything up, let’s build the UI. The note-creation form, the list of notes below it, and the user bar at the top form a self-contained UI block. Create it as a reusable partial:
Create template/components/_notes_widget.php:
<?php
$user ??= null;
if (!$user) return;
?>
<div class="notes-user-bar">
<span class="notes-user-avatar"><?= strtoupper(substr($user['username'], 0, 1)) ?></span>
<span class="notes-user-name"><?= htmlspecialchars($user['username']) ?></span>
<a href="/api/learn/logout" class="notes-user-logout">Log out</a>
</div>
<section class="notes-app">
<form class="note-form"
hx-post="/api/learn/notes"
hx-target="#notes-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()">
<input type="text" name="title" placeholder="Note title" required maxlength="200">
<textarea name="body" placeholder="What's on your mind?" maxlength="4096"></textarea>
<button type="submit">Add note</button>
</form>
<div id="notes-list" class="notes-list"
hx-get="/api/learn/notes"
hx-trigger="load"
hx-swap="innerHTML">
<p class="notes-empty">Loading…</p>
</div>
</section>
The partial renders identical HTML in two consumers — and that’s the lesson:
- Inline in this lesson — the lesson page calls
App::render('/components/_notes_widget', ['user' => $user])below, embedded right between explanation paragraphs so you can try it without leaving the page. - Standalone in a popup —
/demo/view/notes/widgetrenders the same partial insidecomponents/_demo_shell.php(the focused, no-nav shell). Open it in a second tab to test cross-tab sync without two lesson pages cluttering the screen.
Both consumers pass the same $user array; the widget itself trusts that and emits
the same DOM, with the same #notes-list id so /js/learn.js wires up
WebSocket sync identically in both contexts. One component, two consumers —
that’s the React-style reuse pattern, just at the server-side template layer.
3. Auth gate
Notes are per-user, so the lesson page checks for a logged-in user. If none, it renders the login/register card (lesson-specific copy). If a user is found, it scrolls down to the working widget at Try it. The widget itself does not check auth — that’s the caller’s job, which means the same widget can be rendered in any context that already has a user (lesson page, standalone popup, future admin tool, etc).
Sign in to your vault
No email needed — just pick a username and password. Lost the password? Make a new account.
New here? Register
4. CRUD operations
sequenceDiagram
participant B as Browser
participant H as htmx
participant API as /api/learn/notes
participant N as Notes.php
participant DB as SQLite
participant WS as WebSocket
B->>H: Submit form
H->>API: POST /api/learn/notes
API->>N: Notes::create($db, $userId, ...)
N->>DB: INSERT INTO notes
DB-->>N: id = 42
N-->>API: note row
API->>WS: broadcast(note_changed)
WS-->>B: push to all tabs
API-->>H: HTML card fragment
H-->>B: afterbegin swap (green glow)
Three layers, each with one job:
ZealPHP\Learn\Notes— Business logic (already invendor/via the framework). SQL queries scoped byuser_id.api/learn/notes.php— Endpoint you’ll create. Reads the request, calls the class, returns HTML.- Template + htmx — UI you created in step 2, wired with four htmx attributes.
The data layer (already in vendor)
The ZealPHP\Learn\Notes class ships with the framework — you don’t need to create it. Every method takes a $userId parameter. The user can never read or modify another user’s notes:
// vendor/zealphp/zealphp/src/Learn/Notes.php — already installed
class Notes
{
public static function create(\PDO $db, int $userId, string $title, string $body): ?int
{
$stmt = $db->prepare(
'INSERT INTO notes (user_id, title, body, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$userId, trim($title), $body, time(), time()]);
return (int) $db->lastInsertId();
}
public static function list(\PDO $db, int $userId): array
{
$stmt = $db->prepare(
'SELECT id, title, body, updated_at FROM notes
WHERE user_id = ? ORDER BY updated_at DESC'
);
$stmt->execute([$userId]);
return $stmt->fetchAll();
}
}
Note: The abbreviated Notes::create above always returns an int when it reaches lastInsertId(). The real implementation in vendor/ also returns null on validation failure — empty or oversized title, body exceeding 4096 characters, or a per-user quota breach — which is why the endpoint checks if ($id === null) and responds 422.
Introducing App::renderToString()
In Lesson 13 (Layouts & Components), you learned
App::render() which echoes HTML. But htmx sent a POST and expects HTML back as the
response body. You need the HTML as a string, not echoed to the page. That's
App::renderToString():
Create api/learn/notes.php — this is the endpoint htmx talks to:
<?php
use ZealPHP\App;
use ZealPHP\G;
use ZealPHP\Learn\DB;
use ZealPHP\Learn\Auth;
use ZealPHP\Learn\Notes;
use ZealPHP\Learn\WS;
${basename(__FILE__, '.php')} = function () {
$u = Auth::currentUser();
if (!$u) { $this->response($this->json(['error' => 'auth_required']), 401); return; }
$g = G::instance();
$method = strtoupper($g->server['REQUEST_METHOD'] ?? 'GET');
$db = DB::open();
$wantsJson = stripos($g->server['HTTP_ACCEPT'] ?? '', 'application/json') !== false;
if ($method === 'POST') {
$body = $g->post;
$title = (string) ($body['title'] ?? '');
$bodyText = (string) ($body['body'] ?? '');
$id = Notes::create($db, $u['user_id'], $title, $bodyText);
if ($id === null) { $this->response($this->json(['error' => 'validation_failed']), 422); return; }
WS::broadcast($u['user_id'], ['type' => 'note_changed', 'op' => 'create', 'id' => $id]);
$note = Notes::read($db, $u['user_id'], $id);
header('Content-Type: text/html; charset=utf-8');
$this->response(App::renderToString('/components/_note_card', $note), 200);
return;
}
// GET — list notes
$notesList = Notes::list($db, $u['user_id']);
header('Content-Type: text/html; charset=utf-8');
if (empty($notesList)) {
$this->response('<p class="notes-empty">No notes yet. Add one above.</p>', 200);
return;
}
$html = '';
foreach ($notesList as $n) {
$html .= App::renderToString('/components/_note_card', $n);
}
$this->response($html, 200);
};
The key line: App::renderToString('/components/_note_card', $note) renders the card you created above and returns it as a string — exactly what htmx expects as the response body.
The htmx wiring
The form above has four htmx attributes that replace 30+ lines of JavaScript:
<form hx-post="/api/learn/notes"
hx-target="#notes-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()">
hx-post— sends the form data as POSThx-target— which DOM element receives the responsehx-swap="afterbegin"— insert the new note as the first child (top of list)hx-on::after-request— clear the form after success
Delete uses a similar pattern:
<button hx-delete="/api/learn/notes/<?= $id ?>"
hx-target="#note-<?= $id ?>"
hx-swap="outerHTML"
hx-confirm="Delete this note?">Delete</button>
hx-swap="outerHTML" replaces the entire note card with the empty response — effectively removing it.
Create the note card component
Each note renders as a card. This component is used in three places: the notes list (GET), the create response (POST), and the chat history bubbles (Lesson 20, AI Chat). Same file, three consumers.
Create template/components/_note_card.php:
<?php
$id = (int)($id ?? 0);
$title = (string)($title ?? '');
$body = (string)($body ?? '');
$ts = (int)($updated_at ?? time());
?>
<article class="note" id="note-<?= $id ?>" data-id="<?= $id ?>">
<details>
<summary class="note-title"><?= htmlspecialchars($title) ?></summary>
<p class="note-body"><?= nl2br(htmlspecialchars($body)) ?></p>
</details>
<div class="note-meta">
<span>Updated <?= date('Y-m-d H:i', $ts) ?></span>
<button hx-delete="/api/learn/notes/<?= $id ?>"
hx-target="#note-<?= $id ?>"
hx-swap="outerHTML"
hx-confirm="Delete this note?">Delete</button>
</div>
</article>
5. Live sync — cross-tab via WebSocket
Adding a note and seeing it appear in your own tab is just htmx swapping HTML. The harder problem is: another tab is open on the same account — maybe on the AI Chat page, maybe in a popup, maybe on a phone — and it should also reflect the change without the user reloading anything.
The Notes API does it by broadcasting a note_changed event over WebSocket every
time a note is created, updated, or deleted. Every connected tab on that user’s account
receives the event and calls htmx.ajax('GET', '/api/learn/notes', '#notes-list')
to refresh its list. One-liner on the client, one broadcast on the server.
// api/learn/notes.php — inside the POST handler, after the INSERT
WS::broadcast($u['user_id'], ['type' => 'note_changed', 'op' => 'create', 'id' => $id]);
// public/js/learn.js — inside the WebSocket onmessage
if (msg.type === 'note_changed') {
htmx.ajax('GET', '/api/learn/notes', { target: '#notes-list', swap: 'innerHTML' });
}
The next lesson — Lesson 19, Real-Time Sync — walks through
how WS::broadcast iterates the Store table of connected fds
and filters by user_id. (route/learn.php’s path-param
update/delete routes call the thin wrapper Demo::learn_ws_broadcast() —
a public static method on ZealPHP\Learn\Demo that just forwards to
WS::broadcast() — so the route file stays function-free and hot-reloadable;
the api/ endpoint calls WS::broadcast directly as shown above.) The tic-tac-toe
lesson later applies the same broadcaster shape with a different filter key: room.
6. Try it — the live widget
Key Takeaways
- CRUD is four verbs: Create, Read, Update, Delete — every data app follows this pattern
App::renderToString()returns HTML as a string for htmx fragment responses- Four htmx attributes replace 30+ lines of JavaScript for form submission
- User-scoped queries (
WHERE user_id = ?) ensure data isolation