tihloh/prefab-live

Server-driven reactive PHP components for Prefab PHP with signed state, explicit actions and a tiny browser runtime.
4
Install
composer require tihloh/prefab-live
PHP:>=8.1
License:MIT
Last Updated:Sep 28, 2026
Links: GitHub  ·  Packagist
Maintainer: tihloh

Prefab Live

Prefab Live adds server-driven reactive PHP components to Prefab without requiring a JavaScript framework.

Keep application state and actions in PHP. Use small pf:* attributes to connect the browser to the server.

Prefab Live currently focuses on the core reactive loop:

  • PHP component classes;
  • public component state;
  • pf:model state binding;
  • reactive pf:model.live, .debounce and .blur field updates;
  • optional Prefab Input normalization/validation through component rules();
  • application-defined live availability/uniqueness checks through liveChecks();
  • automatic pf:error field messages and field-scoped pf:loading;
  • pf:click actions;
  • pf:submit form actions;
  • focus/cursor preservation during component refreshes;
  • signed snapshots;
  • explicit #[Action] methods;
  • #[Locked] state that may be rendered but not client-updated;
  • optional CSRF validation;
  • component-local HTML replacement;
  • lifecycle hooks: mount(), hydrate(), dehydrate();
  • lightweight error bags on components;
  • a small framework-independent browser runtime.

Prefab Live does not require Laravel, Livewire, React, Vue, Alpine or jQuery.

Requirements

  • PHP 8.1 or newer
  • Composer
  • a route/endpoint capable of receiving JSON POST requests

Installation

When published:

composer require tihloh/prefab-live

1. Create a component

use Tihloh\Prefab\Live\Attributes\Action;
use Tihloh\Prefab\Live\Attributes\Locked;
use Tihloh\Prefab\Live\Component;

final class Counter extends Component
{
    public int $count = 0;

    #[Locked]
    public int $ownerId = 0;

    protected function mount(int $start = 0): void
    {
        $this->count = $start;
        $this->ownerId = 42;
    }

    #[Action]
    public function increment(): void
    {
        $this->count++;
    }

    public function render(): string
    {
        return <<<HTML
        <button pf:click="increment">Count: {$this->count}</button>
        <span pf:loading>Working...</span>
        HTML;
    }
}

Only methods marked with #[Action] may be called by browser requests.

2. Register components

The browser sends a component alias, never a PHP class name. The server resolves the alias through an explicit registry:

use Tihloh\Prefab\Live\ComponentRegistry;

$registry = new ComponentRegistry();
$registry->register('counter', Counter::class);

Factories are also supported for dependency injection:

$registry->register('users.search', fn () => new UserSearch($users));

3. Create the Live manager

Use a private application signing key of at least 32 bytes:

use Tihloh\Prefab\Live\LiveManager;

$live = new LiveManager(
    registry: $registry,
    signingKey: $_ENV['PREFAB_LIVE_KEY'],
    endpoint: '/prefab/live',
);

Do not expose PREFAB_LIVE_KEY to the browser or commit it to source control.

4. Mount a component

<?= $live->mount('counter', ['start' => 5]) ?>

Prefab Live wraps the rendered component with its alias, instance ID, signed snapshot, endpoint and optional CSRF token.

5. Add the browser runtime

Prefab Live owns its browser runtime and exposes it without depending on a router:

LiveManager::runtimePath();
LiveManager::runtimeContents();

Your application chooses the public URL and how that URL is routed. For example, plain PHP can expose the package runtime like this:

if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) === '/prefab/live/runtime.js') {
    header('Content-Type: application/javascript; charset=UTF-8');
    header('Cache-Control: no-cache');
    echo LiveManager::runtimeContents();
    exit;
}

Then load it once:

<script src="/prefab/live/runtime.js" defer></script>

No asset copy, publishing step or build step is required. The runtime is read from the installed tihloh/prefab-live package, so the browser code stays aligned with the Composer-installed module version.

Prefab Live does not register routes and does not depend on Prefab Routes. Laravel, Slim, Symfony, Prefab Routes, Apache front controllers and custom routers can all expose the runtime using the same package API.

6. Handle the Live endpoint

The endpoint receives JSON and returns the result of handle() as JSON.

Plain PHP example:

