Search by

io / live

Lento Leisenfeld

Live components for Io under Tenma: server-side PHP components, events and renders over one WebSocket, services called directly.

v0.1.6 2026-10-25 20:46 UTC

This package is auto-updated.

Last update: 2026-10-25 20:48:17 UTC


README

Live components for Io under Tenma: the components run on the server, in PHP. A component is a PHP class with state and actions. It renders into the page like any view, and after that its events and new renders travel over one WebSocket per browser tab. The component calls services directly; there is no API route and no fetch() in between.

Status: proof of concept. Requires PHP 8.5, Io 0.7 or later and Tenma.

Documentation: docs/: a first component end to end, guides for every part of Live, and the reference of every public class.

The examples come from Sonnenkorn, a made-up bakery whose cart, checkout and order board are live components. Its add button, shortened from src/Components/AddToCart.php:

#[Component]
final class AddToCart
{
    #[Input]
    public int $product = 0;

    #[Inject]
    private Cart $cart;

    #[Inject]
    private Products $products;

    private ?string $notice = null;

    public function add(): void
    {
        $product = $this->products->byId($this->product);
        if ($product === null) {
            return;
        }
        $this->notice = $this->cart->add($product) ? null : 'That is all we have left.';
    }
}
<!-- src/Components/AddToCart.Template.php: $this is the component, {{ }} escapes -->
<button data-on-click="add">Add to cart</button>
@if ($this->cart->quantity($this->product) > 0)
    <span>{{ $this->cart->quantity($this->product) }} in your cart</span>
@endif
@if ($this->notice !== null)
    <span role="status">{{ $this->notice }}</span>
@endif
$app->plugin(new LivePlugin([AddToCart::class, CartBadge::class]));   // only listed components can be created
<app-add-to-cart product="{{ $product->id }}"></app-add-to-cart>   <!-- in a view -->
{{ $this->ioJsTag() }}{{ $this->pluginAssetTags() }}       <!-- in the layout -->

The selector is app- and the class name in kebab case (CartBadge is <app-cart-badge>), the template AddToCart.Template.php next to the class. Both can be given, the template path relative to the class's file:

#[Component(
    selector: 'shop-cart',
    template: 'templates/Cart.php'
)]

