Building an app
An app is a PHP class. It declares a widget tree, holds durable state as typed properties, and handles events with ordinary methods. This page covers the whole of that contract. It's the longest page in the guide because it's the one you'll actually live in.
An app is an App subclass
A concrete app extends SystemX\Core\Runtime\App and provides:
slug()returns the app's stable id and registry key, likehello. Required.render()builds and returns the widget tree. Required.title()is the label the panel and launcher show. Defaults to the slug with a capital letter; override it to change it.icon()is a glyph name from the built-in icon set. Defaults towindow.system()marks the app as system furniture (settings, about) rather than a normal user app. Defaults tofalse.
title(), icon() and system() are declared metadata, not stored state. The shell reads them by slug. A normal app gets a panel button and a launcher tile for free; there's nothing to register beyond the app itself.
System apps and user apps
system() decides where an app surfaces. A user app (the default) appears in the launcher grid. A system app appears instead in the system menu, the dropdown under the user icon in the panel, next to Log out. Leave the default for anything a user would launch and use; return true only for settings-and-about style furniture.
The flag also controls install and uninstall. Users can uninstall a user app (and reinstall it) from the Manage apps entry in the system menu. A system app is never uninstallable. Uninstall is enforced server side: it closes the app's open windows, forgets their state, and blocks future launches, so a forged launch request gets a 403. Your app takes part in all of this just by declaring system() correctly. There's nothing else to write.
Durable state is public typed properties
This is the feature the framework is built around. A public typed property on your app class is persisted automatically, per user and per window:
public int $count = 0;
public string $message = '';
public bool $notify = false;A handler mutates $this->count, and the new value is saved. It survives reload, reconnect, and logout followed by login. The client echoes nothing back; the server is the single source of truth.
A property persists only if it is public, non-static, not readonly, typed as a scalar or array (int, float, string, bool, array), and declared on your subclass rather than the base class. Object and enum types are ignored, so a constructor-injected service can never leak into the saved state.
To opt a property out, mark it #[Transient]. Use it for derived or per-request state that must not persist:
use SystemX\Core\Runtime\Transient;
#[Transient]
public string $scratch = '';Handlers
A widget binds a handler with ->on($event, $handler). Every shorthand routes through it. Bindings never touch the wire; they live server side only.
There are two styles. A named method binds an event to a public method on your app:
Button::make('Click me')->id('clicker')->handles('increment'),public function increment(): void
{
$this->count++;
}An inline closure binds and opens the event's round trip in one call:
TextField::make('message')->value($this->message)->id('message-field')
->onSubmit(function (WidgetEvent $event): void {
$this->message = $event->asString();
}),A handler optionally takes a WidgetEvent. Dispatch is arity aware: a zero-argument handler is called bare, a one-argument handler receives the event.
Event values are untrusted
$event->value arrives over the wire from the browser, and a forged POST can put anything in it. Never raw-cast it. Coerce through the safe accessors instead: asString(), asInt(), asBool(), asFloat(), asArray(). They normalise blanks, missing keys and type mismatches to sane defaults rather than warnings or fatals.
Checkbox::make('Notify me')->checked($this->notify)->id('notify-toggle')
->onChange(function (WidgetEvent $event): void {
$this->notify = $event->asBool();
}),If a value is constrained by a legal set (a Select's options, a tab id), validate it against that set, for example in_array($event->asString(), $legal, true).
The shorthand convention
Every ->onX() shorthand takes an optional handler. Passing one binds it and opens the event on the widget's round-trip allowlist in a single act. Calling an input shorthand bare (->onSubmit() with no argument) only opens the event without binding, which is occasionally useful when a parent handles it. For any event without a shorthand, use ->on($event, $handler) directly.
The widget set
Every widget has a static make() and fluent setters. ->id('...') tags a node so events and updates can target it. The core set, beyond the containers and overlay widgets:
| Widget | Builder | What it's for |
|---|---|---|
Window | Window::make($title)->size($w, $h) | The window frame. The root of every app's tree. Default 400 by 300. |
Label | Label::make($text) | Static text. |
Button | Button::make($label)->onClick($fn) or ->handles('method') | A click action. |
TextField | TextField::make($name)->value($v) | Text input. ->onSubmit($fn) for Enter, ->onChange($fn) for per-edit. |
Checkbox | Checkbox::make($label)->checked($bool) | A toggle. ->onChange($fn) receives its boolean. |
ListWidget | ListWidget::make()->content([...]) | A keyed, reorderable collection. |
ListItem | ListItem::make($text)->key($stableId) | A list row. ->key() is required: it's the identity the morph matches rows by, distinct from ->id(). |
Stack | Stack::make()->content([...]) | A layout container. |
Raw | Raw::make()->html($html) | Escape hatch: your HTML rendered verbatim. Not sanitised, so never feed it unescaped end-user input. |
There are more: switches, selects, radio groups, sliders, progress bars, badges, separators, group boxes, tabs, toolbars, dialogs, menus and tooltips all follow the same shape. The best tour of the full set is the Controls app in the reference host, which uses every one.
One sizing rule worth knowing: ->size($w, $h) is a cap, not a floor. The window renders at that height, and content taller than it scrolls inside the window rather than growing it. The user can still resize it either way.
Registering an app
Register the slug against the class in a service provider:
$this->app->make(AppRegistry::class)->register('counter', CounterApp::class);The registry resolves a fresh app instance per event; apps are never shared singletons. That means an app can constructor-inject services, and the persistence rules keep them out of the saved state.
If the app ships as its own Composer package, the registration must go in the provider's boot() method rather than register(). The reasons are covered in Shipping an app as a package, and getting it wrong fails silently, so it's worth reading before you package anything.
The lifecycle
PHP is stateless between events. On each event the framework:
- Resolves a fresh instance of your app.
- Hydrates its typed properties from the saved state.
- Renders once to build the handler table from the tree's bindings.
- Dispatches the event; your handler mutates state.
- Renders again so the tree reflects the mutation.
- Saves the properties back, serialises the tree, and broadcasts it on the user's private channel.
The browser morphs its live DOM toward the new tree. It doesn't rebuild, which is why focus and caret position survive a re-render. The full contract is on the wire protocol page.
A throwing handler is contained
If your handler throws, the desktop doesn't 500. The framework catches it, rolls back the database transaction (so no partial state is saved), broadcasts nothing, and the user sees a generic error toast. Every other window keeps working.
Two consequences for how you write handlers:
- Don't use exceptions for control flow. A throw is a contained crash, not a signal you can catch downstream. Validate up front and mutate deliberately.
- The user sees a generic toast, never your exception message. If a handler needs to tell the user something, put it in durable state and render it.
A complete example
A counter. A typed property, two named handlers, two buttons:
<?php
namespace App\SystemX;
use SystemX\Core\Runtime\App;
use SystemX\Core\Widgets\Button;
use SystemX\Core\Widgets\Label;
use SystemX\Core\Widgets\Stack;
use SystemX\Core\Widgets\Window;
use SystemX\Core\Wire\Node;
class CounterApp extends App
{
// Durable: survives reload, keyed per user and per window.
public int $count = 0;
public function slug(): string
{
return 'counter';
}
public function title(): string
{
return 'Counter';
}
public function render(): Node
{
return Window::make('Counter')->size(320, 180)->content([
Label::make("Count: {$this->count}")->id('count'),
Stack::make()->content([
Button::make('+')->id('inc')->handles('increment'),
Button::make('Reset')->id('reset')->handles('reset'),
]),
]);
}
public function increment(): void
{
$this->count++;
}
public function reset(): void
{
$this->count = 0;
}
}Register it, launch it from the launcher, click the button, reload the page. The count is still there. That's the whole pitch in one app.
Slug naming
The framework's own apps use bare slugs (hello, notes). If your app ships as a package, use a dotted vendor.name slug like acme.dashboard. The registry throws on a duplicate slug, so two packages claiming the same name fail loudly at boot instead of silently overwriting each other. The dotted prefix is how you stay out of everyone's way.
What the framework owns, and current limits
Window management is framework-owned and automatic. Drag, resize, snap tiling, maximise, minimise, stacking and focus all work without your involvement, and window geometry persists per user, so a reload restores each window where the user left it. Geometry is not app state; you never declare or touch it.
A few honest limits in the current release:
- Window titles are the app's, not per window instance.
- Geometry is per user, shared across devices. Per-device layouts and live cross-tab sync aren't built yet; a second tab picks changes up on reload.
- Only scalar and array typed properties persist. Object and enum types don't.
- The server broadcasts the full tree every frame and the client morphs. There is no server-side diff, and at desktop scale it hasn't been needed.