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
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 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-11 00:52:59 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
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 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.