How it works

  1. The page's tags (or $this->live('app-add-to-cart', ['product' => 3])) create the component, set its #[Input] properties and render the template into the page, wrapped in its selector: <app-add-to-cart data-live data-inputs="{...}">. Without JavaScript the page stays as rendered.
  2. live.js opens a WebSocket to /_live, a normal Io route: the app's middlewares and guards run, and the connection is refused unless its Origin is this site.
  3. The server creates every component again with the same inputs (a "circuit" per tab) and keeps it in memory until the tab closes; after a dropped connection a while longer (see Reconnect).
  4. Children placed by templates get IDs from the server (c0.1), so events reach the right instance.
  5. data-on-click="method" (with an optional data-arg), data-on-input (the field's value, debounced), data-on-change (value, checked for a checkbox, the file for a file input) and data-on-submit (the form as an object, files included) call the component's method. data-on-click="remove($line->product)" passes arguments of any type, objects included: they stay on the server, the browser only gets an ID for the call. What the event brings comes after them, so the stock page's data-on-change="set($product)" calls set($product, $value). Use ' for strings inside the attribute. A call keeps its ID across renders; an enum case or an object of a readonly class keeps it even when the render builds it again. After each call the template renders again, and the client patches the new HTML in place. The focused field keeps what is being typed.
  6. #[Every(1.0)] on a method runs it on a timer and pushes a render. Timers stop when the tab closes.
  7. Broadcast::changed() renders every open tab, on every worker: the bakery's order board shows a new order the moment the checkout places it.

Templates

A template is a PHP file rendered with $this as the component, private properties included. As in a Io view, <?= $expr ?> is HTML-escaped; <?php echo $html ?> stays raw for HTML you built yourself. <?php foreach (...): ?> and <?php if (...): ?> work as in any view. Templates are compiled once when the plugin starts.

$this is the component at runtime without any declaration, and in the editor too through tenma lsp (see In the editor). Without it, a first line <?php /** @var \Bakery\Components\AddToCart $this */ ?> tells the IDE.

<?= $value ?> escapes every value except a Io\Html, which carries markup that was built safely (another template's output, say) and is printed as it is.

Directives

Templates also take directives, mixed with plain PHP as you like. The syntax is Io's, the same as in its views (the syntax); a template adds child tags and data-on-*, and leaves @model, @section and @yield to views:

After the bakery's src/Components/OrderBoard.Template.php, shortened:

{{/* one column per status, one card per order */}}
@foreach ($this->columns() as [$status, $orders])
    <section data-key="{{ $status->value }}">
        <h2>
            {{ $status->label($this->i18n) }}
        </h2>
        @foreach ($orders as $order)
            <article data-key="{{ $order->code }}">
                <p class="font-medium">{{ $order->customerName }}</p>
                @if ($order->status->next() !== null)
                    <button data-on-click="advance($order)">→ {{ $order->status->next()->label($this->i18n) }}</button>
                @else
                    <em>waits for the customer</em>
                @endif
            </article>
        @endforeach
    </section>
@endforeach
  • {{ expr }} prints escaped, as <?= ?>; a lone {{ expr }} in a child tag's attribute passes the value itself. A value of type Io\Html prints as it is: whether markup is raw depends on the value, never on how the template prints it. {{/* */}} is a comment.
  • @if (...), @elseif (...), @else, @endif, @foreach (...), @endforeach. @@ prints an @.
  • A directive starts a line or follows whitespace, > or }}, so hello@sonnenkorn.test, @media in CSS and @click stay text. Inside <?php ?> nothing is a directive.
  • @code ... @endcode holds PHP, as <?php ... ?>: @code $order = $this->order() @endcode. Neither may declare anything (use, namespace, declare, a named function or class) or hold a break outside a loop, a yield outside a function or an assignment to $this: PHP would refuse those with a fatal error at the first render, so the template does not compile.
  • A template resolves class names as its component's class file does: same namespace, same use statements. @use (Bakery\Models\OrderStatus) or @use (Bakery\Services\Format as F) adds one for the template.
  • <li class="step" @class.current="$step === $order->status"> adds current to the class while the condition holds; the name runs to the first = outside [ ], so Tailwind classes like md:w-1/2 work. The attribute's own classes go through the same classes(), so each class comes once. classes('step', ['current' => $step === $order->status]) builds the same list in PHP, in every template without an import (Io\classes()).
  • A block that does not close, or closes another one, keeps the template from compiling, with its line; so does a /* without its */ inside {{ }}, which would comment out the template up to the next */. In the editor these show while typing; @forech gets a hint and stays text.

Nested components

A template places other components by their tag:

<!-- PickupDesk.Template.php: the shop counter, one row per order that is ready -->
@foreach ($this->ready() as $order)
    <app-pickup-row key="{{ $order->code }}" order="{{ $order }}" onHandedOver="{{ $this->handedOver(...) }}" compact></app-pickup-row>
@endforeach
  • Attributes set the child's #[Input] properties. A value that is a single {{ $expr }} passes the value itself (a bool, an object, a closure); anything else passes a string.
  • A child keeps its state as long as the parent's template keeps placing it: by key, or without one by its position among the tags of that selector. A child that is no longer placed is removed, its timers stop.
  • On a parent render only inputs whose value changed are set again: the child keeps what it changed itself.
  • Events go up through a closure input: the child calls ($this->onHandedOver)($this->order), the parent's method runs. That method doesn't need to be in the parent's template, so the browser can't call it directly.
  • Every change renders the top-level components again, children included.

Services per tab

Services come in through #[Inject] as in a controller: one instance for the whole app. #[Scoped] on a service class makes it one instance per circuit, that is per browser tab:

