preflow / htmx
Preflow HTMX — hypermedia driver, component tokens, component endpoint
Requires
- php: >=8.4
- ext-sodium: *
- preflow/components: ^0.1 || @dev
- preflow/core: ^0.1 || @dev
Requires (Dev)
- phpunit/phpunit: ^11.0
- preflow/testing: ^0.65 || @dev
Suggests
None
Provides
None
Conflicts
- preflow/core: <0.65
Replaces
None
- dev-main
- v0.76.0
- v0.75.0
- v0.74.0
- v0.73.0
- v0.72.1
- v0.72.0
- v0.71.1
- v0.71.0
- v0.70.0
- v0.69.2
- v0.69.1
- v0.69.0
- v0.68.1
- v0.68.0
- v0.67.0
- v0.66.2
- v0.66.1
- v0.66.0
- v0.65.0
- v0.64.0
- v0.63.2
- v0.63.1
- v0.63.0
- v0.62.0
- v0.61.0
- v0.60.1
- v0.60.0
- v0.59.0
- v0.58.1
- v0.58.0
- v0.57.0
- v0.56.1
- v0.56.0
- v0.55.0
- v0.54.1
- v0.54.0
- v0.53.2
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.1
- v0.49.0
- v0.48.5
- v0.48.4
- v0.48.3
- v0.48.2
- v0.48.1
- v0.48.0
- v0.47.1
- v0.47.0
- v0.46.1
- v0.46.0
- v0.45.0
- v0.44.0
- v0.43.0
- v0.42.1
- v0.42.0
- v0.41.2
- v0.41.1
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.1
- v0.34.0
- v0.33.0
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.0
- v0.28.0
- v0.27.1
- v0.27.0
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.1
- v0.24.0
- v0.23.0
- v0.22.1
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.2
- v0.19.1
- v0.19.0
- v0.18.1
- v0.18.0
- v0.17.2
- v0.17.1
- v0.17.0
- v0.16.2
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.2
- v0.14.1
- v0.14.0
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.4
- v0.11.3
- v0.11.2
- v0.11.1
- v0.11.0
- v0.10.4
- v0.10.3
- v0.10.2
- v0.10.1
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.1
- v0.4.0
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-11-14 19:29:54 UTC
README
Hypermedia abstraction and component endpoint for Preflow. Ships an HTMX driver out of the box; the HypermediaDriver interface allows swapping in alternatives (e.g. Datastar).
Installation
composer require preflow/htmx
Requires PHP 8.4+, ext-sodium. Twig integration requires twig/twig ^3.0.
What it does
ComponentToken issues HMAC-SHA256-signed tokens that encode a component class, props, and action name. ComponentEndpoint handles incoming hypermedia requests through five security layers: token verification → class validation → action whitelist → Guarded interface → dispatch. HtmxDriver generates hx-* attributes and sets response headers. The hd Twig global surfaces all of this as template helpers.
API
HtmxDriver
Generates HTML attributes and sets response headers.
// Attributes $driver->actionAttrs(method: 'post', url: $url, targetId: $id, swap: SwapStrategy::OuterHTML): HtmlAttributes $driver->listenAttrs(event: 'itemSaved', url: $url, targetId: $id): HtmlAttributes // Response headers (call from within an action) $driver->triggerEvent('itemSaved') // HX-Trigger $driver->redirect('/dashboard') // HX-Redirect $driver->pushUrl('/posts/1') // HX-Push-Url // Requests and responses (0.65; the component endpoint and core call these) $driver->fragmentTarget($request) // the HX-Target id, or null $driver->applyEffects($response) // the recorded HX-* headers, plus HX-Nonce $driver->isFragmentRequest($request) // HX-Request: true $driver->isHistoryRestore($request) // HX-History-Restore-Request: true $driver->varyHeaders() // HX-Request, HX-Target, HX-History-Restore-Request $driver->navigate($response, $url) // HX-Redirect: $url, or HX-Refresh: true for null $driver->announceNonce($response, $n)// HX-Nonce: $n (nonceHeaders(): HX-Nonce) $driver->pageNonce($request) // null: htmx re-stamps scripts with the page nonce itself
Another client library: app.hypermedia (0.65)
HtmxDriver implements HypermediaDriver, and an app has one driver: config/app.php
'hypermedia' names the class of another HypermediaDriver (default HtmxDriver), and the
container builds it (constructor parameters autowired, e.g. the AssetCollector). It is bound as
HypermediaDriver, and everything uses that binding: the hd.* helpers, ComponentEndpoint,
components that type-hint HypermediaDriver or the class, and the form helpers when the driver
also implements Preflow\View\FormHypermediaDriver (otherwise forms stay plain). Core also adds
it to HypermediaProtocols (preflow/core): auth, the expired-session answer, the security headers,
the page cache and GEO ask it about requests (see core's README, "Hypermedia clients").
HypermediaDriver is a LazyHypermediaDriver (LazyFormHypermediaDriver for a form driver)
standing in for the driver, which is the container's shared $class. Core builds it eagerly at the
end of the boot, right after the service providers ran (on the CLI too), so a driver that can't be
built stops the boot ("app.hypermedia: could not build …") instead of a later request. Its
constructor may therefore depend only on services bound by then: core's and those the providers
bind (a provider may bind $class itself), not on anything bound later (in a request, or by
middleware).
With another driver htmx isn't inlined (config/htmx.php then does nothing); the driver's
assetTag() or your layout loads its client.
// config/app.php 'hypermedia' => App\Hypermedia\DatastarDriver::class,
Besides the attribute methods and triggerEvent() / redirect() / pushUrl(), a driver
implements fragmentTarget(ServerRequestInterface): ?string (the id of the element the client
swaps the response into; the endpoint renders the component without its wrapper when that is
another element), applyEffects(ResponseInterface): ResponseInterface (the recorded effects as
the client reads them: headers, or events in the body; called on every endpoint response) and
core's HypermediaProtocol: isFragmentRequest(), isHistoryRestore(), varyHeaders(),
navigate(), announceNonce() / nonceHeaders() and pageNonce() (a client that sends its
page's CSP nonce back with a fragment request is answered with it, so its effect scripts pass the
page's policy). pageNonce() returns untrusted input: core adopts it for fragment requests only
(not plain requests or history restores), for that response only, never stores the response,
and the driver must not print the nonce into the page — its client reads script.nonce at
request time. An unknown class throws at boot. Check a driver with
Preflow\Testing\Contract\HypermediaDriverContract (preflow/testing).
ComponentToken
$token->encode(componentClass: Post::class, props: ['id' => 1], action: 'save'): string $token->decode(tokenString: $str, maxAge: 86400): TokenPayload
Tokens are URL-safe base64 strings. maxAge (seconds) enforces expiry.
ComponentEndpoint
Universal PSR-7 handler. Mount it at e.g. /--component/action and /--component/render.
$endpoint->handle(ServerRequestInterface $request): ResponseInterface
Security layers applied on every request:
- Token present and base64-decodable
- HMAC-SHA256 signature valid, optional max-age enforced
componentClassis a real subclass ofComponentactionis in$component->actions()(or'render')- If component implements
Guarded,authorize(action, request)is called
Guarded interface
Add component-level authorization without middleware.
interface Guarded { public function authorize(string $action, ServerRequestInterface $request): void; }
Throw ForbiddenHttpException to deny access.
SwapStrategy enum
OuterHTML, InnerHTML, BeforeBegin, AfterBegin, BeforeEnd, AfterEnd, Delete, None
Twig hd global (HdExtension)
Available as hd in all templates once the extension is registered.
{# POST action — renders hx-post, hx-target, hx-swap attributes #} <button {{ hd.post('increment', 'App\\Counter', componentId, props) }}>+1</button> {# GET action #} <div {{ hd.get('load', 'App\\Feed', componentId, {page: 2}) }}></div> {# Listen for a server-sent event and re-render #} <div {{ hd.on('itemSaved', 'App\\List', componentId, props) }}></div> {# HTMX script tag #} {{ hd.assetTag() }}
Locale prefix
Since 0.46, HtmxExtensionProvider takes an optional pathPrefix closure (\Closure(): string) that is put before every URL it builds (actionUrl, post, get, on). Preflow's core passes the locale prefix of the current request (LocaleMiddleware::urlPrefix()), so on a /de/… page component URLs are /de/--component/… and the action runs in German; the locale middleware strips /de again before the endpoint. Pages without a prefix, and apps without the i18n URL prefix strategy, keep /--component/…. A hand-written hx-post="/--component/…" loses the locale, so use hd.actionUrl(). Only HtmxExtensionProvider (the hd.* helpers in core apps) applies the prefix; the legacy HdExtension does not. Folio's admin preview under /folio carries no locale prefix, so it renders components without it. Error pages are rendered after the pipeline (the kernel catches the exception once the locale middleware has returned), so a component on a 404 or 500 page under /de/… gets an unprefixed /--component/… URL and its action runs in the default locale.
new HtmxExtensionProvider($driver, $token, pathPrefix: fn (): string => $localeMiddleware->urlPrefix());
Usage
Component with an action:
use Preflow\Components\Component; final class Counter extends Component { public int $count = 0; public function resolveState(): void { $this->count = (int) ($_SESSION['count'] ?? 0); } public function actions(): array { return ['increment']; } public function actionIncrement(array $params): void { $this->count++; $_SESSION['count'] = $this->count; } }
Template (Counter.twig):
<p>Count: {{ count }}</p> <button {{ hd.post('increment', 'App\\Counter', componentId, props) }}>+1</button>
Guarded component:
use Preflow\Htmx\Guarded; use Preflow\Core\Exceptions\ForbiddenHttpException; use Psr\Http\Message\ServerRequestInterface; final class AdminPanel extends Component implements Guarded { public function actions(): array { return ['deleteItem']; } public function authorize(string $action, ServerRequestInterface $request): void { $user = $request->getAttribute('user'); if (!$user?->isAdmin()) { throw new ForbiddenHttpException('Admins only.'); } } public function actionDeleteItem(array $params): void { // ... } }
Wire up the endpoint:
use Preflow\Htmx\{ComponentEndpoint, ComponentToken, HtmxDriver, ResponseHeaders}; $token = new ComponentToken(secretKey: $_ENV['APP_KEY']); $headers = new ResponseHeaders(); $driver = new HtmxDriver($headers); $endpoint = new ComponentEndpoint( token: $token, renderer: $renderer, driver: $driver, componentFactory: fn (string $class, array $props) => new $class(), ); // Route POST /--component/action and GET /--component/render to: $response = $endpoint->handle($request);
Add asset tag in your base layout:
{{ hd.assetTag() }}
Asset injection in fragment responses
ComponentEndpoint automatically appends any CSS and JS collected during component rendering to HTMX fragment responses. Styles and scripts defined inside a component with {% apply css %} / {% apply js %} are included even when the response is a partial swap — no separate asset pipeline step needed.
Fragment rendering
When the driver's fragmentTarget() (htmx: the HX-Target header) does not match the component's own ID, ComponentEndpoint returns a fragment response via renderFragment() / renderResolvedFragment() rather than the full wrapped component. This lets a single component handle both full-page renders and targeted partial swaps.
ComponentRenderer::renderResolvedFragment() skips resolveState() and renders inner HTML only — used after an action has already mutated state.