$payload = LiveManager::decodeRequest(file_get_contents('php://input') ?: '');
$response = $live->handle(
    $payload,
    $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null,
);

header('Content-Type: application/json');
echo json_encode($response, JSON_THROW_ON_ERROR);

Prefab Live itself does not call exit() or force a response abstraction. Your application or router owns HTTP output and error handling.

7. pf:model

Bind normal form controls to public component properties:

final class ProfileForm extends Component
{
    public string $name = '';
    public bool $active = false;

    #[Action]
    public function save(): void
    {
        // Persist $this->name and $this->active.
    }

    public function render(): string
    {
        $name = htmlspecialchars($this->name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        $checked = $this->active ? 'checked' : '';

        return <<<HTML
        <form pf:submit="save">
            <input name="name" pf:model="name" value="{$name}">
            <label>
                <input type="checkbox" pf:model="active" {$checked}>
                Active
            </label>
            <button type="submit">Save</button>
            <span pf:loading hidden>Saving...</span>
        </form>
        HTML;
    }
}

Plain pf:model values are collected when a Live action is sent.

For reactive fields, add modifiers:

<input pf:model.live="search">
<input pf:model.live.debounce.400ms="email">
<input pf:model.blur="recordNo">
  • .live sends the changed field immediately.
  • .debounce.400ms waits before sending and is recommended for database/API availability checks.
  • .blur sends when the user leaves the field.

Newer model requests cancel older in-flight requests for the same component so stale responses do not overwrite newer typing. Component refreshes restore focus and the text selection/cursor where possible.

Nested array paths are supported:

<input pf:model="filters.search">
<input pf:model="address.city">

The top-level public property must be an array for nested updates.

8. Forms

<form pf:submit="save">
    <input pf:model="name">
    <input pf:model="email">
    <button type="submit">Save</button>
    <span pf:loading>Saving...</span>
</form>

The browser runtime prevents the normal form submission, sends current model values plus the action, then replaces only that component's rendered HTML.

9. Loading and field errors

Any element with plain pf:loading is visible during any request for that component:

<button pf:click="refresh">Refresh</button>
<span pf:loading>Loading...</span>

A loading indicator can target one reactive field:

<input pf:model.live.debounce.400ms="email">
<span pf:loading="email">Checking email...</span>

Use pf:error to display the first current error for a field:

<input pf:model.live.debounce.400ms="email">
<small pf:error="email"></small>

Prefab Live also applies aria-invalid="true" while that field has an error.

10. Lifecycle

A component may define:

protected function mount(int $userId): void
{
    // Initial mount only.
}

protected function hydrate(): void
{
    // After signed state is restored on a Live request.
}

protected function dehydrate(): void
{
    // Final cleanup hook after rendering/snapshot creation.
}

Lifecycle methods are infrastructure hooks, not browser-callable actions.

11. Live form normalization and validation

Prefab Live remains usable without Prefab Input. When tihloh/prefab-input is installed, a component may expose normal Input rules:

protected function rules(): array
{
    return [
        'email' => 'trim|lowercase|required|email',
        'username' => 'trim|lowercase|required|string|max:30',
        'recordNo' => 'trim|uppercase|required|string',
    ];
}

Reactive validation applies those transformations back to public component state. For example:

"  USER@Example.COM  "
        ↓
trim + lowercase + email
        ↓
"user@example.com"

This lets the server remain the source of truth for formatting instead of duplicating normalization rules in browser JavaScript.

Application-defined availability / uniqueness checks

Database and business checks stay application-owned:

protected function liveChecks(): array
{
    return [
        'email' => function (mixed $value): ?string {
            return $this->users->emailExists((string) $value)
                ? 'Email is already registered.'
                : null;
        },

        'username' => function (mixed $value): ?string {
            return $this->users->usernameExists((string) $value)
                ? 'Username is already taken.'
                : null;
        },

        'recordNo' => function (mixed $value): ?string {
            return $this->documents->recordExists((string) $value)
                ? 'Record number already exists.'
                : null;
        },
    ];
}

The field name is not special. Applications can check email addresses, usernames, employee IDs, student numbers, OBR numbers, invoice numbers, SKUs, voucher codes or any other value.

A check callback receives:

$value
$field
$component

and returns null/ true when valid or an error string when invalid.

For edit forms, the application can naturally ignore the record being edited:

#[Locked]
public int $userId;

protected function liveChecks(): array
{
    return [
        'email' => fn (mixed $value): ?string =>
            $this->users->emailExists((string) $value, exceptId: $this->userId)
                ? 'Email is already in use.'
                : null,
    ];
}

Always validate again before saving

Live checks are user experience, not a persistence guarantee. Final actions should rerun validation:

#[Action]
public function save(): void
{
    if (!$this->validate()) {
        return;
    }

    // Persist only after the current values pass again.
}

Validate one field when needed:

$this->validateOnly('email');

The database should still enforce real uniqueness with unique indexes/constraints because two requests can pass an availability check at nearly the same time.

Components still have the low-level error bag APIs errors(), error(), addError() and clearErrors() for custom action feedback.

12. Security model

A Live request contains:

component alias
instance ID
signed previous snapshot
model updates
optional action

The server performs this sequence:

request
  ↓
verify signed snapshot
  ↓
resolve alias through ComponentRegistry
  ↓
restore public state
  ↓
apply explicit model updates
  ↓
call only a #[Action] method
  ↓
render
  ↓
sign next snapshot
  ↓
response

Important rules:

  1. Browser requests never choose arbitrary PHP classes.
  2. Public methods are not remotely callable unless they have #[Action].
  3. Previous state is protected by an HMAC checksum.
  4. Mark identifiers or server-controlled public state with #[Locked] when the browser must not update them.
  5. Public component state is browser-visible. Do not put passwords, API secrets, access tokens or other secrets in public properties.
  6. Authorization still belongs in your application/action. A signed request does not replace permission checks.
  7. Use HTTPS in production.
  8. Use your application's CSRF validation for authenticated browser sessions.

13. CSRF integration

Pass the rendered CSRF token and a validator:

$live = new LiveManager(
    registry: $registry,
    signingKey: $_ENV['PREFAB_LIVE_KEY'],
    endpoint: '/prefab/live',
    csrfToken: $session->csrfToken(),
    csrfValidator: fn (?string $token): bool => $session->validateCsrf($token),
);

The browser runtime sends the token in X-CSRF-Token.

14. State types

Prefab Live public state is intentionally simple:

null
string
int
float
bool
array of JSON-safe values

Objects, database connections, service objects and resources belong in private/protected properties or constructor-injected dependencies, not serialized public state.

Typed public scalar properties are safely coerced from browser form values where possible.

15. Request protocol

Example request:

{
  "id": "f12ab34cd56ef789",
  "component": "counter",
  "snapshot": {"count": 5},
  "checksum": "...",
  "updates": {"count": "7"},
  "action": {"method": "increment", "params": []}
}

Example response:

{
  "id": "f12ab34cd56ef789",
  "component": "counter",
  "html": "<button pf:click=\"increment\">Count: 8</button>",
  "snapshot": {"count": 8},
  "checksum": "..."
}

16. Responsibility boundary

Prefab Live owns:

PHP component state
      ↕
signed Live protocol
      ↕
tiny browser bridge
      ↕
focus-preserving component HTML refresh

It does not own:

  • your database/business model;
  • application authorization policy;
  • page routing;
  • a template engine;
  • frontend styling;
  • general JavaScript application state;
  • file uploads yet;
  • nested Live components yet;
  • polling/lazy loading yet;
  • URL/query-string binding yet;
  • a built-in database-specific unique:table,column rule (uniqueness remains application-owned).

Those features can be added after the base protocol is stable.

17. Design principle

Prefab Live follows the wider Prefab rule:

Prefab automates reusable plumbing. Your application keeps control of behavior and architecture.

For ordinary interactive PHP screens, the target experience is:

normal PHP
    +
normal HTML
    +
a few pf:* attributes
    =
server-driven reactive UI

Related Packages

ilhamsyabani/laravel-volt-starter

A modern, MIT-licensed Laravel starter kit using Livewire Volt, Laravel Folio, a...

7 0
nativemobile/laravel-mobile-ui

PHP-first mobile UI package for Android WebView — Alpine.js + Livewire v4 compon...

1 0
sikessem/components

Ready-to-use components, commands, presets, and customizations for Laravel & Fil...

819 0
flux-clone/ui

A beautiful Blade UI component library for Laravel & Livewire - Flux UI alternat...

131 0