/** The cart of one browser tab, shared by the badge in the header, the buttons on the cards, the cart page. */
#[Scoped]
final class Cart
{
    #[Inject]
    private Connection $connection;

    #[Inject]
    private Carts $carts;

    // lines(), quantity(), add(), set(), count(), clear(): kept in Postgres under the browser's cart cookie
}

Every component of the tab that injects Cart gets the same instance, children and other top-level components included; another tab gets its own. It lives as long as the circuit, reconnects included. A scoped service needs a constructor without required arguments; its own #[Inject] properties are set, scoped ones from the same tab. The other way round is refused: an app-wide service that injects a #[Scoped] one, directly or through another service, would share it between all tabs and users, so the component that injects it fails to be created, with the chain in the error. Make that service #[Scoped] too, or inject the scoped one into the component. When an action changes it, every component of the tab that shows it renders again: after a change all top-level components render, and those whose HTML changed are sent.

Changes for every tab

A tab renders after its own actions and timers. State that tabs share, such as the bakery's orders, reaches the other tabs with Broadcast: changed() renders every connected tab, and each sends the components whose HTML changed. From src/Components/OrderBoard.php:

#[Inject]
private Broadcast $broadcast;

/** Bound to the order the board showed: the click moves it from the status it had then, or does nothing. */
public function advance(Order $order): void
{
    if ($this->orders->advance($order->id, $order->status)) {
        $this->broadcast->changed();
    }
}

Outside a component, $app->get(Broadcast::class). A tab whose connection dropped renders when it comes back.

With several workers, changed() goes out through Messaging\Channel::publish(): every worker renders its tabs, with tenma --pubsub redis://host every process on that Redis too. Each worker has its own memory, though: that is enough for state in Postgres or Redis, as the bakery's orders under tenma --workers 2. State in memory goes to every worker with its own message, and each worker renders its tabs once it has it, with changedHere():

// At startup: runs in every worker
new Messaging\Channel('oven')->subscribe(function (string $news) use ($specials, $broadcast) {
    $specials->news[] = $news;
    $broadcast->changedHere();
});

// In the action, instead of changed()
new Messaging\Channel('oven')->publish($news);

Under a Tenma without Messaging\Channel::publish(), changed() only renders the tabs of the own worker.

Components of a plugin

A plugin that brings live components of its own requires LivePlugin and adds them to the Components service, which its boot() takes as a parameter:

public function requires(): array
{
    return [LivePlugin::class];
}

public function boot(Components $components): void
{
    $components->add(StampCard::class, StampCounter::class);
}

Its components name their selector with a prefix of the plugin (selector: 'stamps-card'), so that they never meet an app's app- ones.

The connection

A component or a #[Scoped] service can inject Io\Live\Connection: the request the WebSocket came in with, its cookies, and $request->jwt as the app's middlewares left it. After a reconnect it is the new connection's request. navigate('/path') sends the browser to another page of the site once the action is done, for example after an order was placed. Only a path of the site is a destination, never another site. It counts even if the action throws afterwards, so a check can send a logged-out user to the login and stop the action; during the page's first render it is an error.

$request->jwt is what the middlewares found when the WebSocket connected, not refreshed until a reconnect. Once its exp has passed, the next mount or call closes the connection and the browser reconnects; a component that must notice a logout or a revoked token right away asks its source again on every action. Every render of a tab, from a message, a timer or a broadcast, runs with the FiberLocal values of its connection's request: the locale and the request id are the tab's own.

The bakery's checkout ends on the order's page, from src/Components/Checkout.php:

#[Inject]
private Connection $connection;

public function place(CheckoutForm $form): void
{
    // validate, reserve the stock, empty the cart
    $this->broadcast->changed();
    $this->connection->navigate("/orders/{$order->code}/thanks");
}

Live actions need no CSRF token: the WebSocket only connects from the site's own pages (its Origin is checked), so another site never gets to send an action.

DOM patch

The client patches each render into the page instead of replacing it. Siblings are matched by key and moved, so a reordered list keeps its elements (and the focus and caret inside them); everything else is matched by order. A child component's wrapper is keyed by its ID automatically; any other element gets a key with data-key:

