Components
ZealPHP builds pages from components, and one call renders both kinds:
<?= App::component('card', ['title' => 'Hello']) ?> // stateless: a template with props
<?= App::component(Counter::class, ['count' => 5]) ?> // reactive: state, actions, input binding
- A stateless component is a template rendered with
App::component(): props in, markup out — like a React function component without hooks. Names resolve likerender()'s, nearest first: the page's folder, the page'scomponents/,template/, thentemplate/components/(a leading/is absolute). - A reactive component is a PHP class. Public properties are its state,
#[Action]methods are what buttons call, and#[Model]properties bind to form inputs, with validation. The server owns the state and renders HTML; the browser swaps it in. There's no client-side framework and no JSON API to keep in sync, and pages still work with JavaScript turned off.
Start stateless; make a component reactive when it needs to respond to the user. Live demo: /components. For App::render(), streaming and fragments, see Templates and Rendering.
Stateless components
Put a template in template/components/ and render it by name:
<?php /* template/components/card.php */ use function ZealPHP\e; ?>
<article class="card">
<h3><?= e($title) ?></h3>
<?= $slot ?? '' ?>
</article>
Then call it from any page or template — here a page rendered by public/invoices.php (App::render('invoices-page')):
<?php /* template/invoices-page.php */ use ZealPHP\App; ?>
<main>
<h1>Billing</h1>
<?= App::component('card', [
'title' => 'Invoices',
'slot' => App::component('ui/button', ['text' => 'New invoice']),
]) ?>
</main>
A stateless component needs nothing else: no registration, no key, no script.
- Lookup: the same rule as
render(), nearest first: the page's folder, the page'scomponents/,template/, thentemplate/components/;card.phpbefore the partial_card.phpin each (so existing underscore partials work as they are).'ui/button'names a folder. - Any template: a leading
/renders any template, absolute fromtemplate/:App::component('/emails/welcome', ['user' => $u]). This is how you get markup from a template that isn't a component; it replaces the deprecatedApp::renderToString(). - Props become template variables;
$propsholds them all. Pass child markup asslot— anApp::component()result orEscape::raw()is printed as-is, anything else should go throughe(). - Returns markup, never echoes, so
<?= ?>is correct. A route canreturn App::component(...)too: the response is the HTML. A component returns content only — markup, a Generator or a Closure; a status code or JSON throws, because those belong to the route or page that owns the response (why). - Registered names win. If a reactive component is registered as
card,App::component('card')renders that. - Stateless components can be used anywhere, including inside reactive components. They have no state, so they need no key and no
ZEALPHP_APP_KEY.
Reactive components
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. The rest of this guide is about reactive components.
Setup
Reactive 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 reactive 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>
Putting it on a page, end to end
Four files, plus the counter's template above. This exact set runs as-is:
route/components.php registers the component (once, at boot)
src/Components/Counter.php the class above (Composer autoloads App\ from src/)
template/components/counter.php its view, above
public/counter.php the URL /counter
template/counter-page.php the page that renders it
1. Register it — route/components.php (route files load at boot):
<?php
use ZealPHP\App;
use App\Components\Counter;
App::components([Counter::class]);
2. Add a page — public/counter.php serves /counter and renders a page template:
<?php
use ZealPHP\App;
App::render('counter-page', ['title' => 'Counter']);
3. Render the component in the page — template/counter-page.php. The component is one line, <?= App::component(...) ?>, anywhere in the HTML; the page only has to load htmx:
<?php
use ZealPHP\App;
use App\Components\Counter;
use function ZealPHP\e;
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title><?= e($title) ?></title>
<script src="https://unpkg.com/htmx.org@2.0.10" defer></script>
</head>
<body>
<h1><?= e($title) ?></h1>
<?= App::component(Counter::class, ['count' => 5], key: 'main') ?>
</body>
</html>
Start the server with a key (ZEALPHP_APP_KEY=… php app.php) and open /counter: the buttons update the count in place. A route works the same way as the public file:
$app->route('/counter', fn() => App::render('counter-page', ['title' => 'Counter']));
In a site with a shared layout, put the htmx <script> in the layout's <head> once and call App::component() from any page template, partial or other component's template. Two optional extras also go in the layout once: <?= App::componentPushRoot() ?> before </body> for live updates, and ZealJsBinder::script() instead of htmx for pages without htmx.
The browser receives the component already rendered:
<form method="post" action="/_zeal/c/counter/_refresh" id="counter-main" data-zc="counter">
<input type="hidden" name="_zc" value="eyJjIjoiY291bnRlciIs…"> <!-- the signed state -->
<span>5</span>
<button type="submit" formaction="/_zeal/c/counter/add" hx-post="/_zeal/c/counter/add" …>+1</button>
<button type="submit" formaction="/_zeal/c/counter/add" value="{"by":10}" …>+10</button>
</form>
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 |
$slot |
markup the parent passed as the slot prop (SafeHtml), or null |
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 being typed in is kept across the swap (htmx 2:
hx-preserve; htmx 4: morphing). 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 |
ZealJsBinder |
Plain-form attributes plus data-zc-* markers, for the bundled zeal.js |
Pages without htmx |
| your own | anything — implement ZealPHP\Component\ComponentBinder |
Alpine, Datastar, another client |
Without htmx: ZealJsBinder
A small bundled script makes components interactive on pages that don't load htmx:
App::componentBinder(new ZealJsBinder()); // before App::run()
<?= \ZealPHP\Component\ZealJsBinder::script() ?> <!-- in the layout's <head>, once -->
It speaks the plain protocol below:
- Actions and live (debounced) or on-blur fields post to the endpoint with
Zeal-Request: 1. - The re-rendered component replaces the old one in place, keeping the value, focus and cursor of the field being typed in.
- An action replaces a live update still in flight on the same component, and the clicked button is disabled until its response arrives.
Zeal-Eventsare dispatched as bubbling DOMCustomEvents on the component (detail= the event's detail).Zeal-RedirectandZeal-Reloadnavigate.
The markup is the same working form FormBinder renders, so the page still works with JavaScript off. The script URL is versioned by its content and cached as immutable.
htmx 2 and htmx 4
HtmxBinder supports both. The setting decides the attributes of the first page render; after that, every component request says which htmx sent it (htmx 4 adds HX-Request-Type), and the response uses that version's dialect. htmx 2 is the default setting (it's still npm's latest); pass htmx: 4 if your pages load htmx 4:
App::componentBinder(new HtmxBinder()); // htmx 2.x
App::componentBinder(new HtmxBinder(htmx: 4)); // htmx 4.x
App::componentBinder(new HtmxBinder(morph: true)); // htmx 2 + the idiomorph extension
| htmx 2 | htmx 4 | |
|---|---|---|
| Swap | outerHTML (morph:outerHTML with morph: true) |
built-in outerMorph, always |
| Keeping the field being typed in | the response marks it hx-preserve |
morphing never overwrites the focused input's value |
| Disabling the clicked button | hx-disabled-elt="this" |
hx-disable="this" |
| Refused requests (400/403/404) | not swapped (htmx 2 default) | HX-Reswap: none stops htmx 4 swapping the error text |
Every attribute is set on the element that needs it, so htmx 4's explicit attribute inheritance doesn't affect components. The two dialects differ in attribute names (hx-disable means "ignore this element" in htmx 2), so pick the one matching the htmx on your pages. HtmxBinder answers validation errors with 200, maps events to HX-Trigger, redirects to HX-Redirect, and stale snapshots to HX-Refresh.
Client-only state: hx-live, Alpine
Some state never needs the server: a dropdown being open, a character counter, a tab. Keep that in the browser with htmx 4's hx-live (or Alpine.js) inside a component's template:
<textarea <?= $c->model('bio', false) ?>><?= $c->value('bio') ?></textarea>
<small :text="q('previous textarea').value.length + ' / 280'"></small>
Client-side state lives on DOM nodes, so it survives a re-render only where the swap keeps those nodes — always the case with htmx 4's morphing; with htmx 2's outerHTML it resets on each update. Anything the server must know belongs in a #[Model] property.
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 |
_zc_kids[{id}] |
the current snapshot of each component nested inside this one, so a re-render rebuilds them from their state (each is verified, and used only for its own id) |
Everything a component sends is in its own form: the outer element when $tag is 'form', otherwise an empty <form id="{id}-zc"> it renders first; every control and button joins it with form="{id}-zc". Post that form's FormData and nothing else — never the fields of a component nested inside it.
{action} is an #[Action] method name, _update (apply input and validate changed fields), _refresh (apply input, re-render), or _on (run the component's #[On] methods for the event named in a _zc_event field, then re-render; 404 if it doesn't listen for that event). For _update, name the field that changed in a _zc_trigger form field (the htmx binder does this); a Zeal-Trigger or HX-Trigger-Name header also works. Scripted clients send Zeal-Request: 1.
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.
Live updates (push)
A component can stay current while it's on screen: when data it shows changes, every open page showing it re-renders it.
final class SharedLikes extends Component
{
public static ?Counter $likes = null; // created in route/ before run()
public function topics(): array
{
return ['demo:likes']; // what this component depends on
}
#[Action]
public function like(): void
{
self::$likes?->increment();
Component::invalidate('demo:likes'); // after the write
}
public function render(): mixed
{
return $this->view('components/live/likes', ['likes' => self::$likes?->get() ?? 0]);
}
}
Add the push root once to your layout, after the components:
<?= App::componentPushRoot() ?>
</body>
It renders a <script> only when the page rendered a component with topics. Call Component::invalidate() from anywhere that changes the data — an action, an API handler, a task worker, a sidecar process, a timer.
How it works
- While rendering, each reactive component's
topics()are recorded. The push root signs the list into a token bound to the visitor's session, so a page can only subscribe to topics it was actually shown. - The page opens one connection: a WebSocket (
/_zeal/c/ws) or an SSE stream (/_zeal/c/stream). Component::invalidate('demo:likes')delivers to this worker's connections, sends the topics to the node's other workers (OpenSwoolesendMessage), and, with the Redis or TieredStorebackend, publishes them to other nodes. From a task worker or a sidecar process it reaches every HTTP worker.- A connection receives only the ids of its stale components, never HTML or data. The page then re-requests each one through the normal endpoint (
_refresh), with its own cookies, so each visitor sees what their session is allowed to see.
Invalidations to one connection are coalesced (50 ms by default), so a burst of writes costs one refresh, and what a worker invalidates within a tick goes to the other workers and nodes as one message. The page skips a component while an htmx request from it is running (an action's response already carries its new state) or while the visitor is typing in it, and runs at most one refresh per component at a time. After a reconnect it refreshes all its components, because invalidations may have been missed.
Configuration
App::componentPush(transport: 'sse', maxConnections: 2000, coalesce: 0.1); // before App::run()
| Setting | Default | |
|---|---|---|
transport |
'ws' (or ZEALPHP_COMPONENT_PUSH=sse) |
'ws': one WebSocket per page. 'sse': one streamed response per page. |
maxConnections |
1000 | open push connections per worker; beyond it a WebSocket is closed with 1013 (retry later) and an SSE stream asks the browser to come back in 15–45 s |
coalesce |
0.05 | seconds to gather invalidations into one message |
WebSocket or SSE. WebSockets are the default because browsers allow only six HTTP/1.1 connections per host: with SSE, each open tab holds one, so a visitor's seventh tab of your site would stall. Use SSE over HTTP/2 (one connection carries every stream) or where a proxy blocks WebSockets. SSE holds a coroutine per open page and needs coroutine mode; without coroutines the push root uses the WebSocket regardless.
Both reconnect on their own: SSE after a jittered 1–4 s, the WebSocket client with backoff (0.5 s to 60 s, jittered). A stream ends when the browser leaves (noticed at the next 15 s heartbeat) and after an hour. Navigating with hx-boost to a page without the components closes the connection.
Topics
Topic names are 1–128 characters of letters, digits, :, _, . and - (contacts, contact:42, org.7.billing). They're coarse on purpose: a topic says something here changed, and the re-render decides what the visitor sees. Use a topic per record (contact:42) when a list of many items would otherwise refresh for every edit.
- Topics are fixed when the page renders. A component whose topics depend on state that its actions change (
contacts:page:2after paging) keeps the topics it was first rendered with until the page reloads. Prefer topics that don't change for an instance. - Topics are visible. They're in the page (the token is signed, not encrypted) and in the access log (the token travels in the connection URL). Don't put secrets in them;
user:42is fine, an email address isn't. - An invalid topic throws in dev mode and is logged and dropped otherwise, so a bad topic built from data doesn't break the page.
Security
- The token is HMAC-signed with a key derived from
ZEALPHP_APP_KEYand bound to the session. A token from another session, or signed with a key no longer configured, is refused; the page then refreshes its components, which reload the page when their snapshots are stale too. - Tokens don't expire on their own: a push carries only ids, and every refresh re-checks the snapshot's age and
authorize(). - Pushes carry ids only, and the refresh is an ordinary component request:
authorize(), the session binding and the origin checks all apply. A refresh that's redirected (to a login page, say) is not swapped in. Component::invalidate()is server-side only; clients can't trigger one.- Capacity.
maxConnectionsis per worker, and a page's token can be reused for many connections. On a public site, cap connections per client in front of ZealPHP (your proxy's per-IP connection limit on/_zeal/c/wsand/_zeal/c/stream).
Generating a component
php app.php make:component LikeButton # src/Components/LikeButton.php + template/components/like-button.php
php app.php make:component user-card --stateless # template/components/user-card.php only
The class lands in your src/ namespace (from composer.json's PSR-4 map), with a counter to start from. The command never overwrites a file and prints the line that registers the component.
Testing components
ZealPHP\Component\Tester drives a component through the real endpoint, without a server or a browser, so snapshots, validation, authorize(), hooks, events and redirects behave as in production:
use ZealPHP\Component\Tester;
Tester::render(Counter::class, ['count' => 1])
->call('increment', ['by' => 2]) // click an action
->assertSet('count', 3)
->assertSee('3');
Tester::render(SignupForm::class)
->set('username', 'ab') // type into a #[Model] field
->assertStatus(422)
->assertHasErrors('username')
->set('username', 'alice')
->set('email', 'alice@example.com')
->call('save')
->assertHasNoErrors()
->assertDispatched('signed-up');
| Step | Does |
|---|---|
Tester::render($classOrName, $props, $key) |
Render (registering the class if needed) |
call($action, $args) |
Run an #[Action] |
set($prop, $value) |
Change a #[Model] field: validates it and runs updated{Prop}(); the other bound fields are sent with their current values, as a browser would |
refresh() |
Re-render with the current state |
Read with html(), status(), get($prop), errors(), events() and redirectedTo(). Assert with assertSee()/assertDontSee() (text is HTML-escaped first; pass false to match markup), assertSet(), assertHasErrors(...$fields), assertHasNoErrors(), assertDispatched(), assertRedirect(?$to) and assertStatus(). A refused request (403 from authorize(), 404 for an unknown action) sets the status and leaves the markup as it was. Assertions go through PHPUnit when it's installed and throw \AssertionError otherwise. Without ZEALPHP_APP_KEY the tester signs with a random key for the process.
Nesting components
A reactive component can render other reactive components. Give each child a key: — it's how the child keeps its state:
final class TodoList extends Component
{
public array $items = ['Milk', 'Bread'];
public string $filter = '';
public function render(): mixed
{
return $this->view('components/todo-list');
}
}
final class TodoRow extends Component
{
#[Reactive]
public string $label = ''; // follows the parent
public bool $done = false; // the row's own state
#[Model]
public string $note = '';
#[Action]
public function toggle(): void
{
$this->done = !$this->done;
}
public function render(): mixed
{
return $this->view('components/todo-row');
}
}
<?php /* template/components/todo-list.php */ use ZealPHP\App; ?>
<?php foreach ($items as $i => $item): ?>
<?= App::component(TodoRow::class, ['label' => $item], key: (string) $i) ?>
<?php endforeach; ?>
- Each component sends only its own fields. Every component posts its own form, so a row's button never sends the list's fields and the list's actions never send the rows'. This works the same with htmx 2, htmx 4,
ZealJsBinderand without JavaScript. - Children keep their state when the parent re-renders. Each child's current snapshot travels in its parent's form; when the parent re-renders, every child is rebuilt from its own state — a row stays
done, keeps what was typed in its note — instead of being mounted again from the props. A snapshot is only used for the child id it was issued to. #[Reactive]properties follow the parent. Props seed a child when it first mounts; after that, only properties marked#[Reactive]take the parent's new values. The rest belong to the child.- Keys are required inside a parent, and only need to be unique within it: a child's id is scoped by its parent's (
todo-list-main-todo-row-0). Use the item's stable id rather than its position if rows can be reordered, so state follows the item. - Slots. A parent can pass markup into a reactive child as the
slotprop —App::component(Modal::class, ['slot' => App::component('/partials/terms')], key: 'terms')— and the child prints it with<?= $slot ?>(or$this->slot()). The slot is kept in the child's snapshot, so the child's own updates re-render it; when the parent re-renders, the new slot replaces it. A slot can't contain reactive components (their state would be frozen inside it): render those in the parent. Stateless markup is fine. - Limits: nesting goes 8 levels deep. A component with
$tag = 'form'can't contain reactive children, and a nested component can't use$tag = 'form'(forms can't nest) — keep the defaultdiv.
Events between components
One component's action can make others on the page update — the cart badge after "add to cart":
final class ProductCard extends Component
{
public int $productId = 0;
#[Action]
public function add(): void
{
Cart::add($this->productId);
$this->dispatch('cart-updated');
}
// ...
}
final class CartBadge extends Component
{
public int $count = 0;
#[On('cart-updated')]
public function sync(): void
{
$this->count = Cart::count();
}
// ...
}
- Every component on the page with an
#[On('cart-updated')]method runs it and re-renders, through its own snapshot and session like any other request. Events carry no data to listeners; a listener reads what it needs. - A component doesn't hear its own events: it already re-rendered after its action.
#[On]takes one or more event names (letters, digits,:,_,.,-) and is repeatable; the method is public and takes no arguments.authorize()is called with the listener's method name.- htmx delivers events through the response's
HX-Triggerheader (hx-trigger="… from:body"on a hidden element per event);ZealJsBinderdoes the same fromZeal-Events. Without JavaScript events aren't delivered — the next page load shows current data. - For other tabs and visitors, pair it with push:
Component::invalidate('cart')after the write.
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
- Push needs the root on the page. Pages that don't include
App::componentPushRoot()render components normally but never receive updates. - 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
Composition is complete (design): nesting, #[Reactive] props, events and slots. Next: a benchmark of component actions against render(…, htmx: true) fragments.