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
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() }}
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() }}