@foreach ($this->cart->lines() as $line)
    <li data-key="{{ $line->product->id }}">{{ $line->product->name('en') }}: {{ $line->quantity }}</li>
@endforeach

Unkeyed siblings are matched among those of the same tag, so a message showing up in front of an input leaves the input alone. A form field keeps what the user typed or picked until the server renders a different value for it; while it has the focus, only if the server already had what is in it, so a render that lags behind the typing loses nothing. Typing is sent after 150 ms, or right before any other event.

Uploads

A file input or a form sends its files along; the method gets a Io\Http\UploadedFile, the same class a controller gets from $req->file():

<input type="file" accept="image/*" data-on-change="pick">

<form data-on-submit="save">
    <input name="product">
    <input type="file" name="photos[]" multiple>
    <button>Upload</button>
</form>
public function pick(?UploadedFile $file): void              // null when the selection was cleared
{
    $this->preview = $file?->name;
}

/** @param array{product: string, photos?: list<UploadedFile>} $form */
public function save(array $form): void
{
    foreach ($form['photos'] ?? [] as $photo) {
        $photo->moveTo($this->pictures->pathFor($photo));  // under Tenma without blocking the worker
    }
}

Name and type come from the browser, as with any upload: never use the name as a path, and check the content before trusting the type. An empty file input is left out of the form; multiple gives a list. The file's content lives in memory: move it, don't keep the UploadedFile in the component, or it stays for the life of the circuit. Events keep their order: a click right after picking a file waits until the file is read and sent. A file the browser can't read marks the component with data-live-error.

An element with data-live-ignore belongs to the browser once it is on the page: a render doesn't touch it or what is inside it. For an editor, a map or another widget that keeps its own DOM; put a data-key on it so it moves along with its row.

Reconnect

When the connection drops, the server keeps the tab's circuit for 3 minutes (at most 100 per worker and 10 per client, the busiest client's oldest goes first; one without components is not kept), with its timers paused. The browser reconnects after 1, 2, 4 ... at most 30 seconds and sends the circuit's token; the server hands back the same components with their state, renders them once to catch up and starts the timers again. Events from the time offline are queued and sent afterwards. If the circuit is gone, the components start again from the page's inputs and the queued events are dropped. A reload is a new circuit.

The token lives only in the page's memory and is a bearer secret for the circuit: whoever has it gets the components while they are parked, but only as the same user. A circuit resumes only if the new connection's $request->jwt names the same sub as the one it was opened with, or without a JWT sub brings the same cookies; after a logout or for anyone else the components start over. It is never in a URL, a cookie or storage.

