Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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.

Build it yourself. The business logic classes (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:
  1. api/learn/notes.php — CRUD endpoint
  2. route/learn.php — path-param routes + WebSocket registration
  3. template/components/_notes_widget.php — the notes UI
  4. template/components/_note_card.php — individual note display
Create each file as you reach its section. Restart the server (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&hellip;</p>
  </div>
</section>

The partial renders identical HTML in two consumers — and that’s the lesson:

  1. 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.
  2. Standalone in a popup/demo/view/notes/widget renders the same partial inside components/_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:

  1. ZealPHP\Learn\Notes — Business logic (already in vendor/ via the framework). SQL queries scoped by user_id.
  2. api/learn/notes.php — Endpoint you’ll create. Reads the request, calls the class, returns HTML.
  3. 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 POST
  • hx-target — which DOM element receives the response
  • hx-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