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)

The problem

You have users who 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.

Sign in to your vault

No email needed — just pick a username and password. Lost the password? Make a new account.

New here? Register

The architecture

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. src/Learn/Notes.php — Business logic. SQL queries scoped by user_id.
  2. api/learn/notes.php — Endpoint. Reads the request, calls the class, returns HTML.
  3. Template + htmx — UI. The form and list, wired with four htmx attributes.

The data layer

Every method takes a $userId parameter. The user can never read or modify another user's notes:

// src/Learn/Notes.php
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();
    }
}

Introducing App::renderToString()

In Lesson 4, 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():

// api/learn/notes.php — return the rendered note card
$note = Notes::read($db, $userId, $id);
$html = App::renderToString('/components/_note_card', $note);
$this->response($html, 200);

Same component, same template file — but now you get the HTML as a string to return from your API.

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.

Component reuse

The _note_card component is used in three places: the notes list (GET), the create response (POST), and the chat history bubbles (Lesson 9). Same file, three contexts — that's the power of server-rendered components.

🔎 Cross-tab sync via WebSocket

When you add or delete a note, the server also broadcasts a note_changed event via WebSocket. Other browser tabs receive it and refresh their notes list with htmx.ajax(). Open this page in two tabs and try it — Lesson 10 explains how.

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