Security

  • Only components listed in LivePlugin can be created, by selector; the browser never names a class.
  • The guards of a page's route do not protect its components: any browser that reaches /_live can mount any listed component with any inputs. #[Authenticated], #[Role] and #[Permissions] on a component class are checked when it is created and before every action, on a method before every call of it, against the connection's request as for a route. A JWT whose exp has passed counts as signed out: the call is refused and the connection closed, so the browser reconnects through the middlewares. A rule the attributes cannot say (whose record it is) the component checks itself, from its Connection.
  • The browser can only call public methods that the template names in a data-on-* attribute. Any other public method stays out of reach, and so does one named only in commented-out markup (<!-- -->, {{/* */}}). A method is reachable only while one of the last two renders showed it, so a button inside an @if that was false keeps its method out of reach. A method the template calls with arguments (remove($item)) is reachable only through the IDs of the last two renders, with the arguments the template bound and at most the one value the event brings; a click on an item that is gone since does nothing.
  • Only #[Input] properties can be set from outside, and their values come back from the browser when it connects: treat them like query parameters, never as proof of identity. The user comes from the upgrade request (JWT, session), which the route's guards check.
  • A call takes at most one value from the browser, the one its event brings (the field's value, the form, data-arg). It fills the first parameter the template does not bind; any further one keeps its default. A forged message can send it for a click without data-arg too, so that parameter is input like any other.
  • Values from the browser are converted to the method's parameter types by the rules of Io\FromArray: "7" fills an int, "false" a bool as false, "" a nullable parameter as null, "l" a backed enum as its case, the form's array a FromArray DTO (with its errors). Nothing is cast: a value that does not fit ("7.5" for an int) is refused without calling the method, logged in dev. Check the values in the method like any other request input.
  • Errors go to the log; the browser only gets {"error": true}, and the circuit keeps running. A component whose render fails gets data-live-error and holds back no other component of the tab.

In the editor

The package brings a provider for tenma lsp, Tenma's language server that runs next to the editor's PHP extension (in VS Code and VSCodium through the Tenma extension, which starts it in trusted workspaces). It finds the components below the project's autoload and autoload-dev directories, without running the app, and gives a template:

  • $this-> completed with the component's properties and methods, private ones included; hover and F12 into the class
  • data-on-click=" completed with the methods the browser may call; F12 to the method
  • <app- completed with the selectors, the attributes of a child tag with its #[Input] properties; F12 from a tag to its class
  • the variable of a loop over $this->items completed with the members of the element type that the property's @var list<Item> (or Item[], array<int, Item>) names
  • directives and the PHP inside them coloured, which the editor's PHP grammar sees as text
  • while typing: an action the component lacks or cannot be called (Did you mean add()?), an attribute that is no input of the child, a child tag the compiler would leave as it is, a // comment inside <?= ?>, PHP that does not parse, directives that do not close, each at its line

A changed component is seen once it is saved.

Testing

Io\Live\Testing\Tab is a browser tab without a browser, for the app's PHPUnit tests: the components get the app's services, and every event goes through the template the way the browser sends it, so a test also proves that the template wires the method up.

use Io\Live\Testing\Tab;

$tab = Tab::for($app, $request);                    // after $app->attach(); $request has the cookies, the JWT
$button = $tab->mount('app-add-to-cart', ['product' => 3]);

$button->click('add')->assertSee('1 in your cart');   // the element whose data-on-click names the method
$editor = $tab->mount('app-cart-editor');
$editor->click('li[data-key="3"] button');           // or a CSS selector: here less($line->product); a data-arg goes along
// and on the components that have such fields:
$search->input('input[type=search]', 'pretz');       // data-on-input, without the wait
$stock->change('input[aria-label="Lye pretzel"]', '55');
$checkout->submit('form', ['name' => 'Mia', 'email' => 'mia@example.test', 'pickupDate' => $day]);
$photos->upload('input[type=file]', 'pretzel.png', $bytes, 'image/png');
$tab->advance(1.0);                                 // #[Every] timers run on fake time

$button->html();                                    // what the browser has
$button->component();                               // the object, for its state
$desk->find('app-pickup-row', key: 'K7Q2');         // a child, with the same methods
$tab->assertNavigatedTo("/orders/$code/thanks");
Tab::page($app, 'app-add-to-cart', ['product' => 3]);   // the HTML before any connection

The bakery's tests in tests/Components drive every one of its components this way.

$tab->with($fake) puts a service into the tab before the first mount(), in place of the #[Scoped] one it is or extends (new class extends Wishlist { ... }); anything else is refused, the app's services come from the app. An error that the browser would only see as data-live-error fails the test with its own stack. Two tabs of one app share its services: a Broadcast in one renders the other.

Static analysis

vendor/bin/io-check, Io's check, covers the project's components too (Live takes part through TemplateChecks), and vendor/bin/live-check is another name for it. It runs without the app: what the app checks in development (above), and PHPStan on every template, read as the PHP it becomes with $this as its component, and on every view. An undefined property, a method with the wrong arguments or a typo in the member of a loop variable is reported at the template's line:

$ vendor/bin/io-check
src/Components/OrderBoard.Template.php:15: error: Access to an undefined property Bakery\Models\Order::$custmerName.
io-check: 1 errors, 0 warnings

It needs phpstan/phpstan as a dev dependency and uses the project's phpstan.neon (level, extensions), or level 6 without one. The generated files go to the system's temp directory, never into the project; the exit code fails a CI step. In a tenma.dev.php hook, PHPStan's findings reach the editor as you save:

new Development\Hook(
    name: 'io-check',
    inputs: ['src/**', 'views/**'],
    run: fn () => Io\View\Check::publish(__DIR__),
),

The package also brings a PHPStan extension (included by phpstan/extension-installer): a private property or method of a #[Component] class that only its template uses no longer counts as unused.

Development

With APP_ENV=development (Io's Environment::isDev()) and tenma --watch:

  • A saved template renders at once in every open tab, and the components keep their state. A template that no longer compiles keeps the last one that did.

  • A saved PHP file restarts the workers (--watch). When it was a component's class, the open pages keep their components and these get their state back: the properties with plain values (numbers, strings, arrays of them), children by their selector and key. A property the new code no longer has, or whose type no longer takes the value, starts over and is logged. Any other file loads the open pages again. Needs a Tenma with Development\Watch::reloadedBy(); without it, the pages always load again.

  • Every component and its template are checked once the app has started and after every saved template: actions the component lacks, attributes that are no input of the child, tags that stay as they are, PHP that does not parse, directives that do not close, timers that cannot run, inputs the page cannot set. The problems go to the terminal and, through Development\Problems::report(), to the editor, also for templates that are not open. A page that uses a tag close to a selector, or an attribute close to an input, gets a warning in the log.

  • An error shows in the page: what failed, in which component and after which event, the template's lines around it and the stack, with links that open the file in the editor. The overlay closes when the component renders again. A template that no longer compiles shows there too, until it is saved without the mistake. The links open VS Code; for another editor:

    $app->configure(fn (LiveOptions $live) => $live->editor = 'vscodium://file/{file}:{line}');
  • The same errors show in the editor at their line, under a Tenma with Development\Problems::report() and tenma lsp, until the component renders again or the template compiles.

  • localStorage.liveDebug = '1' (or ?live-debug in the URL) logs every action in the browser's console: what caused it, its time on the server and the round trip, and per component the render time, the size of what was sent or that nothing changed, and the patch time. live.inspect('c0') prints what a component holds on the server: inputs, state, children, timers and the tab's services (by class, not their content).

  • The console and the log warn when an action takes more than 50 ms, a message is larger than 100 KB, a tab has more than 30 components, or timers send more than 10 renders a second.

In production none of this happens: the browser only learns that a component failed (data-live-error).

Limits of the proof of concept

  • A circuit lives in the memory of one worker. With several workers a reconnect can land on another one and starts over; so does a restart or a deploy.
  • Every change renders every top-level component of the tab; fine for small pages, not for big ones.
  • Tags need attributes in double quotes and an empty body (no content projection). In an Io view the rendered HTML page is searched for them. An attribute that is no #[Input] (class, id) stays on the tag; an input arrives as a string (product="3" still fills an int), so a bool or an array needs $this->live(). A page's inputs travel through the browser and back, so they are JSON values only: an object is refused, pass its ID and load it in the component. A child placed by a template gets any value, objects and closures included. A tag inside <script>, <textarea> or an HTML comment is expanded too.
  • At most 50 top-level components per tab.
  • The server keeps a circuit only once it notices that the old connection is dead. A browser that notices first (a network switch) may reconnect before that and start over.
  • A // comment inside <?= ?> breaks the template; use /* */.
  • data-on-submit sends the form as an object: name[] collects a list, any other repeated name (checkbox group, select multiple) keeps its last value. The button that submitted it is in with its name and value.
  • A file travels base64-encoded inside one WebSocket message: a third bigger than the file, at most --max-body (10 MB by default) per message, and at most 20 files per call.
  • An event sent while the connection is dying unnoticed (no ping yet) is lost.

Demo

cd demo && composer install
tenma index.php    # http://localhost:8090

License

MIT or Apache-2.0, at your option.