preflow/htmx

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, and dispatch. HtmxDriver generates hx-* attributes and sets response headers. The hd Twig global surfaces all of this as template helpers.

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:

  1. Token present and base64-decodable
  2. HMAC-SHA256 signature valid, optional max-age enforced
  3. componentClass is a real subclass of Component
  4. action is in $component->actions() (or 'render')
  5. 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() }}