ForgeHtmx

HTMX integration for Forge Kernel. Add AJAX-powered partial updates, triggers, and dynamic navigation to your Forge applications with minimal JavaScript.

What It Is

ForgeHtmx gives Forge controllers first-class HTMX support. Use traits to respond with HTMX headers, render partial views conditionally, and protect your HTMX endpoints with automatic CSRF token injection.

Philosophy

ForgeHtmx uses standard HTMX attributes and behavior. It does not introduce custom HTML attributes, wrappers, or DSLs. Learn HTMX once and use it anywhere.

What Forge Adds

Zero-config HTMX asset injection
Automatic CSRF token configuration
Conditional full/partial view rendering
12 HTMX response helpers
No JavaScript frameworks required
Middlewares that respect HX-Request

Requirements: ForgeHtmx depends on forge-router and forge-view. Both are included with a standard Forge installation.

Installation

Install ForgeHtmx via the Forge CLI:

php forge.php package:install-module --module=forge-htmx

The module auto-registers and asset symlinks are handled automatically.

Basic Usage

Use any standard HTMX attribute (hx-post, hx-get, hx-target, hx-swap, etc.) on your HTML elements. ForgeHtmx doesn't replace HTMX — you get the full HTMX attribute set. The controller handles the request with htmxFragment() to return the raw HTML.

<!-- View: hx-post triggers a POST to /languages, swaps result into #language-switcher -->
<button hx-post="/languages" hx-target="#language-switcher">Switch Language</button>
<div id="language-switcher"></div>
// Controller: returns the rendered fragment
#[Endpoint(path: "/languages", method: "POST")]
public function languages(): Response
{
    return $this->htmxFragment(component(
        name: 'ForgeLanguage:language-switcher',
        props: [
            'definition' => new LanguageSwitcherDefinition(
                showFlags: true,
                showLabels: true,
                showCodes: false,
            )
        ]
    ));
}

Traits Available

ForgeHtmx provides two independent traits.

HtmxResponseHelper

Modules\ForgeHtmx\Traits\HtmxResponseHelper

Standalone trait — no view dependency. Use in any controller that needs to return HTMX responses (redirects, triggers, location changes, etc.).

HtmxViewHelper

Modules\ForgeHtmx\Traits\HtmxViewHelper

For controllers that already use a view() method (e.g. from ForgeView). Provides htmxView() which returns a full page or a partial view depending on whether the request includes the HX-Request header.

Response Helpers

All methods live in HtmxResponseHelper and use the htmx prefix for consistency.

Method Description
htmxFragment() Return raw HTML for HTMX swap
htmxRedirect() Client-side redirect via HX-Redirect
htmxRefresh() Force full page refresh via HX-Refresh
htmxTrigger() Fire client-side events via HX-Trigger
htmxTriggerAfterSwap() Fire events after swap via HX-Trigger-After-Swap
htmxTriggerAfterSettle() Fire events after settle via HX-Trigger-After-Settle
htmxLocation() Navigate to a new URL with optional context via HX-Location
htmxPushUrl() Push a URL into history via HX-Push-Url
htmxReplaceUrl() Replace current URL via HX-Replace-Url
htmxRetarget() Override swap target via HX-Retarget
htmxReswap() Override swap method via HX-Reswap
htmxStopPolling() Stop polling via HTTP 286 status

Usage

<?php

namespace App\Controllers;

use Modules\ForgeHtmx\Traits\HtmxResponseHelper;
use Modules\ForgeRouter\Http\Response;

class TodoController
{
    use HtmxResponseHelper;

    public function delete(string $id): Response
    {
        // Delete logic...

        return $this->htmxTrigger('todo-deleted', ['id' => $id]);
    }
}

Optional: HtmxViewHelper

Use this only when one endpoint needs to serve both full page responses and HTMX partial responses. htmxView($view, $data, $partial) renders the full view on direct visits and the partial (without layout) when it detects the HX-Request header.

public function show(): Response
{
    return $this->htmxView(
        'dashboard/index',          // full page
        ['stats' => $this->getStats()],
        'dashboard/partials/stats'  // partial for HTMX
    );
}

Middleware

ForgeHtmxMiddleware

Registered in the web middleware group at order 2. It injects a <script> tag that configures HTMX to send the CSRF token with every AJAX request. The middleware skips injection when the request is an HTMX partial (has HX-Request header).

// Injected automatically:
document.addEventListener("htmx:configRequest", function(e) {
    var token = document.querySelector('meta[name="csrf-token"]');
    if (token) e.detail.headers["X-CSRF-TOKEN"] = token.getAttribute("content");
});

Note: This middleware only handles CSRF configuration. The htmx.min.js script is injected separately by the module class via the #[RouterHookAttribute(RouterHookName::AFTER_REQUEST)] lifecycle hook.

Common Examples

Partial Page Update

use Modules\ForgeHtmx\Traits\HtmxResponseHelper;
use Modules\ForgeHtmx\Traits\HtmxViewHelper;

class TodoListController
{
    use HtmxResponseHelper;
    use HtmxViewHelper;

    protected function view(string $view, array $data = []): Response
    {
        // ... rendering logic
    }

    public function list(): Response
    {
        $todos = TodoRepository::all();
        return $this->htmxView('todos/index', ['todos' => $todos], 'todos/partials/_list');
    }

    public function toggle(string $id): Response
    {
        TodoRepository::toggle($id);

        $this->flash('status', 'Todo updated');

        return $this->htmxTrigger('todo-toggled', ['id' => $id]);
    }
}

Client-Side Redirect

public function logout(): Response
{
    auth()->logout();

    return $this->htmxRedirect('/login');
}

Return Raw HTML Fragment

public function search(string $query): Response
{
    $results = SearchService::find($query);

    $html = '';
    foreach ($results as $item) {
        $html .= '<div class="result-item">' . htmlspecialchars($item['title']) . '</div>';
    }

    return $this->htmxFragment($html);
}

Stop Polling

public function checkStatus(): Response
{
    if ($this->jobIsComplete()) {
        return $this->htmxStopPolling();
    }

    return $this->htmxFragment('<div>Processing...</div>');
}

Under the Hood

Module Registration

ForgeHtmxModule registers with #[Module] and uses the InjectsAssets trait to inject htmx.min.js into the response via #[RouterHookAttribute(RouterHookName::AFTER_REQUEST)]. The asset is served from /assets/modules/forge-htmx/js/htmx.min.js.

HX-Request Detection

HtmxViewHelper::isHtmxRequest() resolves the current Request from the Container and checks for the HX-Request header. Since it uses the Container rather than a method parameter, controllers don't need to change their method signatures.

CSRF Integration

The default Forge CsrfMiddleware reads the X-CSRF-TOKEN header. ForgeHtmxMiddleware injects a htmx:configRequest event listener that reads the CSRF token from a <meta name="csrf-token"> tag and adds it as the X-CSRF-TOKEN header. This approach avoids timing issues with deferred script loading.

Stopping Polling

HTMX stops polling when it receives an HTTP 286 status. The htmxStopPolling() method returns new Response('', 286), which triggers HTMX to cease polling automatically.