Live Components
Live Components let you build interactive UI from a PHP class and a plain PHP template. The server owns the state and renders HTML; the browser swaps it in. There's no client-side framework, no JSON API to keep in sync, and pages still work with JavaScript turned off.
- Public properties are the component's state.
#[Action]methods are what buttons can call.#[Model]properties bind to form inputs, with validation.
State travels in the page as a signed snapshot. Each request builds a fresh component from it, so workers keep nothing between requests — no per-user memory, nothing to leak between users, nothing lost when a worker restarts.
Live demo: /live-components.
Setup
Components sign their state with ZEALPHP_APP_KEY. Generate one and export it:
php app.php key:generate
# ZEALPHP_APP_KEY=base64:3q2+7w...
export ZEALPHP_APP_KEY=base64:3q2+7w...
Register your components before App::run(), in app.php or a route/*.php file:
use App\Components\{Counter, SignupForm};
App::components([Counter::class, SignupForm::class]);
The name defaults to the kebab-case class name (SignupForm → signup-form). To choose it, pass 'name' => Class::class. Only registered components can be reached from the browser.
If components are registered and there's no key, php app.php refuses to start. In dev mode (php app.php --dev or ZEALPHP_DEV=1) it uses a random key for that boot instead, and open pages need a reload after a restart.
The component templates use htmx attributes by default, so the page needs htmx 2 loaded. See Clients to use something else.
A first component
namespace App\Components;
use ZealPHP\Component\{Action, Component};
final class Counter extends Component
{
protected string $tag = 'form'; // the outer element; 'form' also works without JS
public int $count = 0;
#[Action]
public function add(int $by = 1): void
{
$this->count += $by;
}
public function render(): mixed
{
return $this->view('components/counter');
}
}
<?php /* template/components/counter.php */ use function ZealPHP\e; ?>
<span><?= e($count) ?></span>
<button <?= $c->action('add') ?>>+1</button>
<button <?= $c->action('add', ['by' => 10]) ?>>+10</button>
Put it on a page:
<?= App::component(Counter::class, ['count' => 5], key: 'main') ?>
The template is only the inside of the component. The framework wraps it in the $tag element (with id="counter-main" and data-zc="counter") and adds a hidden _zc input holding the signed snapshot.
App::component() returns the markup and never echoes it, so <?= ?> is correct. That's unlike App::render(); see the echo side effect described in Templates and Rendering.
What templates receive
$this->view($template, $extra = []) renders template/{$template}.php with:
| Variable | Value |
|---|---|
| each public property | its current value ($count) |
$c |
the component, for the helpers below |
$errors |
field => message validation errors |
anything in $extra |
as given |
The template path is always absolute from template/, so the page and the endpoint render the same file.
Keys and ids
The instance id is {name}-{key}; it's also the outer element's DOM id. Without a key, instances are numbered per request (counter-1, counter-2). Give a stable key: when the same component appears more than once, or when it must keep its state across a no-JS form submission.
State
Public properties declared on your class are the state. Each must have one declared type:
string,int,float,boolarray— of scalars and nested arrays (up to 4 levels)- a backed enum
- any of these nullable (
?int)
Anything else — objects, union types, mixed, untyped — is refused when you register the component, so the browser can never cause an object to be constructed. Names c, errors, and names starting with _ are reserved.
State is signed, not encrypted. Anyone who can see the page can read it. Keep ids in state and load the records in render():
final class InvoiceRow extends Component
{
public int $invoiceId = 0;
public function render(): mixed
{
return $this->view('invoices/row', ['invoice' => Invoice::find($this->invoiceId)]);
}
}
Protected and private properties are not state; they're reset on every request. Use them for configuration ($tag, $submit, $attributes) or per-request caches.
Lifecycle
| Hook | When |
|---|---|
mount() |
Once, when App::component() first places the component, after initial props are set |
hydrate() |
On every endpoint request, after state is restored, before input is applied |
updated{Prop}($value) / updated($prop, $value) |
After a #[Model] property changes from input (define them protected or public) |
authorize(string $action): bool |
Before input is applied; return false to answer 403 |
render() |
Every time the component is displayed |
authorize() receives the action name, or _update / _refresh for input-only requests.
Actions
Mark a public method #[Action] to let the browser call it. $c->action('name', $args) prints the attributes for a button:
#[Action]
public function move(int $position, Direction $direction = Direction::Up): void { /* ... */ }
<button <?= $c->action('move', ['position' => $i, 'direction' => 'down']) ?>>Down</button>
Arguments are bound by parameter name and converted to the declared type; a value that doesn't convert, or a missing required argument, is a 400. Parameter types follow the same rules as state. Methods without #[Action] — including render(), mount() and anything inherited from Component — answer 404.
Inside an action:
| Call | Effect |
|---|---|
$this->validate() |
Validate #[Model] fields; on failure, stop the action and re-render with errors |
$this->addError($field, $msg) / clearErrors() |
Set or clear errors directly |
$this->dispatch('event', $detail) |
Fire a browser event after the swap (htmx: HX-Trigger) |
$this->redirect('/path') |
Navigate after the action (same-site paths only) |
$submit names the action that runs when the component's form is submitted (Enter key, or a <button type="submit"> with no action()):
protected string $tag = 'form';
protected ?string $submit = 'save';
Input binding
Mark a property #[Model] to bind it to a form control, and use $c->model('prop') on the control:
#[Model(rules: 'required|email')]
public string $email = '';
#[Model(live: true, rules: 'required|min:3')]
public string $username = '';
#[Model(blur: true)]
public int $age = 0;
<input <?= $c->model('email') ?>>
<?= $c->error('email') ?>
model() prints name, id ({instance id}-{prop}), the current value, aria-invalid/aria-describedby when the field has an error, and the client attributes for live or blur fields.
| Option | When the value is sent |
|---|---|
| (default) | With the next action |
live: true |
As you type, after debounce ms (default 300) |
blur: true |
When the field changes and loses focus |
A live or blur change posts to the component's _update endpoint: input is applied, only the changed fields are validated, and the component re-renders. Errors stay on fields until they're fixed. The control you're typing in is left untouched by the swap (htmx hx-preserve), so focus, cursor and in-flight keystrokes survive.
Only #[Model] properties accept input, and only those the last render printed a control for. Everything else in the request is ignored — including values for real properties that have no #[Model].
Controls other than text inputs
| Control | Template |
|---|---|
<textarea> |
<textarea <?= $c->model('bio', false) ?>><?= $c->value('bio') ?></textarea> |
<select> |
<select <?= $c->model('plan', false) ?>><option value="pro" <?= $c->selected('plan', 'pro') ?>>Pro</option></select> |
checkbox (bool) |
<input type="checkbox" <?= $c->model('terms') ?> <?= $c->checked('terms') ?>> |
checkbox group (array) |
<input type="checkbox" <?= $c->model('tags') ?> value="php" <?= $c->checked('tags', 'php') ?>> |
| radio | <input type="radio" <?= $c->model('size', false) ?> value="l" <?= $c->checked('size', 'l') ?>> |
An unchecked checkbox sends nothing, so a missing bool field means false and a missing array field means []. Checkbox groups get no id (it would repeat).
Conversion errors
A submitted value that can't convert to the property's type ("old" for an int) becomes a field error ("Age must be a whole number."). The control shows back what the user typed, the property keeps its last valid value, and no action runs in that request.
Validation
Rules come from #[Model(rules: …)] and from an optional rules() method, which wins for the same field:
protected function rules(): array
{
return [
'terms' => 'accepted',
'code' => ['required', fn($v) => $v === 'zeal' ? null : 'Wrong code.'],
];
}
protected function messages(): array
{
return ['terms.accepted' => 'Please accept the terms.', 'required' => ':field is needed.'];
}
| Rule | Passes when |
|---|---|
required |
not null, '' or [] |
accepted |
true, 1, '1', 'on', 'yes' |
email, url |
a valid email / an http(s) URL |
min:n, max:n, between:a,b |
string length, number value, or array count is in range |
in:a,b, not_in:a,b |
the value is / isn't one of these |
regex:/pattern/ |
matches (put it last, or use the list form if the pattern has |) |
numeric, integer |
a number / a whole number |
alpha, alpha_num, alpha_dash |
letters / plus digits / plus - and _ |
closure fn($value, $field) |
returns null (an error message string fails) |
Every rule except required and accepted skips empty values, so 'email' allows an empty field and 'required|email' doesn't. :field in a message becomes the field's label (first_name → "First name").
$c->error('field') prints <span class="zc-error" role="alert"> with the message, or nothing. $c->hasError() and $c->errors() are there for custom markup.
Concurrency in the browser
With HtmxBinder:
- An action always goes out. If a live update is still in flight on the same component, the action replaces it (the action carries every field anyway).
- The clicked button is disabled until its response arrives, so a double-click can't re-send the state the first click started from. Clicking a different action while one is in flight replaces the first one's response: the result is computed from the state the page showed.
- Live updates queue behind one another, and the field that triggered the update is preserved across the swap. If you type in field B while field A's update is in flight, B is re-rendered from the server's copy when A's response lands; its next keystroke sends it again.
Elements inside a $tag = 'form' component inherit its hx-target and hx-sync. If you add your own hx-get inside a component, set hx-target on it explicitly, or its response replaces the whole component.
Without JavaScript
With $tag = 'form', every component is a real form: action buttons are submit buttons with formaction, and the form's action is the $submit action. A plain form post runs the action, stores the resulting snapshot in the session, and answers 303 back to the page (the Referer, if it's on the same host). The next App::component() with the same key picks the stored state up once.
Use a stable key: for components that must work this way; numbered keys can shift between renders. The round trip needs an active session (register SessionStartMiddleware): without one the action still runs, but the page after the redirect shows the old state.
Pressing Enter submits a form with its first submit button. When $submit is set, the framework puts a hidden submit button for it first in the form, so an action button earlier in your template doesn't run instead.
With FormBinder and the default $tag = 'div', action buttons sit outside any form and do nothing without JavaScript. Set $tag = 'form' for components that must work without it — but don't put a form component inside another <form> (nested forms aren't valid HTML).
Security
| The browser tries to… | Result |
|---|---|
| Edit state in the snapshot | 400 — the HMAC doesn't match |
| Use another visitor's snapshot | Refused like a stale snapshot (the page reloads and shows the visitor their own state) — snapshots are bound to the session with an HMAC of the session id, never the id itself |
Use a snapshot from before a deploy that changed the class, or older than App::componentMaxAge() (default 24 h) |
htmx: HX-Refresh: true (page reloads); protocol: 409 |
| Re-send an older snapshot of their own | Accepted until it expires — see below |
Call a method without #[Action] |
404 |
Write a property without #[Model], or one the page didn't render |
ignored |
| Send an object or a value of the wrong type | 400 for action arguments; a field error for input |
| Post from another site | 403 — Sec-Fetch-Site must be same-origin, or Origin must match Host |
| Name a class to instantiate | impossible — only registered names are looked up |
Replay: signed isn't the same as latest
A snapshot proves that this session was shown that state; it doesn't prove it's the newest one. A visitor can re-send an earlier snapshot of their own (until it expires) and get the earlier state back. That's harmless for UI state — a counter, a form, a selected tab — but never use component state as the record of something that happened: votes cast, attempts used, coupons claimed, a wizard step that grants something. Keep those in the database or the session and read them in render() or the action. App::componentMaxAge(seconds) bounds how long a snapshot can be re-sent (default 86400; 0 = no limit).
Without a session
Snapshots are bound to the PHP session. When no session is active (no SessionStartMiddleware and no PHPSESSID cookie yet, or an app that authenticates with tokens instead), snapshots aren't bound to anyone. The endpoint then refuses requests that carry neither Sec-Fetch-Site nor Origin, so cross-site requests can't use them, but any snapshot works for any sessionless visitor. Start a session (SessionStartMiddleware) for anything user-specific. A session that starts or is regenerated after a page was rendered (login) makes that page reload on its next component request.
Things to do yourself:
- Authorize. The snapshot proves this session rendered this state; it doesn't prove the user may act on it. Check permissions in
authorize()or in the action, and scope queries by the current user. Middleware on the endpoint works too:App::when('/_zeal/c', ['auth']). - No secrets in state. It's readable in the page source.
- Escape in templates. Use
e()for values you print (see Escaping output); the helpers escape everything they print.
Rotating the key
Move the old key to ZEALPHP_APP_KEY_PREVIOUS (comma-separated for several) and set the new one. Snapshots signed with the old key keep working; new ones use the new key. Remove the old key once open pages have had time to expire.
The raw key never signs anything directly: each use derives its own key with HKDF, so a snapshot signature can't be reused for another purpose.
Clients
The endpoint speaks plain HTTP. A binder decides which attributes action() and model() print and how side effects are signalled:
| Binder | Prints | Use |
|---|---|---|
HtmxBinder (default) |
hx-post, hx-target, hx-include, hx-sync, hx-preserve, plus the plain-form attributes |
Pages with htmx 2 |
FormBinder |
Plain-form attributes only | Pages with no JavaScript |
| your own | anything — implement ZealPHP\Component\ComponentBinder |
Alpine, Datastar, a custom fetch() client |
App::componentBinder(new HtmxBinder(morph: true)); // idiomorph swaps; load the htmx idiomorph extension
HtmxBinder answers validation errors with 200 (htmx doesn't swap 4xx responses by default), maps events to HX-Trigger, redirects to HX-Redirect, and stale snapshots to HX-Refresh.
Protocol
For writing a binder or a client.
Request — POST /_zeal/c/{name}/{action}, form-encoded (application/x-www-form-urlencoded or multipart/form-data):
| Field | Value |
|---|---|
_zc |
the snapshot from the component's hidden input |
_zc_args |
action arguments as a JSON object ({"by": 2}); omit for none |
#[Model] fields |
as a form would send them (tags[] for arrays) — send every bound control in the component, not just the changed one: a missing bool means unchecked and a missing array means empty, so a partial post clears them |
{action} is an #[Action] method name, _update (apply input and validate changed fields), or _refresh (apply input, re-render). Scripted clients send Zeal-Request: 1 and, for _update, Zeal-Trigger: <field name>.
Response:
| Status | Meaning | Body |
|---|---|---|
| 200 | OK | the re-rendered component (outer element included) |
| 422 | validation errors | the re-rendered component showing them |
| 303 | plain form post (no Zeal-Request / HX-Request) |
— Location is the page |
| 400 | malformed or tampered snapshot, bad arguments | text |
| 403 | cross-origin, sessionless without an origin header, or authorize() refused |
text |
| 404 | unknown component or action | text |
| 409 | snapshot from another version, another session, or expired (or signed before a dev-mode restart) — reload the page | — |
Headers: Zeal-Events (JSON object of event → detail), Zeal-Redirect (a path to navigate to), Zeal-Reload: 1 (with 409). Every response is Cache-Control: no-store.
Running in coroutine-legacy
App::components() loads and reflects your component classes — and the framework's own component classes — in the master, before workers fork, so none of them is compiled cold under concurrency. Instances live for one request and are never stored in statics, so they need no isolation support.
Limits in this version
- No nesting.
App::component()inside a component's render throws: a parent's requests would carry the child's snapshot too. Render components side by side. - Dev hot-reload (
--dev) re-registers components fromroute/*.php, but the endpoint route and the key check are set up at boot: if a reload adds the first component, restart. Removed components stay registered until restart. - Behind a proxy that rewrites
Host, theOriginfallback and the no-JS back-link compare against the rewritten host. Modern browsers sendSec-Fetch-Site, which isn't affected; the back-link falls back to/.
What's next
Planned in the design doc: a bundled vanilla-JS client, pushing "refresh" signals over WebSocket when server data changes (Component::invalidate('topic')), nested components, and benchmarks against App::renderHtmx() fragments.