gecole / htmx
Livewire-inspired htmx components and request/response helpers for the Gecole framework, shipped as an optional package.
Requires
- php: >=8.3
- gecole/framework: dev-main
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-07 16:58:02 UTC
README
Livewire-inspired server components for the Gecole framework, powered by htmx. Optional package: the framework core stays dependency-free, and the component protocol itself ships no custom JavaScript.
composer require gecole/htmx
What it gives you
- A component class with public state,
mount()/hydrate()lifecycle hooks,rendering()/rendered()hooks, actions, validation, redirects and client events. - An element-level action model (
ax:click,ax:submit,ax:model, …), so components are not limited to forms: buttons, links, table rows and inputs can all trigger server actions. - Parameter binding for actions (
ax:click="remove(id:5)"→remove(int $id)). - Optional
#[HtmxAction]whitelisting. authorizeAction()hook for centralized action authorization.#[Url]properties andWithPaginationfor URL/query-string state.ComponentTesterfor rendering, acting on and asserting components in tests.- Double-submit protection through
hx-disabled-elt. - Signed state round-tripping between requests (HMAC, no server-side session storage required), or cache/session stores for large, non-visible payloads.
- A single POST endpoint that re-renders one component as an HTML fragment.
outerHTMLby default, or Idiomorphmorphswapping for focus/scroll-safe updates.- Lazy/deferred islands, polling components, slots, nested components with scoped state, out-of-band updates and client events.
- Temporary file uploads with validation and persistence helpers; indicators and disabled-while-processing actions.
- Typed htmx request/response helpers.
make:htmx-componentscaffolding with per-engine view stubs.- Typed htmx request/response helpers over the framework's own
RequestandResponseobjects, with no PSR-7 dependency.
Enable
Add the provider and, optionally, copy the config:
// config/app.php
'providers' => [
// …
\Gecole\Htmx\HtmxServiceProvider::class,
],
// config/htmx.php
return [
'endpoint' => '/_htmx',
'middleware' => ['web'],
'route' => true,
'key' => '', // defaults to APP_KEY
'max_state_bytes' => 65536,
'state_ttl' => 0, // seconds; 0 disables expiry
'state_store' => 'signed', // signed, cache or session
'state_cache_ttl' => 3600, // used by the cache store
'namespace' => '', // defaults to app.namespace . "\Components"
'components' => [], // only non-conventional aliases need entries
'script' => '', // optional htmx CDN/local asset for htmx_script()
'uploads' => [
'directory' => '', // defaults to storage/framework/htmx-uploads
'ttl' => 3600,
'max_bytes' => 10_485_760,
],
];
Component names
You do not need to list every component. With the default namespace
(app.namespace . "\Components"), the alias is derived from the class name:
| Alias | Class |
|---|---|
notifications-badge | App\Components\NotificationsBadge |
user-card | App\Components\UserCardComponent |
admin/user-card | App\Components\Admin\UserCard |
Use components only for aliases that do not follow that convention, and
namespace when components live outside App\Components. Class names
(htmx_component(\App\Components\Counter::class)) always work too.
The provider registers POST /_htmx as htmx.handle, shares $htmx with
views, binds Gecole\Htmx\Htmx in the container, and registers
make:htmx-component.
Scaffolding
php bin/console make:htmx-component UserCard
php bin/console make:htmx-component Admin/UserCard --no-view
php bin/console make:htmx-component UserCard --view=latte --force
The generator creates:
src/App/Components/UserCard.php(namespaceApp\Components, or the nested namespace and directory forAdmin/UserCard)resources/views/components/user-card.<ext>by default
The view engine comes from app.views.default, so a Latte app gets a Latte
stub and a PHP app gets a PHP stub. Options:
| Option | Meaning |
|---|---|
--no-view | create the class only |
--view=php\|latte\|twig\|blade\|mustache | force the view engine |
--force | overwrite existing files |
--name=UserCard | alternative to the positional name |
The generated class includes mount(), an increment() action marked with
#[HtmxAction], and a matching render() view name. The view includes a
working ax:click action. Render it with:
<?= htmx_component('user-card') ?>
Client setup
htmx itself is the app's dependency. Either bundle it:
bun add htmx.org
# optional, for morph swapping:
# bun add idiomorph
// resources/js/app.ts
import htmx from 'htmx.org'
// optional: enables hx-swap="morph"
// import 'idiomorph/htmx'
window.htmx = htmx
or add a CDN tag to the layout:
<?= htmx_script('https://unpkg.com/htmx.org@2.0.4') ?>
htmx.script / htmx_script() is only a convenience; no package-specific JS is
required. The component wrapper is plain HTML + htmx attributes, so the app's
existing asset pipeline is enough. Generic package asset publishing is not
needed for this package.
A component
namespace App\Components;
use Gecole\Htmx\Attribute\HtmxAction;
use Gecole\Htmx\Component;
final class Counter extends Component
{
public int $count = 0;
public string $label = '';
public function mount(array $params = []): void
{
$this->count = (int) ($params['start'] ?? 0);
}
public function render(): string
{
return 'components.counter';
}
#[HtmxAction]
public function increment(): void
{
$this->count++;
}
#[HtmxAction]
public function remove(int $id): void
{
// $id is bound from ax:click="remove(id:5)"
}
}
// resources/views/components/counter.php
?>
<div class="counter">
<p>Count: <strong><?= (int) $count ?></strong></p>
<input type="text" ax:model.live.debounce.300ms="label" value="<?= e($label) ?>">
<button type="button" ax:click="increment">+1</button>
<button type="button" ax:click="remove(id:5)">Remove #5</button>
</div>
Render it from any view:
<?= htmx_component('counter', ['start' => 5]) ?>
The rendered fragment is a <div> envelope, not a form:
<div id="htmx-…" data-htmx-component="counter"
hx-target="#htmx-…" hx-swap="outerHTML" hx-include="#htmx-…">
<input type="hidden" name="_token" value="…">
<input type="hidden" name="_state" value="…">
…component view…
</div>
The envelope can contain any markup, including real forms. Actions are
element-level hx-post requests generated from ax:* attributes, so buttons,
links, table rows and inputs outside a <form> all work.
Actions and binding
<button type="button" ax:click="increment">+1</button>
<button type="button" ax:click="remove(id:5)">Delete</button>
<button type="button" ax:mouseenter="preview(id:5)">Preview</button>
ax:click,ax:submit,ax:change,ax:mouseenter, … compile tohx-post,hx-trigger,hx-target,hx-swapandhx-include.ax:model="property"updates onchange;ax:model.liveuses a 250 ms keyup delay;ax:model.live.debounce.300msuses a custom delay;ax:model.blurupdates on blur.ax:click="method(param:value, other:'text')"becomeshx-vals; action parameters are bound by name and type.- Actions are opt-in: mark every callable method with
#[HtmxAction].protected bool $allowUnmarkedActions = true;is an explicit prototype escape hatch.
#[HtmxAction]
public function remove(int $id): void
{
// $id comes from ax:click="remove(id:5)"
}
Reserved framework methods (render, mount, validate, redirect, …),
private/protected methods and static methods cannot be invoked.
Actions may return a Response; a returned RedirectResponse becomes an
HX-Redirect header automatically.
Authorization and double submits
Centralize permission checks instead of repeating them in every action:
use Gecole\Framework\Auth\AuthorizationException;
protected function authorizeAction(string $action, array $input): void
{
if ($action === 'delete' && ! can('delete-project', $input['id'] ?? null)) {
throw new AuthorizationException('You cannot delete this project.');
}
}
authorizeAction() runs before every action, after input sync. The framework
renders an AuthorizationException as a 403.
Generated action elements carry hx-disabled-elt="this", so a button cannot be
submitted twice while the request is in flight. Disable it per component with:
protected bool $disableWhileProcessing = false;
URL state and pagination
Mark public properties with #[Url] to hydrate them from the query string and
push changes back into the browser URL:
use Gecole\Htmx\Attribute\Url;
#[Url(as: 'q', replace: true)]
public string $search = '';
#[Url(as: 'page', except: 1)]
public int $page = 1;
- First render reads
?q=…into$search. - When the property changes, the component emits
HX-Replace-Url(orHX-Push-Urlwhenreplace: false). exceptremoves the key when the value matches, keeping URLs clean.
WithPagination provides page state, page actions and a query adapter:
use Gecole\Htmx\Concerns\WithPagination;
final class ProjectList extends Component
{
use WithPagination;
public function render(): string
{
return 'components.project-list';
}
public function data(): array
{
return ['projects' => $this->paginate(Project::query()->orderBy('id', 'desc'))];
}
}
<button type="button" ax:click="previousPage">Previous</button>
<button type="button" ax:click="gotoPage(page:2)">2</button>
<button type="button" ax:click="nextPage">Next</button>
$page is URL-bound (?page=3); $perPage is #[Locked] and clamped to
1–100. The adapter accepts a model query (paginate()) or a raw query builder
(count/limit/offset/get).
Lazy and polling islands
Render a placeholder that mounts the component only when it becomes visible:
<?= htmx_lazy_component('reports', ['range' => '30d'], [
'trigger' => 'intersect once',
'skeleton' => '<p>Loading reports…</p>',
]) ?>
The signed mount parameters go to GET /_htmx/lazy; mount() runs when the
placeholder loads. Keep mount() idempotent and cheap.
Poll a component by setting its interval:
final class LiveQueue extends Component
{
protected ?int $pollSeconds = 5;
// …
}
The envelope gets hx-get="/_htmx/poll?_poll=…" and
hx-trigger="every 5s", so it re-renders with its current signed state. Polling
uses hx-sync replace semantics; do not poll expensive queries without indexes.
Composition
Components accept mount props:
<?= htmx_component('user-card', ['id' => $user->id]) ?>
A third argument passes a raw HTML slot:
<?= htmx_component('panel', ['title' => 'Filters'], $filterMarkup) ?>
// resources/views/components/panel.php
?>
<section>
<h2><?= e($title) ?></h2>
<?= $slot ?>
</section>
Nested components are valid because the envelope is a <div>. Each component
uses a scoped state field (_state_{id}) and its action URLs carry
_component={id}, so an outer action cannot pick up an inner component's state.
Use distinct input names when both levels are editable.
Cross-component updates:
// inside an action
$this->addOutOfBand(htmx_oob('stats', ['project' => $this->projectId]));
$this->dispatch('project-updated', ['id' => $this->projectId]);
htmx_oob()renders another component withhx-swap-oob="true".dispatch()emitsHX-Trigger-After-Swap; a sibling can listen withhx-trigger="project-updated from:body".dispatchImmediately()anddispatchAfterSettle()control the timing.
State and lifecycle
Public properties are the signed state. They must be JSON-friendly scalars or
arrays (no resources, PDO handles or arbitrary objects). The state is signed,
not encrypted: it is visible in the page, so never put secrets in public
properties. Public properties are also exposed directly to the view
($count, $label, …), alongside $component, $errors and $endpoint.
public function hydrate(): void { /* state restored */ }
public function updating(string $property, mixed $value): void { /* before assign */ }
public function updated(string $property, mixed $value): void { /* after assign */ }
public function rendering(): void { /* before the view renders */ }
public function rendered(string $view): void { /* after the view renders */ }
public function dehydrate(): void { /* before signing */ }
mount() runs only on the first render. Dirty posted fields are synchronised
into matching public properties before the action runs.
Morph swapping
outerHTML is the default and has no client dependency. For focus- and
scroll-safe updates, enable Idiomorph and set the component swap strategy:
bun add htmx.org idiomorph
// resources/js/app.ts (or a module imported by it)
import htmx from 'htmx.org'
import 'idiomorph/htmx'
window.htmx = htmx
The idiomorph/htmx entry registers the morph extension on the htmx instance.
Import it after htmx.org. The app's Bun build then hashes it with the rest of
the assets; nothing else is needed.
final class UserList extends Component
{
protected string $swap = 'morph:{ignoreActiveValue:true}';
protected array $extensions = ['morph'];
// …
}
If the app does not use Bun, the same extension can be loaded from a CDN:
<script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js"></script>
Morph swapping preserves focus, input values, scroll position and media state
much better than replacing the whole wrapper. The envelope carries hx-ext so
the extension is enabled per component.
State stores
The default store signs and embeds the payload in the page. For large screens or state that should not be visible in HTML, keep it server-side:
// config/htmx.php
'state_store' => 'cache', // signed (default), cache, session
'state_cache_ttl' => 3600,
'state_ttl' => 3600, // signed-store expiry
cachestores the payload inapp.cacheand sends onlyid.nonce. Use a shared store (file/redis); the array store is process-local.sessionstores the payload in the session and requires thewebmiddleware.- Tokens rotate on every render, so an older token becomes invalid. This is
stronger replay protection than the signed store, but it means two concurrent
requests for the same component must be serialized (
hx-syncdoes this for generated requests). - State still belongs in IDs/filters. For a large editor, store a draft ID and hydrate the rows from the database rather than keeping the whole form in state.
Validation
public function save(): void
{
if (! $this->validate([
'title' => 'required|string|min:3',
'email' => 'required|email',
], [], ['title' => 'Title'])) {
return;
}
$data = $this->validated();
// …
}
Errors are available to the view as $errors and stay in the re-rendered
fragment (status 200 so htmx swaps it). rules(), validationMessages() and
validationLabels() are also available as defaults.
Redirects and events
public function save(): void
{
$this->dispatch('saved', ['id' => 42]); // after swap
$this->redirect('/dashboard'); // HX-Redirect
}
Also available: pushUrl(), replaceUrl(), refresh(), reswap(),
retarget(), dispatchImmediately() and dispatchAfterSettle().
Request / response helpers
$request = htmx_request();
if ($request->isHtmx()) {
$target = $request->target();
$triggeredBy = $request->triggerName();
$currentUrl = $request->currentUrl();
$prompt = $request->prompt();
}
return htmx_response(Response::html($fragment))
->pushUrl('/items/42')
->trigger('itemSaved', ['id' => 42])
->retarget('#item-42')
->apply();
Available request methods: isHtmx(), isBoosted(),
isHistoryRestoreRequest(), currentUrl(), prompt(), target(),
triggerId(), triggerName().
Available response methods: pushUrl(), replaceUrl(), reswap(),
retarget(), refresh(), redirect(), location(), trigger(),
triggerAfterSwap(), triggerAfterSettle(), header(), apply().
File uploads
Mark a public string property with #[FileUpload]:
use Gecole\Htmx\Attribute\FileUpload;
#[FileUpload]
public ?string $avatar = null;
public function save(): void
{
if (! $this->validate(['avatar' => 'required|image|max:2048'])) {
return;
}
$path = $this->persistUpload('avatar', 'avatars', 'public');
// store $path on your model
}
<input type="file" ax:model="avatar">
<?php if ($upload = $component->upload('avatar')): ?>
<p><?= e($upload->originalName()) ?> — <?= (int) $upload->size() ?> bytes</p>
<button type="button" ax:click="removeAvatar">Remove</button>
<?php endif; ?>
#[HtmxAction]
public function removeAvatar(): void
{
$this->discardUpload('avatar');
}
- The property holds an opaque temporary token; file bytes stay in
storage/framework/htmx-uploads. - Normal validation rules (
file,image,mimes,max) work because the component reconstructs a file entry from the temp upload. upload(),persistUpload()anddiscardUpload()manage the lifecycle.- Configure
uploads.directory,uploads.ttlanduploads.max_bytes; runphp bin/console htmx:prune-uploadsfrom cron/queue if uploads outlive the TTL. - In tests:
$tester->upload('avatar', UploadedFile::fake('avatar.png', 'image/png', 512));
Indicators and loading states
protected ?string $indicator = '#upload-spinner';
Generated actions/inputs get hx-indicator="#upload-spinner". The trigger
element also gets hx-disabled-elt="this" by default; disable that per
component with:
protected bool $disableWhileProcessing = false;
For optimistic UI or complex local state, use a small JS island; the component protocol stays server-rendered.
Page navigation and history
Boost normal links/forms with native htmx attributes:
<nav hx-boost="true" hx-target="#main" hx-swap="innerHTML" hx-push-url="true">
<a href="/projects">Projects</a>
</nav>
- The server can detect boosted navigation with
htmx_request()->isBoosted()and return a fragment instead of the full layout. pushUrl()/replaceUrl()and#[Url]properties keep filters and pages in history.- Keep normal
href/actionattributes so the no-JS baseline still works.
Security model
This is a server-component layer: treat every action and every public property as reachable by the browser.
- Actions are opt-in. A public method is callable only when marked with
#[HtmxAction].protected bool $allowUnmarkedActions = true;is an explicit prototype escape hatch, not a default. UseauthorizeAction()for centralized permission checks. - Public properties are client-writable unless marked
#[Locked]or named with a leading underscore. Use#[Locked]for server-derived flags (roles, ownership, computed IDs) and verify authorization inside actions anyway.#[Url]properties are intentionally client-writable; treat them as filters, not privileges. - State is signed, not encrypted. Public state is visible in the page. Do
not put secrets, tokens, password hashes or sensitive personal data in public
properties. Use protected/private properties and recompute in
hydrate(). - CSRF is required. Keep the endpoint on the
webmiddleware group; the envelope includes_tokenandhx-includesubmits it. Do not expose a component endpoint on a stateless API group unless you understand the trade-off. - Replay remains possible. State is stateless and signed; an old token can
be replayed. Set
htmx.state_ttl(seconds) to narrow the window, make destructive actions idempotent, and verify ownership/permissions in the action.hx-confirmis not an authorization mechanism. - Concurrency. Generated action requests use
hx-sync="…:queue"; model updates use…:replace. This prevents lost updates from overlapping clicks and stale search responses. Database transactions/locks are still required for invariants. - XSS. The package escapes envelope attributes and action URLs, but the
component view is trusted application code. Latte auto-escapes; keep
|noescapefor known-safe markup only. - Action names are public. URLs expose method names; that is fine, but do not put secrets in names and do not rely on obscurity.
Performance notes
- Keep signed state small: filters and IDs, not full result sets. Large public arrays bloat every page and every request.
- Fetch data in
data()/viewData()or the action; eager-load relations to avoid N+1 queries on each component request. - Every interaction is a normal HTTP request; use
hx-syncfor search-as-you-type and disable submit controls (hx-disabled-elt) when an action is not idempotent. - The endpoint is a single cacheable route; there is no per-action route explosion.
- Idiomorph (
morph) reduces DOM churn and preserves focus, which is cheaper and nicer than replacing large subtrees when components are big.
Limitations
- Interactive components require htmx. Treat them as progressive enhancement: keep critical navigation in normal links/forms outside the component.
- The response is an HTML fragment, not a full page.
- State is signed but visible. Keep it small and free of secrets.
- DOM updates replace the envelope (
outerHTML) unless you enable Idiomorph (morph) as shown above. - Nested components use scoped state fields and action URLs, but
hx-includestill includes all inputs inside the outer envelope. Use distinct input names or explicit targets when both levels are editable. - No package-specific JS is shipped. Focus/scroll preservation comes from Idiomorph when enabled, not from a custom asset.
Testing components
ComponentTester runs the real renderer and endpoint controller:
use Gecole\Htmx\Testing\ComponentTester;
$test = ComponentTester::make('counter', ['start' => 1]);
$test->assertState('count', 1)
->call('increment')
->assertState('count', 2)
->assertSee('data-count>2');
$test->call('save', ['name' => ''])
->assertErrors('name')
->assertSee('Name is required.');
Available assertions: assertSee, assertDontSee, assertState,
assertErrors, assertNoErrors, assertStatus, assertRedirect,
assertTrigger. Helpers: fill(), withHeader(), html(), state(),
component(), response().
A global component_test('counter') helper is also registered.
Tests
php tests/run.php
# or point at a framework checkout
FRAMEWORK_AUTOLOAD=/path/to/gecole-framework/vendor/autoload.php php tests/run.php
The suite covers request/response headers, signed state, tamper rejection, component rendering, actions, dirty-field sync, lifecycle hooks, validation and route registration.
HTMX compatibility
The package uses the standard htmx request headers (HX-Request, HX-Target,
HX-Trigger, HX-Current-Url, …) and response headers (HX-Push-Url,
HX-Redirect, HX-Trigger, HX-Reswap, …). It operates directly on the
framework's own Request/Response objects and has no PSR-7 dependency.