mauricius/laravel-htmx
mauricius/laravel-htmx integrates HTMX with Laravel through Blade directives, HTMX-aware middleware, response helpers, and validation support. It helps detect HTMX requests, return partial responses, and build fast, dynamic, server-rendered interfaces with minimal custom JavaScript.
Install and publish
composer require mauricius/laravel-htmx
php artisan vendor:publish --tag=htmx-config # config/htmx.php
The service provider and Htmx facade are auto-discovered on Laravel 8+. v0.10.0 only widens the framework constraint to Laravel 13.x — there are no API changes from v0.9.x, so an in-place composer update is safe.
First place to look
config/htmx.php — controls the script version/CDN used by the Blade directive and any middleware wiring. Publish it first so you're not chasing defaults.Htmx facade source (src/Htmx.php) — it's a thin wrapper over HTMX's request/response headers, and reading it once tells you everything the package can and can't do.Include htmx in your layout
<head>
@htmxScripts
</head>
<body hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
First real use case — a live search
<input type="search"
name="q"
hx-get="{{ route('search') }}"
hx-target="#results"
hx-trigger="keyup changed delay:300ms"
hx-indicator="#spinner">
<div id="results">@include('partials.results', ['users' => []])</div>
public function search(Request $request)
{
$users = User::search($request->q)->limit(10)->get();
// Only the fragment is needed for HTMX; full page for direct visits.
return Htmx::isHtmxRequest()
? view('partials.results', compact('users'))
: view('search.index', compact('users'));
}
That single branch — full page vs. fragment — is the pattern you'll repeat everywhere. Everything else in the package is about the response headers you attach when the fragment alone isn't enough.
Always branch on Htmx::isHtmxRequest() in the controller. Don't infer it from $request->ajax() (HTMX sets HX-Request, not X-Requested-With) and don't put the branching in Blade — it's a response-shape decision, and keeping it in the controller keeps your partials pure and cacheable.
Extract fragments into resources/views/partials/ and have the HTMX branch render only those. A shared layout plus a fragment view means one source of truth; duplicating markup for full-page and HTMX responses is the single most common way this package gets misused.
Response helpers cover the cases where the fragment isn't enough:
// Full browser navigation from an HTMX request (URL bar updates, page reloads)
return Htmx::redirect(route('users.index'));
// Same, but without a reload — pushes the new URL and issues a follow-up GET
return Htmx::location(route('users.index'));
// Force a hard refresh of the current page
return Htmx::refresh();
// Fire a client-side event; optional detail payload
return Htmx::trigger('user-saved', ['id' => $user->id])
->with('status', 'Saved'); // chain back into a normal response
// Override swap / target for this one response
return Htmx::reswap('none')->retarget('#flash');
trigger/reswap/retarget are the escape hatches for "the fragment is right but the swap strategy is wrong" — e.g. a 409 conflict where you want to keep the form and just flash a message via reswap('none').
Out-of-band swaps for multi-region updates. When one action must update several places (e.g. save a row and bump a counter in the header), return the main fragment plus OOB fragments:
{{-- partials/user-row.blade.php --}}
<tr id="user-{{ $user->id }}">...</tr>
<div id="user-count" hx-swap-oob="true">{{ $count }}</div>
This is plain HTMX, not package API, but it's the pattern that removes most of the "I need a full page reload" temptation.
Validation responses. HTMX does not swap on non-2xx by default, so a 422 from $request->validate() will silently no-op. Two clean options:
200 with the form partial + errors and let the fragment re-render.422 and add hx-target/hx-swap overrides on the form, or listen for htmx:responseError in JS.Middleware. If you use Htmx::redirect()/location() anywhere, apply the package's middleware to the relevant group — it's what converts those calls into HX-Redirect/HX-Location headers on the response. Without it, you get a plain 302 and HTMX will follow it transparently inside the XHR, leaving the browser URL stale.
Boost-aware navigation. Htmx::isBoosted() distinguishes boosted full-page navigations from partial requests. If you add hx-boost to your nav, branch on it so boosted requests still return the full layout while genuine fragments don't.
Redirects don't do what you think. A 302 from a controller is followed silently by the XHR — the URL bar never changes and the user's history is wrong. Use Htmx::redirect() (browser navigation) or Htmx::location() (no reload) for any redirect triggered from an HTMX request. This is the #1 bug people hit.
CSRF tokens must be re-sent. Session-token rotation (login, regenerate()) invalidates the token baked into the page. Put hx-headers on <body> rather than per-form so a single meta refresh fixes everything, and add a htmx:configRequest listener if you rotate tokens mid-page.
HX-Request is case-sensitive in some proxies and stripped by others. If isHtmxRequest() returns false in production but true locally, check for a CDN/WAF or a middleware that whitelists headers. Same for HX-Boosted and HX-History-Restore-Request.
Non-2xx means no swap. Covered above, but it also bites abort(403) and policy failures. Either return a rendered fragment with an error state, or catch it client-side.
Script version is config-driven. @htmxScripts pulls the version from config/htmx.php. If you self-host or use a CSP with a strict script-src, publish the config and point it at your own asset rather than fighting the directive.
Config caching. After bumping config/htmx.php, php artisan config:cache — the usual Laravel trap, but it bites here because the failure mode is "the script tag renders a stale version."
Debugging toolkit. In the browser console: htmx.logAll() shows every request/swap decision; document.body.addEventListener('htmx:responseError', ...) catches the silent failures. On the server side, Htmx::isHtmxRequest() in a dd() is usually enough to confirm the header path is intact before you start doubting your fragment.
Extension points are deliberately small. There are no custom Blade components or macro registration — the package is a thin header wrapper. When you need more, you're writing plain HTMX attributes and hx-* config, not extending the package. Plan for that: don't expect to swizzle its behaviour, and don't wrap the facade in yet another abstraction layer.
Upgrade note for v0.10.0. The release is Laravel 13 support plus maintenance housekeeping — no deprecations, no removed methods, no config key changes. It's a drop-in for v0.9.x. The only thing to watch is that laravel/framework ^13.0 in your own composer.json will now resolve; pin to ^0.9|^0.10 if you're rolling out framework upgrades separately from package upgrades.
How can I help you explore Laravel packages today?