tihloh/prefab-live
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:modelstate binding;- reactive
pf:model.live,.debounceand.blurfield updates; - optional Prefab Input normalization/validation through component
rules(); - application-defined live availability/uniqueness checks through
liveChecks(); - automatic
pf:errorfield messages and field-scopedpf:loading; pf:clickactions;pf:submitform 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">
.livesends the changed field immediately..debounce.400mswaits before sending and is recommended for database/API availability checks..blursends 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:
- Browser requests never choose arbitrary PHP classes.
- Public methods are not remotely callable unless they have
#[Action]. - Previous state is protected by an HMAC checksum.
- Mark identifiers or server-controlled public state with
#[Locked]when the browser must not update them. - Public component state is browser-visible. Do not put passwords, API secrets, access tokens or other secrets in public properties.
- Authorization still belongs in your application/action. A signed request does not replace permission checks.
- Use HTTPS in production.
- 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,columnrule (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
A modern, MIT-licensed Laravel starter kit using Livewire Volt, Laravel Folio, a...
PHP-first mobile UI package for Android WebView — Alpine.js + Livewire v4 compon...
Ready-to-use components, commands, presets, and customizations for Laravel & Fil...
A beautiful Blade UI component library for Laravel & Livewire - Flux UI alternat...