Live Components
Interactive UI from PHP classes and plain PHP templates. The server owns the state and renders HTML; the browser swaps it in. No client-side framework, no JSON API to keep in sync, and the page still works with JavaScript turned off.
A component is a PHP class. Its public properties are its state, #[Action] methods are what buttons can call, and #[Model] properties bind to form inputs. Every request builds a fresh instance from a signed snapshot in the page, so workers keep nothing between requests. The full guide is docs/live-components.md.
The live demos are off on this server because ZEALPHP_APP_KEY isn't set. Run php app.php key:generate, export the key, and restart — or start with php app.php --dev.
A counter
Each button posts to the component endpoint, which restores the counter from its snapshot, runs add(), and returns the re-rendered component.
Live
Demo disabled.
final class LikeCounter extends Component
{
protected string $tag = 'form'; // works without JS too
public int $count = 0;
#[Action]
public function add(int $by = 1): void
{
$this->count = max(0, $this->count + $by);
}
public function render(): mixed
{
return $this->view('components/live/counter');
}
}
<span><?= e($count) ?></span>
<button <?= $c->action('add', ['by' => -1]) ?>>−1</button>
<button <?= $c->action('add') ?>>+1</button>
Input binding and live validation
username is live: it's validated as you type (try ab, admin, or a b). The field you're typing in is never replaced, so focus and cursor stay put. email, plan and the checkbox are deferred — they ride along when you press Sign up, which runs save().
Live
Demo disabled.
final class SignupForm extends Component
{
protected string $tag = 'form';
protected ?string $submit = 'save'; // what submitting the form runs
#[Model(live: true, rules: 'required|min:3|max:20|alpha_dash')]
public string $username = '';
#[Model(rules: 'required|email')]
public string $email = '';
#[Model(rules: 'in:free,pro')]
public string $plan = 'free';
#[Model]
public bool $terms = false;
public bool $saved = false; // no #[Model]: the browser can't set it
protected function rules(): array { return ['terms' => 'accepted']; }
protected function updatedUsername(string $value): void
{
if ($value === 'admin') {
$this->addError('username', 'That username is taken.');
}
}
#[Action]
public function save(): void
{
$this->validate(); // errors re-render next to the fields
$this->saved = true;
$this->dispatch('signup-saved', ['username' => $this->username]);
}
}
<input <?= $c->model('username') ?>>
<?= $c->error('username') ?>
<select <?= $c->model('plan', false) ?>>
<option value="free" <?= $c->selected('plan', 'free') ?>>Free</option>
<option value="pro" <?= $c->selected('plan', 'pro') ?>>Pro</option>
</select>
<input type="checkbox" <?= $c->model('terms') ?> <?= $c->checked('terms') ?>>
<button type="submit">Sign up</button>
How a request works
- The page renders the component with
App::component(). The framework wraps the template output in the component's element and adds a hidden_zcfield: the state, signed withZEALPHP_APP_KEYand tied to the visitor's session. - A button or live input posts the form fields to
/_zeal/c/{component}/{action}. - The endpoint checks the signature, session and version, builds a fresh component from the snapshot, applies
#[Model]input, and runs the action. - It re-renders the component and sends back its HTML, with a new snapshot. htmx swaps it in. Without JavaScript the browser gets a redirect back to the page, which shows the new state.
What the browser can and can't do
- Change state directly: no. The snapshot is HMAC-signed; any edit is rejected with 400.
- Reuse another visitor's snapshot: no. Snapshots are bound to the session; another session's page just reloads with its own state.
- Re-send an older snapshot of their own: yes, until it expires (24 hours by default). Keep votes, attempts and claims in the database, not in component state.
- Call any method: no. Only
#[Action]methods, with typed scalar arguments.authorize()can refuse per action. - Write any property: no. Only
#[Model]properties the last render showed a control for. Values are converted to the declared type; objects are never built from input. - Read state: yes. Snapshots are signed, not encrypted — keep ids in state, not secrets.
- Post from another site: no. Cross-origin requests are refused using
Sec-Fetch-Site/Origin.
htmx is first-class, not required
The endpoint speaks plain HTTP: form fields in, HTML out. A binder decides which attributes the template helpers print. HtmxBinder (the default) emits hx-* attributes on top of plain-form markup; FormBinder emits only the plain forms; you can write your own for another client.
use ZealPHP\Component\{HtmxBinder, FormBinder};
App::components([LikeCounter::class, SignupForm::class]);
App::componentBinder(new HtmxBinder()); // the default; FormBinder for no-JS pages