Components
Build pages from components: plain PHP templates that take props, and — when you need interactivity — PHP classes that keep state, run actions and bind form inputs. The server renders HTML; the browser swaps it in. No client-side framework, no JSON API to keep in sync, and pages still work with JavaScript turned off.
One call renders both kinds: App::component('card', $props). A name registered with App::components() is a reactive component; any other name renders a stateless template from template/components/. The full guide is docs/components.md; App::render(), streaming and fragments are in the Templates & Rendering reference.
Stateless components
A template in template/components/ that takes props. Like a React function component without hooks: same props in, same HTML out. Pass child markup as the slot prop.
<?= App::component('card', [
'icon' => '⚛',
'title' => 'Reactive components',
'body' => 'State, actions and input binding — below.',
'href' => '#reactive',
]) ?>
<a href="<?= htmlspecialchars($href, ENT_QUOTES) ?>" class="card">
<div class="card-icon"><?= $icon ?></div>
<h3><?= htmlspecialchars($title) ?></h3>
<p><?= htmlspecialchars($body) ?></p>
</a>
The name resolves to components/card.php, then components/_card.php; use 'ui/button' for folders. Stateless components can be used anywhere, including inside reactive ones.
Reactive components
A PHP class. Its public properties are its state, #[Action] methods are what buttons 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.
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
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>
From class to page
The class and its view above are two of the files. Register the component once at boot, then render it with one line in any page template. The page only has to load htmx.
<?php
use ZealPHP\App;
use App\Components\Counter;
App::components([Counter::class]);
<?php
use ZealPHP\App;
App::render('counter-page', ['title' => 'Counter']);
<?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 ZEALPHP_APP_KEY set and open /counter. A route works the same: $app->route('/counter', fn() => App::render('counter-page', ['title' => 'Counter'])). With a shared layout, the htmx script goes in the layout once and any page template, partial or component template can call App::component(). The full walkthrough also shows the HTML the browser receives.
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
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>
Live across tabs (push)
One like count shared by every visitor. The component lists the topic it depends on; the action invalidates it. Every open page showing the component re-renders it, over one SSE connection per page. Open this page in a second tab and click Like there.
Live
final class SharedLikes extends Component
{
public static ?Counter $likes = null; // shared by all workers
public function topics(): array
{
return ['demo:likes'];
}
#[Action]
public function like(): void
{
self::$likes?->increment();
Component::invalidate('demo:likes'); // every page showing it re-renders
}
public function render(): mixed
{
return $this->view('components/live/likes', ['likes' => self::$likes?->get() ?? 0]);
}
}
<?= App::componentPushRoot() ?>
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