Search by

hostkurd / flocms-reactive

Khalat

Optional reactive PHP components for FloCMS, with server-rendered HTML updates.

Package info

github.com/hostkurd/flocms-reactive

pkg:composer/hostkurd/flocms-reactive

Statistics

Installs: 26

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 0

v0.3.0 2026-10-13 13:26 UTC

README

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.3 prototype in development. Examples include a searchable, paginated product table with an edit modal and a dependent country/city form with transactional saving. 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 or /en/demo/form for the country/city form. 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 (reactive-v0.3.0.js and reactive-v0.3.0.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; input updates debounce 300ms.
  • 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. Submit buttons are disabled during updates; existing disabled states are preserved.
  • 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.

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.

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).

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.
  • 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.

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.