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

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.

src/Site/Components/LikeCounter.php
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');
    }
}
template/components/live/counter.php
<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.

src/Site/Components/SignupForm.php
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]);
    }
}
template/components/live/signup.php (excerpt)
<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

  1. The page renders the component with App::component(). The framework wraps the template output in the component's element and adds a hidden _zc field: the state, signed with ZEALPHP_APP_KEY and tied to the visitor's session.
  2. A button or live input posts the form fields to /_zeal/c/{component}/{action}.
  3. The endpoint checks the signature, session and version, builds a fresh component from the snapshot, applies #[Model] input, and runs the action.
  4. 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.

app.php
use ZealPHP\Component\{HtmxBinder, FormBinder};

App::components([LikeCounter::class, SignupForm::class]);
App::componentBinder(new HtmxBinder());   // the default; FormBinder for no-JS pages