FloCMS Reactive

flocms-reactive 0.5.1 · October 16, 2026 View as Markdown Edit on GitHub

Optional reactive PHP components for FloCMS. Define state and actions in PHP; the browser receives server-rendered HTML updates without a full page refresh. Works on public websites and supplies a foundation for future Admin components.

Status: 0.5, pre-1.0. The API may still change between minor versions. Examples include a searchable, paginated product table with an edit modal, a dependent country/city form with transactional saving, and a contact form showing the binding modes. Nested components, uploads, persistent component storage, and a complete Admin integration are not included yet. FloCMS Core and the skeleton remain usable without this package.

Requirements and installation

  • PHP 8.1+, JSON, mbstring, and FloCMS Core 2.3.2+.
  • A real application needs its normal session startup and APP_KEY.
  • End-user hosting needs PHP and Composer, not Node.js. Compiled assets ship in dist/; Node.js is used only to develop/rebuild the package's browser bundle.

Install into a FloCMS application. FloCMS 1.8.1 pins Core 2.3.1, so update that root requirement explicitly if your skeleton still uses it:

composer require hostkurd/flocms-core:2.3.2 hostkurd/flocms-reactive -W

If the root requirement is already Core 2.3.2+, composer require hostkurd/flocms-reactive is sufficient.

Run the examples

From a Git checkout of this package:

composer install
php -S 127.0.0.1:8080 examples/products/router.php

Open /en/demo for the tables, /en/demo/form for the country/city form or /en/demo/contact for the binding modes. Both also support /ku/ and /ar/. The demo uses SQLite files in sys_get_temp_dir()/flocms-reactive-demo-v0.3, one per demo session, so saved addresses and product edits survive a reload. This versioned directory starts fresh rather than migrating old example databases. Delete those files after testing. They are application records; Reactive itself still recreates components from signed snapshots on each request.

The demo session is not production authentication. Do not deploy its router or fixed signing key. Executable examples are included in Git, not the Composer archive. See the dependent form walkthrough. Use Edit in either product table for the edit-modal walkthrough.

Define a component

namespace App\Components;

use FloCMS\Reactive\Component;
use FloCMS\Reactive\Attributes\{State, Action};
use FloCMS\Core\Auth;
use FloCMS\Core\Http\Request;

final class ProductTable extends Component
{
    #[State(rules: 'max:100', query: 'q')]
    public string $search = '';

    #[State(rules: 'integer|min:1|max:100000', query: 'page')]
    public int $page = 1;

    public function authorize(Request $request): void
    {
        Auth::authorize('catalog.view');
    }

    public function updated(string $property): void
    {
        if ($property === 'search') $this->page = 1;
    }

    #[Action(permission: 'catalog.view')]
    public function clear(): void
    {
        $this->search = '';
        $this->page = 1;
    }

    public function render(): string
    {
        // Run an authorized, parameter-bound query here.
        return $this->view(ROOT . '/views/components/products.html', [
            'search' => $this->search,
            'page' => $this->page,
        ]);
    }
}

See examples/products/ProductTable.php and table.html for the complete database query, pagination, locked featured scope, localized labels, and empty/loading states. Put App\Components in your application's Composer autoload mapping, or use an existing application namespace. This package does not rewrite your composer.json.

Register component aliases explicitly in config/reactive.php:

return ['products' => App\Components\ProductTable::class];

Alternatively call Reactive::register('products', ProductTable::class) in your normal application bootstrap. The lazy package resolver answers only the default route's flo-reactive controller. Application controllers retain priority: reserve that controller name, or mount an update endpoint yourself using Kernel.

Mount and load assets

In a FloCMS template:

{!! \FloCMS\Reactive\Reactive::assets() !!}
{!! \FloCMS\Reactive\Reactive::mount('products', 'catalog') !!}

Load assets once per page, preferably in the layout's head. Component keys must be unique and stable (catalog, featured), using lowercase letters, digits, and hyphens. The wrapper IDs are rx-catalog, rx-featured. Query state is scoped to those keys (catalog_q, catalog_page), so multiple components do not overwrite each other's state. Refreshing a URL restores its component query state. Back and forward navigation reloads the page in this prototype.

Assets use the package's full release version (for example reactive-v0.5.1.js and reactive-v0.5.1.css) and immutable cache headers. The build reads the version from package.json; the manifest and Reactive::assets() select the filenames automatically. Every released asset change needs a new package version so browsers download the update. Do not replace published files under an existing version. The update endpoint uses Cache-Control: no-store. Endpoint and asset URLs honor SITE_URI and the active language, including applications installed below a subdirectory.

Template controls

<form data-rx-submit="refresh">
    <label>Search
        <input type="search" value="{{ $search }}"
               data-rx-bind="search" data-rx-reset="page:1">
    </label>
    <button type="submit">Search</button>
    <button type="button" data-rx-action="clear">Clear</button>
    <span data-rx-loading>Loading…</span>
