Forms & htmx
Interactivity without a JavaScript framework. One attribute changes everything.
You will learn
- Handle HTML form submissions with POST
- The full-page-reload problem and why it feels broken
- Add htmx to submit forms without reloading
- The four htmx attributes you'll use 95% of the time
The problem
You build a form. The user types something. They click submit. The entire page reloads. The scroll position resets. The nav re-renders. For a simple "add item" action, reloading the whole page feels like demolishing a wall to replace a light switch.
This is how the web worked in 1999. You can do better — without writing JavaScript.
Step 1: A traditional form
Here's a form that submits via regular POST:
<form method="post" action="/api/items">
<input type="text" name="item" placeholder="New item">
<button type="submit">Add</button>
</form>
This works. The server receives the data, processes it, and returns a full HTML page. But the browser navigates to a new URL, the old page is gone, and the user sees a flash of white.
Step 2: Add one attribute
Now add hx-post:
<form hx-post="/api/items" hx-target="#list" hx-swap="afterbegin">
<input type="text" name="item" placeholder="New item">
<button type="submit">Add</button>
</form>
<div id="list">
<!-- items appear here -->
</div>
The form no longer reloads the page. htmx sends the POST in the background, receives the
server's HTML response, and inserts it as the first child of #list.
No JavaScript. No fetch(). No React.
<form method="post" action="/api/items">
<input name="item" placeholder="New item">
<button>Add</button>
</form>
<!-- Full page reload. Scroll resets.
User sees flash of white. -->The mental model
Traditional forms are like demolishing a wall to replace a light switch. htmx is like unscrewing just the switch plate. The server sends back a new switch plate (an HTML fragment), and htmx swaps it into the wall. Everything else stays untouched.
htmx doesn't replace your server rendering. It enhances it. The server still generates HTML — htmx just puts it in the right place without a full page navigation.
The four attributes
This is 95% of htmx:
hx-get/hx-post/hx-put/hx-delete— fire the requesthx-target— which element to update (CSS selector)hx-swap— how to insert the responseinnerHTML— replace the target's childrenouterHTML— replace the target itselfafterbegin— insert as first childbeforeend— insert as last childdelete— remove the target
hx-trigger— when to fire (clickdefault, alsoload,change,keyup delay:300ms)
That's it. Four attributes replace hundreds of lines of JavaScript.
Live demo: a counter button
The button below has zero custom JavaScript. It uses hx-post to send
a request to /api/learn/demo/incr. The server increments a counter stored in your session,
renders a new <button> element, and returns it. hx-swap="outerHTML"
replaces the old button with the new one.
Open DevTools → Network tab and watch each click: a POST goes out, an HTML fragment comes back, the button is replaced. No page reload. No JSON parsing. No client-side state management.
Six recipes you'll actually use
The four attributes cover the mechanics. These six patterns cover the everyday use cases — every one of them is wired up in the demo app you're reading right now. Copy, paste, adapt.
1. Inline edit — click to edit, save in place
The user clicks an item. The server returns the same row, but with an <input>
replacing the static text. They edit, blur, the server saves and returns the static row again.
Used in the notes app for renaming notes.
<!-- The static row -->
<div id="note-42">
<span hx-get="/api/learn/notes/42/edit"
hx-target="#note-42"
hx-swap="outerHTML"
style="cursor:pointer">Buy milk</span>
</div>
<!-- What the server returns when /edit is clicked -->
<div id="note-42">
<input name="title" value="Buy milk"
hx-post="/api/learn/notes/42"
hx-trigger="blur, keyup[key=='Enter']"
hx-target="#note-42"
hx-swap="outerHTML" autofocus>
</div>
2. Delete with confirmation
One attribute, native browser confirm dialog, the row disappears on success.
No confirm() wrapper, no event handlers, no state.
<button hx-delete="/api/learn/notes/42"
hx-confirm="Delete this note?"
hx-target="#note-42"
hx-swap="delete">Delete</button>
3. Search as you type
Type in the search box, results filter live. The delay:300ms debounces requests;
changed only fires when the input actually changed (not every keystroke).
<input type="search" name="q" placeholder="Search notes…"
hx-get="/api/learn/notes/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-swap="innerHTML">
<div id="results"></div>
4. Load more on scroll
The "Load more" link replaces itself with the next page of items plus a new "Load more" at
the bottom. hx-trigger="revealed" auto-fires when the link enters the viewport —
true infinite scroll, three lines.
<div id="feed">
<!-- ...first page of items... -->
<a hx-get="/api/feed?page=2"
hx-target="this"
hx-swap="outerHTML"
hx-trigger="revealed">Loading more…</a>
</div>
5. Modal — open, fill, close
Click a button, server returns a <dialog> element with the form inside.
Append it to <body>. The form posts and removes the dialog on success.
<button hx-get="/notes/new-modal"
hx-target="body"
hx-swap="beforeend">+ New note</button>
<!-- Server returns: -->
<dialog id="modal" open>
<form hx-post="/api/learn/notes"
hx-target="#modal"
hx-swap="outerHTML">
<input name="title" placeholder="Title" required autofocus>
<button type="submit">Create</button>
<button type="button" onclick="this.closest('dialog').remove()">Cancel</button>
</form>
</dialog>
6. Server-driven pagination
Recipe 4 is client-driven — the browser fires a request when a sentinel scrolls into view, and the server replies with whatever page the client asked for. That works when the data is static. When the underlying list is changing live (new posts arriving, items getting deleted), you want the server to decide what "next page" means using its own state — typically a cursor (last id seen) instead of a page number.
<!-- Initial render — server emits N items + a "Load more" link
whose hx-get carries the cursor of the last row shown -->
<ul id="feed">
<li>Item 200</li>
<li>Item 199</li>
...
<!-- The link replaces itself with the next page + a new "Load more" -->
<a hx-get="/api/feed?after=180"
hx-target="this"
hx-swap="outerHTML">Load more</a>
</ul>
<!-- Server returns, for /api/feed?after=180: -->
<li>Item 180</li>
<li>Item 179</li>
...
<a hx-get="/api/feed?after=161"
hx-target="this"
hx-swap="outerHTML">Load more</a>
The handler:
$app->route('/api/feed', function ($request) {
$after = (int)($request->get['after'] ?? PHP_INT_MAX);
$rows = Feed::recent(beforeId: $after, limit: 20);
if (empty($rows)) return '<!-- end -->'; // empty fragment, link is gone
$last = end($rows)->id;
$html = '';
foreach ($rows as $row) $html .= "<li>{$row->title}</li>";
$html .= "<a hx-get=\"/api/feed?after={$last}\" hx-target=\"this\" hx-swap=\"outerHTML\">Load more</a>";
return $html;
});
The cursor (last_id) lives in the URL of the next request, not in client state.
The server can change page size, skip soft-deleted rows, or insert promotional content
between pages — all without the client knowing. This is the pattern most "infinite feed"
products actually use; recipe 4 is the demo, recipe 6 is the product.
Progressive enhancement
htmx works on top of regular HTML. If JavaScript is disabled, a
<form hx-post="/foo"> falls back to a regular form POST. The server returns
the same HTML; htmx just makes it smoother. Your app degrades gracefully — that's a feature.
What does hx-swap="afterbegin" do?
When htmx isn't enough
htmx is request/response. The client asks, the server answers. For scenarios where the server needs to push updates without being asked — live notifications, multi-tab sync, AI token streaming — you need a persistent connection. ZealPHP has WebSocket (App::ws()) and Server-Sent Events ($response->sse()) for those cases. You'll use both in Lessons 19 (Real-Time Sync) and 20 (AI Chat).
Template fragments — one file, two responses
The htmx pattern above has a tension: when a user clicks the
Add button, the server returns a single <li>
to insert into the list. But the same page on first load needs the
full list rendered as part of the HTML — same markup, same
template variables. Where does the <li> markup live?
The naive answer is "two files":
// template/contacts/list.php — full page
<ul>
<?php foreach ($contacts as $c): ?>
<?= App::renderToString('/partials/contact-row', ['c' => $c]) ?>
<?php endforeach; ?>
</ul>
// template/partials/contact-row.php — the row in a separate file
<li id="contact-<?= $c['id'] ?>">
<?= htmlspecialchars($c['name']) ?>
</li>
// Route handler — call render() OR include the partial directly:
$app->route('/contacts', fn() => App::render('contacts/list', ['contacts' => Contact::all()]));
$app->route('/contacts/{id}/row', fn($id) => App::render('/partials/contact-row', ['c' => Contact::find($id)]));
That works (and ZealPHP does it natively — App::render(),
App::renderToString(), App::renderStream(), and
App::include() all let one template call another;
see Layouts & Components for the full
family). But it forces the row markup to live in a separate file from the
list it's used in, and the route handler now has two near-identical entries
that drift apart over time.
Template fragments (since v0.2.24) collapse both into one
template + one route. Mark named regions inline with
App::fragment($name, fn); the framework either runs the
region inline as part of the full page (no fragment selector) or extracts
just that region when the caller asks for it by name.
<ul id="contacts">
<?php foreach ($contacts as $c): ?>
<?php App::fragment("contact-{$c['id']}", function() use ($c) { ?>
<li id="contact-<?= $c['id'] ?>" hx-target="this" hx-swap="outerHTML">
<span><?= htmlspecialchars($c['name']) ?></span>
<button hx-get="/contacts?fragment=contact-<?= $c['id'] ?>-edit">Edit</button>
</li>
<?php }); ?>
<?php App::fragment("contact-{$c['id']}-edit", function() use ($c) { ?>
<li id="contact-<?= $c['id'] ?>" hx-target="this" hx-swap="outerHTML">
<form hx-post="/contacts/<?= $c['id'] ?>" hx-swap="outerHTML">
<input name="name" value="<?= htmlspecialchars($c['name']) ?>">
<button>Save</button>
</form>
</li>
<?php }); ?>
<?php endforeach; ?>
</ul>
$app->route('/contacts', function() {
$g = \ZealPHP\G::instance();
return App::render('contacts/list', [
'contacts' => Contact::all(),
// No fragment → full page renders normally.
// ?fragment=contact-2 → only that row's <li> comes back, no <ul>, no siblings.
'fragment' => is_string($g->get['fragment'] ?? null) ? $g->get['fragment'] : null,
]);
});
Fragments ride the universal return contract
Inside the closure passed to App::fragment() you can return
anything a route handler can — the framework propagates it through the
same universal return contract
that every other entry point uses (route handler, public file, API
closure, App::include()).
<?php App::fragment('contact-row', function() use ($contact, $request, $g) {
// Auth check — return HTTP status int and the framework emits 403:
if (!$g->session['user'] || !canSee($g->session['user'], $contact)) {
return 403;
}
// Accept: application/json — return array, framework emits JSON:
if (str_contains($request->header['accept'] ?? '', 'application/json')) {
return ['id' => $contact->id, 'name' => $contact->name];
}
// Otherwise stream HTML chunks via a Generator:
return (function() use ($contact) {
yield "<li id='contact-{$contact->id}'>";
yield htmlspecialchars($contact->name);
yield '</li>';
})();
}); ?>
Three other behaviours worth knowing:
-
Missing fragment → 404. Asking for
?fragment=does-not-existwhen noApp::fragment('does-not-exist', …)block matched returns HTTP 404. Doesn't accidentally fall through to the full page. -
First match wins. If the same fragment name appears
twice in a template, the first block is extracted; the rest of the
template short-circuits via
HaltException. -
Nested renders compose cleanly. An
App::render()called from inside a fragment closure does not inherit the parent's fragment selector — each render's scope is saved and restored.
Try it live
A four-contact list rendered through one template. Each row's "Show
details" button does hx-get="/demo/fragments/contacts?fragment=contact-{id}" —
same URL as the page, just one named fragment swapped in place. View source
after a swap and you'll see only the <li> came back, not the surrounding
<html> shell.
/demo/fragments/contacts — full page, then click any row.
Open DevTools → Network → XHR to see each swap is a single 200 response with just one <li> as the body. And /demo/fragments/contacts?fragment=does-not-exist returns HTTP 404 — the framework refuses to fall back to the full page when the named fragment doesn't exist.
Server-side: detect htmx, drive client behaviour, compose with App::fragment()
htmx flows have two sides: the client decides what to swap (hx-* attributes,
shown above), and the server decides what HTML to return AND what htmx should do after the swap.
ZealPHP gives you a thin layer over both — App::fragment() for the body
(one file, two responses) and a fluent
$response->htmx() builder for the response headers htmx reads. The two compose cleanly because
they're orthogonal: fragment writes the body, htmx() writes the metadata.
Read htmx context from the request
Available on every ZealPHP\HTTP\Request — no need to dig through $g->server['HTTP_HX_*']:
use ZealPHP\App;
$app->route('/notes/{id}', function ($request, $response, $id) {
$note = Notes::find((int) $id);
if ($request->isHtmx()) {
// htmx swap — return just the card fragment
return App::renderToString('pages/notes', [
'note' => $note,
'fragment' => 'note-card', // App::fragment('note-card', ...) inside the template
]);
}
// Plain navigation / bookmark / search-engine — full page
return App::render('_master', ['page' => 'note', 'note' => $note]);
});
Also: isBoosted(), isHistoryRestoreRequest(),
htmxTarget(), htmxTrigger(), htmxTriggerName(),
htmxCurrentUrl(), htmxPrompt().
Drive client behaviour with $response->htmx()
11 HX-* response headers htmx reads after a swap — events, browser history, target/swap override, refresh, redirect. Fluent, chained:
$app->route('/api/notes', function ($response) {
$g = \ZealPHP\G::instance();
$note = Notes::create($g->post);
$response->htmx()
->trigger('note-saved') // HX-Trigger: fire JS event
->triggerAfterSwap('focus-next-input') // HX-Trigger-After-Swap
->pushUrl("/notes/{$note->id}") // HX-Push-Url: browser history
->reswap('beforeend'); // HX-Reswap: override target swap
return App::renderToString('partials/note_card', ['note' => $note]);
});
Full surface: trigger(), triggerAfterSwap(), triggerAfterSettle(),
reswap(), retarget(), reselect(),
refresh(), location(), pushUrl(), replaceUrl(),
redirect(). Each returns the builder — the HX-* headers are set on the underlying response automatically.
Out-of-band swaps — update multiple regions in one response
Sometimes one action should refresh more than the targeted element — e.g. saving a note also bumps an
unread-counter badge in the nav. HtmxResponse::oob() emits a marked fragment you concat into
the response body; htmx swaps it into its own id-matched region client-side:
use ZealPHP\HTTP\HtmxResponse;
$body = App::renderToString('partials/note_card', ['note' => $note]) // primary swap (hx-target)
. HtmxResponse::oob('unread-badge', '<span>' . $unread . '</span>') // OOB: replace #unread-badge
. HtmxResponse::oob('toast', '<div>Saved!</div>', swap: 'beforeend'); // OOB: append to #toast
$response->htmx()->trigger('note-saved');
return $body;
Putting it together: one template, two responses, one HX-Trigger
This is the punchline. The same route handler serves the full page on plain navigation and the named fragment on htmx swap — and on the htmx response, fires a client event so a toast component picks up:
// template/pages/notes.php — fragments and full page from one file
<?php use ZealPHP\App; ?>
<section>
<h1>Notes</h1>
<?php App::fragment('note-list', function () use ($notes) { ?>
<ul id="note-list" hx-target="this" hx-swap="outerHTML">
<?php foreach ($notes as $note): ?>
<li><?= htmlspecialchars($note->title) ?></li>
<?php endforeach; ?>
</ul>
<?php }); ?>
</section>
// route handler — branch by request type, fire event on save
use ZealPHP\App;
$app->route('/notes', function ($request, $response) {
$g = \ZealPHP\G::instance();
if ($g->server['REQUEST_METHOD'] === 'POST') {
Notes::create($g->post);
// After save, return just the updated list AND fire the toast event.
$response->htmx()->trigger('note-saved');
return App::renderToString('pages/notes', [
'notes' => Notes::all(),
'fragment' => 'note-list',
]);
}
// GET — full page on plain nav, just #note-list on htmx swap.
return App::render('_master', [
'page' => 'notes',
'notes' => Notes::all(),
'fragment' => $request->isHtmx() ? 'note-list' : null,
]);
});
Why this design: body and metadata are orthogonal concerns. App::render*() /
App::include() / App::fragment() own the body (the
universal return contract covers every shape). Response
headers are not body content; pushing them into the rendering API would conflate two unrelated axes and break
the contract that "what the file/template returns is the body." The builder lives on Response
because that's where headers actually go.
Key Takeaways
- htmx turns any HTML element into an AJAX trigger with just HTML attributes
- The server returns HTML fragments, not JSON — no client-side rendering needed
- Four attributes (
hx-post,hx-target,hx-swap,hx-trigger) cover 95% of use cases - Server side:
$request->isHtmx()+$response->htmx()->trigger()->pushUrl()+App::fragment()compose into one handler that serves full page and partial swap - Progressive enhancement: forms still work without JavaScript