HTMX integration for Forge Kernel. Add AJAX-powered partial updates, triggers, and dynamic navigation to your Forge applications with minimal JavaScript.
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.
Requirements: ForgeHtmx
depends on
forge-router
and
forge-view.
Both are included with a standard Forge
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.
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,
)
]
));
}
ForgeHtmx provides two independent traits.
Modules\ForgeHtmx\Traits\HtmxResponseHelper
Standalone trait — no view dependency. Use in any controller that needs to return HTMX responses (redirects, triggers, location changes, etc.).
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.
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 |
<?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]);
}
}
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
);
}
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.
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]);
}
}
public function logout(): Response
{
auth()->logout();
return $this->htmxRedirect('/login');
}
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);
}
public function checkStatus(): Response
{
if ($this->jobIsComplete()) {
return $this->htmxStopPolling();
}
return $this->htmxFragment('<div>Processing...</div>');
}
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.
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.
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.
HTMX stops polling when it receives an HTTP 286
status. The
htmxStopPolling()
method returns
new Response('', 286), which triggers HTMX to cease polling
automatically.