</form>
  • data-rx-bind: update a declared writable property; by default an update is sent 300 ms after typing stops. data-rx-model chooses another moment (below).
  • data-rx-reset="page:1": include a related state reset. Enforce the invariant in PHP's updated() hook too; browser controls can be bypassed. Matching bound controls are reset immediately, including dependent selects.
  • data-rx-submit: explicitly submit an action, defaulting to refresh. Submit reads current bound control values, including autofill that emitted no input event. Buttons are disabled while an action runs; existing disabled states are preserved. A click during a plain update is queued and runs right after it, once.
  • data-rx-action: invoke a zero-argument method carrying #[Action].
  • data-rx-set + data-rx-value: set a declared property before an action (pagination).
  • data-rx-loading: visible while the component request is running.
  • data-rx-dialog, data-rx-open, data-rx-close: manage a native <dialog> with server-controlled opening and an explicit discard action.
  • data-rx-dialog-trigger: remember the triggering action button for focus return.
  • data-rx-dialog-close: cancel locally and request the server discard action.
  • data-rx-focus-fallback: focus target when a dialog's opener has disappeared.
  • data-rx-ignore="key" (0.5.1+): updates leave this element and its contents alone while the key stays the same, so a widget that builds its own DOM (a rich text editor, a map) survives. When the server renders another key (another record), it is replaced.

When a bound control is sent (0.5+)

<input data-rx-bind="name">                                <!-- live: 300 ms after typing stops -->
<input data-rx-bind="search" data-rx-model="debounce:600"> <!-- live, with another delay (0–10000 ms) -->
<input data-rx-bind="email"  data-rx-model="lazy">         <!-- when the value is committed (change) -->
<input data-rx-bind="city"   data-rx-model="blur">         <!-- when an edited control loses focus -->
<textarea data-rx-bind="body" data-rx-model="defer"></textarea> <!-- only with the next request -->

Every mode keeps the browser's copy current, so an action (Save) always sends the latest values of all bound controls. Use lazy, blur or defer for long forms: each keystroke pause is otherwise a request, which matters on shared hosting. A deferred or lazy value that was typed while another update was running is kept as typed when that update's response arrives; the server copy catches up with the next request. Run the example at /en/demo/contact.

refresh is reserved for rendering without calling a component method. Normal PHP methods, including lifecycle methods, are not exposed unless explicitly marked. Components are not nested in this prototype. Supply an ordinary form/page alternative when the application needs operation without JavaScript.

State, lifecycle, and validation

Only initialized, public properties carrying #[State] are transmitted. This prototype supports non-null string, int, float, and bool; objects, models, arrays, and closures are excluded. #[State(writable: false)] seals server-provided context, such as a category ID. It still appears in the browser: never put secrets in state.

mount() runs initially; updates recreate the component from its signed state. authorize() runs for initial rendering and every update, including after writable changes and around actions. Read authorization decisions from current records and permissions, not from a previous mount. All valid submitted properties are applied before updated() runs, in property declaration order. Hooks see the complete accepted batch and their dependent resets are not overwritten by later client fields. Initial query-state restoration validates and applies values without calling updated(), preserving a bookmarked search/page combination. Hooks must not write records or send external messages. validate() can return additional field errors. The component's errors()/template $errors expose validation errors.

validate() runs before every action and, by default, on plain updates too. Return false from validatesOnUpdate() to check only before actions, so a half-filled form shows no errors and a costly check (a database lookup) does not run while typing. Field rules from #[State(rules: ...)] always apply. Mark Cancel, Discard or Reset actions #[Action(validate: false)]: they run even while the form has errors (a value rejected by its field rule is still not applied).

public function validatesOnUpdate(): bool { return false; }

#[Action(validate: false)]
public function discard(): void { $this->name = ''; }

State::rules uses Core Validator rules (required, string, integer, numeric, min, max, in). Invalid updates do not run an action and return a rendered 422. For invalid type/rule input the last valid state is retained in this prototype. For database mutations, reload the record, authorize it, validate business input, and use a transaction inside the explicitly exposed action. An action may throw Core's ValidationException; Kernel renders its field errors with HTTP 422 and a fresh snapshot, retaining the accepted draft. Other exceptions still propagate. Validate before writing, or throw within a transaction so writes roll back. Action-only required checks let incomplete forms update without errors.

After a successful commit, $this->refreshComponents('catalog', 'featured') requests refreshes of other mounted component keys. These are response-only effects, not snapshot state. Each target refresh uses its own snapshot and current authorization; the originating component already renders its new data. External JavaScript may call window.FloReactive.refresh('catalog') (returns false for an unknown key).

Larger string drafts (0.4+)

Properties retain an 8,192 UTF-8 byte limit by default. A form component may explicitly declare a larger string:

#[State(maxBytes: 65536)]
public string $draft = '';

maxBytes must be 1–65,536. An override applies only to strings; query-bound properties cannot exceed 8,192 bytes. Input, server values and restored snapshots all enforce the declared byte limit. Existing scalar-only rules, writable flags, session/CSRF/signature checks and action authorization still apply. Signed state is readable by the browser and must never contain secrets.

