The pieces
@mtjncn/runtimeruns language packs in the browser, against the real DOM.auto.jsfinds the page's<script type="text/php">tags and runs them.@mtjncn/php84is PHP 8.4 itself, php-src compiled to WebAssembly: about 5 MB, 1.5 MB compressed, cached after the first visit.ui.php(@mtjncn/ui) is the component framework, one PHP file, run by that PHP. ui-server serves a copy of it at/unscripted/ui.php.unscripted/ui-serveris the Laravel side: it serves components with their server methods stubbed out, renders them on the server, and runs the server methods.
A request's life
- Laravel renders your page.
@unscripted('todos')runs the component on the server, with the sameui.php, and puts its HTML and state in the page. - The browser loads the runtime and PHP, then
ui.php, which fetches each component's browser copy from/unscripted/components/<name>.blade.php. - Each component takes the state the server rendered with, and from then on runs in the browser: events call actions, actions change properties, and the template renders again.
- A
#[Server]stub returns a description of the call.yieldhands it to the framework, which sends it to/unscripted/call(or over the socket), and resumes the action with the result.
Rendering
A render after every action, as in Livewire, rather than signals or a virtual DOM. Each
render patches the DOM in place with the runtime's morph: elements that are still there are
kept, and only what changed is updated, so focus, the caret, scroll positions and anything else
on them survive. Give list items a u-key, so they're matched by identity rather than position.
Events are delegated: one listener per event type on the component's root, not one per element. Each component handles events only up to its children's roots.
Generators, not async
PHP has no async/await, and the browser's PHP is built without Fibers (WebAssembly outside
the browser has no stack switching). So an action that waits returns a Generator, and yield
is its await.
For the browser's own promises, promise() wraps one so it can chain:
use function Unscripted\Ui\promise;
use Unscripted as U;
promise(U::fetch('/api/weather'))
->then(fn($response) => $response->json())
->then(function ($data) { $this->weather = $data; $this->refresh(); });
32-bit integers
The browser's PHP is 32-bit: an int stops at 2,147,483,647 there, while the server's goes
much higher. A larger number from JSON arrives as a float, and (int) of one wraps around.
Keep timestamps in milliseconds as floats (microtime(true) * 1000), and watch for ids or
counts that could pass two billion.
Limits worth knowing
- The first visit downloads PHP, 1.5 MB compressed. The page is server-rendered meanwhile,
but not interactive.
u-navigateavoids it on later pages. - Browsers: Chromium is what's tested so far.
- Laravel only: ui-server needs Laravel 12 or 13.
- A dropped call isn't cancelled. When an action starts again, the earlier result is ignored, but the server still did the work.
- Partials shift line numbers in errors below them.
- Browser validation is a subset of Laravel's rules, and its
email,urlandintegerchecks are looser. The server always checks everything again. - Not available:
u-dirtyandu-transition. Use CSS for transitions.