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.

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.

Client: one form, two htmx attributes
<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>
Server: the route returns the new row
$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:

LibraryVersionRole
htmx.org2.0.10The core library.
htmx-ext-head-support2.0.4Reconciles <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.

Boosted vs plain requests. A boosted navigation sends 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.

AccessorReads headerReturnsMeaning
isHtmx()HX-RequestboolRequest came from htmx.
isBoosted()HX-BoostedboolIssued by hx-boost.
isHistoryRestoreRequest()HX-History-Restore-Requestboolhtmx is restoring a history-cache miss.
htmxTarget()HX-Target?stringThe id of the target element.
htmxTrigger()HX-Trigger?stringThe id of the triggering element.
htmxTriggerName()HX-Trigger-Name?stringThe name of the triggering element.
htmxCurrentUrl()HX-Current-URL?stringThe browser's current URL.
htmxPrompt()HX-Prompt?stringThe user's hx-prompt response.
Branch on isHtmx() — partial for htmx, full page for a direct hit
$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.

Chained response-header builder
$response->htmx()
    ->retarget('#alerts')
    ->reswap('afterbegin')
    ->trigger('itemSaved');

History & navigation

MethodHeaderEffect
pushUrl($url)HX-Push-UrlPush a URL onto history ("false" suppresses).
replaceUrl($url)HX-Replace-UrlReplace the URL without a new history entry.
redirect($url)HX-RedirectClient-side redirect (no full reload).
location($urlOrJson)HX-LocationClient-side redirect; URL or JSON location object.

Swap control

MethodHeaderEffect
reswap($strategy)HX-ReswapOverride the swap (innerHTML, outerHTML, afterbegin, … + modifiers).
retarget($selector)HX-RetargetRedirect the swap to a different element.
reselect($selector)HX-ReselectChoose which part of the body is swapped in.

Page control

MethodHeaderEffect
refresh($bool = true)HX-Refreshtrue → full client-side page refresh.

Events

MethodHeaderEffect
trigger($events)HX-TriggerTrigger events after the swap (name, comma-list, or JSON).
triggerAfterSwap($events)HX-Trigger-After-SwapSame, after the swap step.
triggerAfterSettle($events)HX-Trigger-After-SettleSame, after the settle step.
triggerJSON($event, $detail)HX-TriggerTrigger 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:

Validation failed → retarget the error box, swap it, 422 the response
$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.

template/contacts/list.php — a named region per row
<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:

  1. If $fragmentName is passed, that region is rendered.
  2. Otherwise the framework derives it from the request — the HX-Target id (a leading # is stripped), falling back to HX-Trigger-Name. If neither is present, the template renders with no fragment key (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.

Before — the manual branch
$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]);
});
After — one line
$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: true and HX-Request: true — read it with $request->isBoosted().
  • It degrades gracefully: with JS off, the underlying href/action performs 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 JS EventSource (or htmx's sse extension). See Streaming.
  • WebSocketApp::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 APIHX-* 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
See also: Components & templates (the file-execution family, App::fragment()), Streaming (Generator SSR, SSE), WebSocket, and the /learn/htmx lesson.