Only schemas opting into larger strings accept a larger HTTP envelope (up to 1 MiB); other schemas keep the 64 KiB request limit. Aggregate signed snapshot JSON is bounded at 256 KiB (the encoded token is capped at 350,000 bytes). JSON escaping and URL encoding count toward envelope limits, so allocate headroom when choosing your component's own draft budget. Configure the web server/PHP request-body limits accordingly. This does not introduce nested state, uploads or automatic write retries.

Upgrade the Composer package and deploy the matching release-version assets. Reload open pages when changing a component's schema or limits; a restored value exceeding the new limit returns 409. Core remains ^2.3.2, and standalone FloCMS does not depend on Reactive. The local example at /en/demo/large demonstrates a larger draft through the real browser transport.

Edit modals

The product example reloads and authorizes a selected record in an explicit edit action, then seals the active record ID and version in locked state. save validates the draft, reloads/rechecks ownership and scope inside a transaction, and updates only if the version still matches. Prices are decimal strings in drafts and integer cents in storage. Stale/replayed saves return a field-error response without overwriting newer records. Successful saves close the dialog and refresh both example tables.

Native dialogs provide background inertness; the adapter preserves them through morph updates, wraps keyboard Tab focus, handles Escape, restores opener/fallback focus, and cleans up removed components. Include a status element inside the dialog so transport/authentication errors remain visible there. Mutation requests disable dialog-bound controls, submission and cancellation until the request settles. Cancellation closes locally and sends no draft updates, so failed cancellation cannot trap the user or persist their draft. The PHP discard action still needs view/session authorization and must not require valid business input or mutate records.

See docs/edit-modals.md for the complete contracts and example.

Request and security contract

Updates POST _snapshot, _token, optional state[property], and _action to /<language>/flo-reactive/update. Action parameters and arbitrary class names are not accepted. Requests are serialized per component; newer pending refreshes replace older pending refreshes. Running actions are not automatically replayed. For financial/external side effects, implement application-level idempotency.

  • Snapshots are HMAC-signed, tied to the session CSRF token, and expire after 30 minutes by default (Config::set('reactive.ttl', 1800)). Signatures do not encrypt.
  • Long sessions (0.5+): while a page stays open, the browser re-signs each component's snapshot once half the TTL has passed (_action=$renew). Renewal re-checks CSRF, the session, the admin context and authorize(), re-signs the unchanged state and renders nothing. An editor can keep a form open for hours without losing the draft; the session itself (login, CSRF token) still ends as configured, and then the component shows the "session ended" or "expired" message.
  • A signed snapshot can be replayed until it expires. That is safe because every update re-checks permissions and actions re-check records, but do not rely on snapshot expiry for anything else.
  • Signing uses APP_KEY, or an explicit reactive.key, with at least 32 bytes.
  • State/property allowlists, action allowlists, and typed input are enforced server-side.
  • Core CSRF runs before the endpoint, and Kernel repeats the check for custom mounts.
  • Auth::refresh() runs on updates. Configure Core's user loader; its configured refresh interval also applies. Use interval 0 for checks on every request.
  • Admin mounting context is signed and enforced at the update endpoint. Other route middleware/guards are not automatically inherited: repeat them in authorize().
  • Application record/tenant scopes must apply to queries and actions. An allowed component is not permission to access every record.
  • Invalid signatures/properties return 400; unknown components/actions 404; authorization 403; ended sessions 401; CSRF/expired snapshots 419; validation 422.

The browser bundle uses htmx for transport, Alpine's CSP build for local behavior, and Alpine Morph for DOM updates. htmx evaluation, script execution, and history HTML caching are disabled. The example tests script-src 'self' and style-src 'self', without inline scripts or unsafe-eval. Escape untrusted template content as usual; rich HTML still needs application sanitization.

The bundle starts Alpine only when window.Alpine is absent. Load an application's Alpine before the Reactive bundle if it already owns Alpine initialization.

Messages the runtime shows itself (session ended, expired, no permission, failed update) follow the page language: the package ships English, Arabic and Kurdish (Sorani), with English for other languages. A site overrides any line in its own lang files with the key reactive.<name>, for example 'reactive.expired' => 'Please reload the page.'. Names: session, forbidden, missing, changed, expired, failed, invalid.

Browser events: flo-reactive:initialized, flo-reactive:before-update, flo-reactive:updated, flo-reactive:error, and flo-reactive:destroyed. Dialogs also emit flo-reactive:dialog-opened and flo-reactive:dialog-closed with detail.name identifying the dialog within its owning component. Use them to integrate and clean up editors and other JavaScript widgets.

Development and checks

vendor/bin/phpunit
npm ci
npm run typecheck
npm run build
npx playwright install chromium
npm run test:browser

For a new release, update the full version in package.json and package-lock.json, then rebuild and commit dist/ with browser-source changes. CI checks PHP 8.1–8.4, TypeScript, reproducible assets, and Chromium browser interactions. CI installs the released Core package from Packagist. Node 24 is used for the package build. See docs/architecture.md for boundaries and next milestones.

License

MIT. Bundled third-party notices are in THIRD_PARTY_NOTICES.md.

Esc