HTMX
ZealPHP treats htmx as a first-class citizen. The model is the opposite of a SPA: the server returns HTML, and htmx swaps it into the page over AJAX with no full reload. You write routes that return markup; htmx wires up interactivity from HTML attributes — no client framework, no JSON-to-DOM glue, no build step.
This is the reference. For a gentle, narrative introduction, read the /learn/htmx lesson. The full prose guide also lives at docs/htmx.md.
Overview — the server returns HTML
A normal AJAX setup returns JSON and rebuilds the DOM in JavaScript. htmx inverts that: an element declares where a request goes and what to do with the HTML that comes back.
<form hx-post="/items" hx-target="#list" hx-swap="afterbegin">
<input name="item" placeholder="New item">
<button type="submit">Add</button>
</form>
<ul id="list"><!-- new <li> rows get inserted here --></ul>
$app->route('/items', methods: ['POST'], handler: function ($request) {
$item = Item::create($request->post['item']);
return "<li>" . htmlspecialchars($item->name) . "</li>";
});
Progressive enhancement. Because the markup is real HTML — a real <form action> / <a href> underneath the htmx attributes — the same page still works with JavaScript disabled. htmx is an enhancement layer, not a hard dependency.
Setup out of the box
The demo app's template/_master.php wires htmx for every page:
<body hx-boost="true" hx-ext="head-support">
and template/_head.php loads the libraries:
| Library | Version | Role |
|---|---|---|
htmx.org | 2.0.10 | The core library. |
htmx-ext-head-support | 2.0.4 | Reconciles <head> on boosted navigation (hx-boost swaps <body> + <title> but not <head>, so per-page CSS/JS modules load when you navigate into a page). |
With hx-boost="true" on <body>, every <a> and <form> is automatically AJAX-ified: htmx swaps the <body>, updates the <title>, and manages history — no full reload.
HX-Boosted: true and HX-Request: true; an explicit hx-get/hx-post sends HX-Request: true only; a full-page load (typed URL, hard refresh) sends neither. That distinction lets a handler decide between a full page and a partial.
Reading the request
ZealPHP\HTTP\Request (the $request injected into every handler) exposes eight accessors, one per htmx request header. Each returns null (or false for the booleans) when the header is absent.
| Accessor | Reads header | Returns | Meaning |
|---|---|---|---|
isHtmx() | HX-Request | bool | Request came from htmx. |
isBoosted() | HX-Boosted | bool | Issued by hx-boost. |
isHistoryRestoreRequest() | HX-History-Restore-Request | bool | htmx is restoring a history-cache miss. |
htmxTarget() | HX-Target | ?string | The id of the target element. |
htmxTrigger() | HX-Trigger | ?string | The id of the triggering element. |
htmxTriggerName() | HX-Trigger-Name | ?string | The name of the triggering element. |
htmxCurrentUrl() | HX-Current-URL | ?string | The browser's current URL. |
htmxPrompt() | HX-Prompt | ?string | The user's hx-prompt response. |
$app->route('/search', methods: ['GET'], handler: function ($request) {
$hits = Search::run($request->get['q'] ?? '');
if ($request->isHtmx()) {
// htmx asked for just the results — return the partial.
return App::renderToString('search/results', ['hits' => $hits]);
}
// Direct navigation — return the whole page.
return App::render('search/page', ['hits' => $hits]);
});
That branch is common enough that ZealPHP ships App::renderHtmx() to collapse it to one line.
Driving the client
$response->htmx() returns a fluent ZealPHP\HTTP\HtmxResponse builder that queues HX-* response headers — instructions the htmx client follows after it receives the body. Each setter returns the builder so calls chain; every value is CRLF/NUL-injection-guarded before it's queued.
$response->htmx()
->retarget('#alerts')
->reswap('afterbegin')
->trigger('itemSaved');
History & navigation
| Method | Header | Effect |
|---|---|---|
pushUrl($url) | HX-Push-Url | Push a URL onto history ("false" suppresses). |
replaceUrl($url) | HX-Replace-Url | Replace the URL without a new history entry. |
redirect($url) | HX-Redirect | Client-side redirect (no full reload). |
location($urlOrJson) | HX-Location | Client-side redirect; URL or JSON location object. |
Swap control
| Method | Header | Effect |
|---|---|---|
reswap($strategy) | HX-Reswap | Override the swap (innerHTML, outerHTML, afterbegin, … + modifiers). |
retarget($selector) | HX-Retarget | Redirect the swap to a different element. |
reselect($selector) | HX-Reselect | Choose which part of the body is swapped in. |
Page control
| Method | Header | Effect |
|---|---|---|
refresh($bool = true) | HX-Refresh | true → full client-side page refresh. |
Events
| Method | Header | Effect |
|---|---|---|
trigger($events) | HX-Trigger | Trigger events after the swap (name, comma-list, or JSON). |
triggerAfterSwap($events) | HX-Trigger-After-Swap | Same, after the swap step. |
triggerAfterSettle($events) | HX-Trigger-After-Settle | Same, after the settle step. |
triggerJSON($event, $detail) | HX-Trigger | Trigger one named event with a structured detail payload — no hand-encoding. |
triggerJSON('showMessage', ['level' => 'info', 'message' => 'Saved!']) is shorthand for trigger('{"showMessage":{"level":"info","message":"Saved!"}}'). The browser receives event.detail = the decoded array.
Flowing back to the Response
Every setter returns the HtmxResponse, so the chain can't directly call a Response method like status(). response() hands the parent Response back so the chain can continue:
$res->htmx()
->retarget('#form-errors')
->reswap('outerHTML')
->response() // ← back to the Response
->status(422);
Fragments & App::renderHtmx()
The htmx "one URL, two responses" pattern: a direct hit returns the full page; an htmx request returns just the piece that swaps in. ZealPHP supports this two ways.
App::fragment() — two responses, one template file
App::fragment($name, $fn) marks a named region inside a template. The same template renders the full page normally, and just the named region when called with a fragment selector — no separate partial file.
<ul>
<?php foreach ($contacts as $c): ?>
<?php App::fragment("contact-{$c->id}", function () use ($c) { ?>
<li id="contact-<?= $c->id ?>"><?= htmlspecialchars($c->name) ?></li>
<?php }); ?>
<?php endforeach; ?>
</ul>
// Full page: App::render('contacts/list', ['contacts' => $all])
// One row (htmx): App::render('contacts/list', ['contacts' => $all, 'fragment' => "contact-{$id}"])
When extracted, the fragment closure's return value rides the universal return contract — it can return 404;, return ['k' => 'v'];, or yield a Generator. A selector that matches no region is a 404. See Components & templates for full fragment semantics.
App::renderHtmx() — the selector
App::renderHtmx() is a thin, htmx-aware selector over App::render(): an htmx request gets a fragment (partial), a normal request gets the full page.
public static function renderHtmx(
string $template,
array $args = [],
?string $fragmentName = null,
?string $fullPageTemplate = null
): mixed
Fragment selection for an htmx request:
- If
$fragmentNameis passed, that region is rendered. - Otherwise the framework derives it from the request — the
HX-Targetid (a leading#is stripped), falling back toHX-Trigger-Name. If neither is present, the template renders with nofragmentkey (its bare partial output).
Called outside a request (a CLI render, a warmup) it falls back to the full-page path. It does not touch executeFile() — it only chooses what to render, so the return contract and streaming are preserved.
$app->route('/search', methods: ['GET'], handler: function ($request) {
$hits = Search::run($request->get['q'] ?? '');
if ($request->isHtmx()) {
$target = ltrim($request->htmxTarget() ?? '', '#');
if ($target !== '') {
return App::render('search', ['hits' => $hits, 'fragment' => $target]);
}
return App::render('search', ['hits' => $hits]);
}
return App::render('search', ['hits' => $hits]);
});
$app->route('/search', methods: ['GET'], handler: fn($request) =>
App::renderHtmx('search', [
'hits' => Search::run($request->get['q'] ?? ''),
]));
Same shell — the App::fragment('results', …) region inside search.php is what an htmx hx-target="#results" request gets back; a direct hit gets the whole page. For a bare partial plus a distinct full-page shell, pass fullPageTemplate:
$app->route('/widget', methods: ['GET'], handler: fn() =>
App::renderHtmx('widget/partial', ['w' => $w], fullPageTemplate: 'widget/page'));
Out-of-band swaps
Sometimes a response updates an element other than the swap target — a cart badge, a toast, a count. htmx's out-of-band (OOB) swap does that: any element carrying hx-swap-oob is swapped into the matching id regardless of the primary target. HtmxResponse::oob() builds the wrapper:
$app->route('/cart/add', methods: ['POST'], handler: function ($request) {
$cart = Cart::add($request->post['sku']);
// Primary swap: a confirmation. OOB: the cart badge updates too.
return "<div>Added.</div>"
. HtmxResponse::oob('cart-count', (string) $cart->count);
});
The id and swap value are HTML-escaped; the tag is sanitised to alphanumerics (falling back to div).
The boosting model
hx-boost="true" turns ordinary <a> and <form> elements into AJAX navigations:
- A click/submit becomes an AJAX request; htmx swaps the
<body>, updates the<title>, and pushes history. - The request carries
HX-Boosted: trueandHX-Request: true— read it with$request->isBoosted(). - It degrades gracefully: with JS off, the underlying
href/actionperforms a normal navigation.
History restoration. On back/forward, htmx restores from its history cache. On a cache miss it re-fetches and sends HX-History-Restore-Request: true — detect it with $request->isHistoryRestoreRequest() (e.g. to skip an expensive personalisation pass on a restore). Because hx-boost swaps <body> but not <head>, the demo loads the head-support extension so per-page scoped CSS/JS still loads on navigation; after each swap htmx fires htmx:afterSettle, which the demo uses to re-run highlight.js and re-init demo panels.
SSE / WebSocket — where htmx ends
htmx is request/response: an action triggers a request, the server returns HTML, htmx swaps it. For server-pushed updates — the server sending data without a client request — reach past htmx to ZealPHP's streaming primitives:
- Server-Sent Events —
$response->sse($fn)formats the SSE wire protocol for a JSEventSource(or htmx'ssseextension). See Streaming. - WebSocket —
App::ws($path, $onMessage, $onOpen, $onClose)for bidirectional realtime. See WebSocket.
htmx's hx-ext="sse" extension can consume an SSE endpoint declaratively (sse-connect / sse-swap), so a $response->sse() route pairs naturally with htmx when you want push-driven swaps without writing EventSource JavaScript. For two-way realtime (chat, presence, collaboration) use App::ws() — outside htmx's request/response model.
CSRF with htmx
htmx submits forms over AJAX, so a hidden-input token works — but the cleaner pattern is hx-headers: attach the token as a request header for every htmx request under an element. Because it's inherited, one declaration on <body> covers every boosted navigation and nested hx-get/hx-post.
<body hx-boost="true" hx-headers='{"X-CSRF-Token": "<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>"}'>
Validate it in middleware or the handler by reading the header off the request ($request->header['x-csrf-token']).
Skipping a login double-render. When an unauthenticated htmx request hits a protected route, returning a full login page would swap login HTML into a small target. Read HX-Request to send an HX-Redirect instead:
if (!Auth::check()) {
if ($request->isHtmx()) {
$response->htmx()->redirect('/login'); // HX-Redirect → clean client redirect
return '';
}
return $response->redirect('/login'); // normal 302 for a direct hit
}
Reference table
| ZealPHP API | HX-* header / behaviour |
|---|---|
Request — $request-> | |
isHtmx() | reads HX-Request |
isBoosted() | reads HX-Boosted |
isHistoryRestoreRequest() | reads HX-History-Restore-Request |
htmxTarget() | reads HX-Target |
htmxTrigger() | reads HX-Trigger |
htmxTriggerName() | reads HX-Trigger-Name |
htmxCurrentUrl() | reads HX-Current-URL |
htmxPrompt() | reads HX-Prompt |
Response — $response->htmx()-> | |
pushUrl($url) | sets HX-Push-Url |
replaceUrl($url) | sets HX-Replace-Url |
redirect($url) | sets HX-Redirect |
location($urlOrJson) | sets HX-Location |
reswap($strategy) | sets HX-Reswap |
retarget($selector) | sets HX-Retarget |
reselect($selector) | sets HX-Reselect |
refresh($bool) | sets HX-Refresh |
trigger($events) | sets HX-Trigger |
triggerAfterSwap($events) | sets HX-Trigger-After-Swap |
triggerAfterSettle($events) | sets HX-Trigger-After-Settle |
triggerJSON($event, $detail) | sets HX-Trigger (JSON-encoded detail) |
oob($id, $html, $swap, $tag) | builds an hx-swap-oob element (static) |
response() | returns the parent Response (chain back) |
Rendering — App:: | |
renderHtmx($tpl, $args, $fragmentName, $fullPageTemplate) | htmx → fragment, else full page; derives from HX-Target / HX-Trigger-Name |
fragment($name, $fn) | marks a named region for one-file-two-responses extraction |
App::fragment()),
Streaming (Generator SSR, SSE),
WebSocket, and the /learn/htmx lesson.