Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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, bool
  • array — 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 from route/*.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, the Origin fallback and the no-JS back-link compare against the rewritten host. Modern browsers send Sec-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.