Search by

gecole / htmx

ecoleplus

Livewire-inspired htmx components and request/response helpers for the Gecole framework, shipped as an optional package.

Package info

gitlab.com/gecole/htmx

Issues

pkg:composer/gecole/htmx

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

dev-master 2026-11-07 16:56 UTC

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 and WithPagination for URL/query-string state.
  • ComponentTester for 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.
  • outerHTML by default, or Idiomorph morph swapping 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-component scaffolding with per-engine view stubs.
  • Typed htmx request/response helpers over the framework's own Request and Response objects, 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:

AliasClass
notifications-badgeApp\Components\NotificationsBadge
user-cardApp\Components\UserCardComponent
admin/user-cardApp\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 (namespace App\Components, or the nested namespace and directory for Admin/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:

OptionMeaning
--no-viewcreate the class only
--view=php\|latte\|twig\|blade\|mustacheforce the view engine
--forceoverwrite existing files
--name=UserCardalternative 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 to hx-post, hx-trigger, hx-target, hx-swap and hx-include.
  • ax:model="property" updates on change; ax:model.live uses a 250 ms keyup delay; ax:model.live.debounce.300ms uses a custom delay; ax:model.blur updates on blur.
  • ax:click="method(param:value, other:'text')" becomes hx-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 (or HX-Push-Url when replace: false).
  • except removes 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 with hx-swap-oob="true".
  • dispatch() emits HX-Trigger-After-Swap; a sibling can listen with hx-trigger="project-updated from:body".
  • dispatchImmediately() and dispatchAfterSettle() 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
  • cache stores the payload in app.cache and sends only id.nonce. Use a shared store (file/redis); the array store is process-local.
  • session stores the payload in the session and requires the web middleware.
  • 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-sync does 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() and discardUpload() manage the lifecycle.
  • Configure uploads.directory, uploads.ttl and uploads.max_bytes; run php bin/console htmx:prune-uploads from 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/action attributes 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. Use authorizeAction() 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 web middleware group; the envelope includes _token and hx-include submits 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-confirm is 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 |noescape for 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-sync for 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-include still 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.