Documentation menu

Web components

php-via pages can use web components built with Rocket, Datastar's component layer, such as the components in the Starbase catalog. A Rocket component is a JavaScript module that imports Datastar, so the page needs a Datastar build that contains Rocket, and the component has to import the same copy of Datastar the page runs. php-via serves that build when you ask for it, and via_head writes the import map that points the components at it.

With the Rocket build on:

  • Rocket components run on php-via pages, also inside php-via components and keyed lists that the server re-renders.
  • Starbase components load from its catalog, pinned with integrity hashes through withImportMap(), or from copies in your app.
  • bind('value') binds a signal to a component property, so the component's value reaches the server with the next action.

The Rocket build is 22 KB with Brotli instead of 12 KB, and only apps that turn it on load it.

Enabling the Rocket build

$app = new Via(
    (new Config())
        ->withDatastarRocket()
);

/datastar.js then serves Datastar 1.0.4 with Rocket instead of the plain Datastar 1.0.4 bundle. The build comes from Starbase: Datastar 1.0.4 and Rocket built with Starbase's fixes, which php-via ships because of the keyed-list crash in the official Rocket bundle.

The Rocket build is 21,958 bytes with Brotli, against 12,183 bytes for the plain bundle, and every page of the app loads it, whether the page uses a component or not. Leave the option off unless your pages use Rocket components.

A reverse proxy that serves /datastar.js from disk has to serve vendor/mbolli/php-via/public/datastar-rocket.js instead, or pass the request on to php-via. With the plain bundle, every component module fails to load, because the bundle has no rocket export.

One Datastar engine per page

Rocket components import Datastar by name, import { rocket } from 'datastar'. The browser resolves the name through the page's import map, which has to map datastar to exactly the URL the page loads Datastar from, query string included. A module is identified by its URL, so /datastar.js and /datastar.js?v=f602fbe19d are two modules. If the map and the script tag differ, the components import a second copy and start a second Datastar engine on the same page, with signals of its own. The import map also has to come before the first module script, so that it is in place when the first import resolves.

via_head and via_foot, which every page writes (see Templates → The page bootstrap), take care of both. With withDatastarRocket(), or once withImportMap() added entries, via_head writes the map right after the via_ctx signal, ahead of everything appendToHead() adds, and via_foot loads Datastar from the same URL at the end of the body:

<script type="importmap">{"imports":{"datastar":"/datastar.js?v=f602fbe19d"}}</script>
...
<script type="module" src="/datastar.js?v=f602fbe19d"></script>

v is the first 10 hex digits of the served bundle's SHA-256, so a new bundle gets a new URL and browsers fetch it instead of using a cached copy. Config::getDatastarUrl() returns the URL, including the base path; in a page handler that is $c->getConfig()->getDatastarUrl(). With the plain bundle and no entries of your own, via_head writes no import map.

The import map is an inline script. Under a Content-Security-Policy without 'unsafe-inline', the browser blocks it unless it carries the page's nonce, and every component then fails to import datastar. Set the nonce in the via.csp_nonce request attribute, and via_head writes it on the map and on its other tags. Templates → Content-Security-Policy shows a policy that Datastar runs under.

The import map in a custom shell or Twig layout

Custom shell

A shell set with withShellTemplate() writes the map and Datastar with the same two placeholders as the default shell. Put {{ via_head }} right after <meta charset>, before {{ head_content }} and any module script of your own, and {{ via_foot }} before </body>:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    {{ via_head }}
    {{ head_content }}
</head>
<body>
    {{ content }}
    {{ foot_content }}
    {{ via_foot }}
</body>
</html>

A shell that loads Datastar with a script of its own, such as {{ base_path }}datastar.js, loads it from another URL than the map's, so the components start a second engine. php-via logs a warning for such a shell, and for a shell without via_head. An unversioned datastar.js also stays cached for up to an hour after an upgrade.

Twig layout

A view that renders its own <html> document (see Layouts that render the whole document) writes the same tags with the Twig functions:

{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    {{ via_head() }}
    <title>{% block title %}{% endblock %}</title>
</head>
<body>
    {% block content %}{% endblock %}
    {{ via_foot() }}
</body>
</html>

php-via warns once per route whose document lacks via_head or loads a Datastar script of its own next to the map, and in dev mode about a page with more than one import map.

Adding modules to the import map

Config::withImportMap() adds entries to the map php-via writes, so your own modules and the integrity hashes of Starbase files end up in the same map as datastar. A page needs them in one map: a browser that supports one import map per page ignores a second one.

$config = (new Config())
    ->withDatastarRocket()
    ->withImportMap(
        ['charts' => '/js/charts.min.js', 'icons/' => '/js/icons/'],
        ['https://cdn.example.com/lib@2.1.0/lib.min.js' => 'sha384-<hash>'],
    );

The first array maps module specifiers to URLs: import { chart } from 'charts' loads /js/charts.min.js, and import 'icons/rocket.js' loads /js/icons/rocket.js. The second gives the browser a Subresource Integrity hash per module URL, and the browser refuses a module whose bytes do not match. Browsers that do not support integrity in import maps ignore it and load the modules unchecked.

Calls merge: a later URL for the same specifier, or a later hash for the same URL, replaces the earlier one. The map is written as soon as there are entries, with the plain bundle too, and getImportMap() returns it as an array. Configure it before new Via(), like withDatastarRocket().

Each entry is checked when you add it. One the browser would ignore throws an \InvalidArgumentException: a URL that is neither absolute nor starts with /, a specifier ending in / whose URL does not, or a hash that is not sha256-, sha384- or sha512- with a base64 digest of that algorithm's length. A URL starting with ./ or ../ throws too: every page gets the same map, and the browser resolves such a URL against each page's own URL, so it would point at another file on /docs/intro than on /. Under withBasePath(), start your URLs with getBasePath(). php-via maps datastar itself, so an entry for it throws as well.

Pinning Datastar

php-via does not put Datastar's hash into the map by itself. A proxy or CDN that rewrites /datastar.js, by minifying it or injecting a script, would then break every page. Compression is fine, since the hash covers the decoded bytes. Config::getDatastarIntegrity() returns the served bundle's sha384 for apps that want the pin:

$config->withImportMap([], [$config->getDatastarUrl() => $config->getDatastarIntegrity()]);

Call it after withDatastarRocket() and withBasePath(), which both change the URL. The hash also applies to the Datastar script tag, so with a hash that does not match, the browser refuses Datastar and nothing on the page reacts.

Loading a Starbase component

Every Starbase component page has an Installation section with four tabs of snippets. Each snippet starts with an import map whose imports entry points datastar at Starbase's own copy of the build. Leave that entry out on a php-via page: php-via's map already points datastar at the build php-via serves.

From the catalog

The This component tab pins one component version: its minified module at an immutable URL, with an integrity hash, so the browser refuses the file if a single byte changes. With the default shell, add that tab's script tag with appendToHead():

$app->appendToHead(
    '<script type="module" src="https://starbase.zweiundeins.gmbh/c/slider@<hash>/slider.min.js" integrity="sha384-<hash>"></script>',
);

The integrity attribute covers that one file. A component that imports files of its own, such as sb-qr-code with its vendored uqr, lists their hashes under integrity in the tab's import map. Add them with withImportMap(), and leave out the line for Starbase's copy of Datastar:

$config->withImportMap([], [
    'https://starbase.zweiundeins.gmbh/c/qr-code@<hash>/qr-code.min.js' => 'sha384-<hash>',
    'https://starbase.zweiundeins.gmbh/c/qr-code@<hash>/vendor/uqr.min.mjs' => 'sha384-<hash>',
]);

The Autoloader and Pinned tabs load Starbase's autoloader instead, which imports each <sb-*> component the first time its tag appears on the page, including tags a morph adds later. /c/autoloader.js always loads the latest version of each component. The Pinned tab's autoloader belongs to one snapshot of the catalog, and /c/@<catalog>/importmap.json holds the integrity hashes of every file that snapshot can load. Save that file with your code, merge it into php-via's map and load the autoloader with its hash:

// curl -o starbase-importmap.json https://starbase.zweiundeins.gmbh/c/@<catalog>/importmap.json
$snapshot = json_decode(file_get_contents(__DIR__ . '/starbase-importmap.json'), true, flags: JSON_THROW_ON_ERROR);
$autoloader = 'https://starbase.zweiundeins.gmbh/c/@<catalog>/autoloader.js';

$config->withDatastarRocket()->withImportMap([], $snapshot['integrity']);

$app = new Via($config);
$app->appendToHead('<script type="module" src="' . $autoloader . '" integrity="' . $snapshot['integrity'][$autoloader] . '"></script>');

The snapshot's URLs never change, so the saved file stays valid, and the app starts without reaching Starbase. The file also lists Starbase's copy of Datastar, which a php-via page never loads; the browser ignores hashes for URLs it does not fetch. The file covers the whole catalog, 93 files in October 2026, which adds 14 KB to every page, 6 KB with Brotli. For a few components, the hashes from each component's Pinned tab are enough: that tab's map lists the files of the component and of the components it renders.

Copied into your app

Components import only datastar and files from their own folder, so copying the folder is enough. Download the files the Self-host tab lists into your withStaticDir() directory, keeping each component's folder, and load the module from there:

$config->withStaticDir(__DIR__ . '/public');  // public/js/slider/slider.min.js

$app->appendToHead('<script type="module" src="/js/slider/slider.min.js"></script>');

php-via serves .js and .mjs files as JavaScript. The Self-host tab's import map points at a copy of datastar-rocket.js you would host yourself; leave it out, since php-via serves the build at /datastar.js.

With withImportMap(), a copied module can also get a name and its hashes, and a page imports it by name where it uses it. This website loads its copy buttons, its visitor count and the pairing QR code that way:

$config->withDatastarRocket()->withImportMap(
    ['sb-qr-code' => '/vendor/starbase/qr-code@ecc5a99c314a/qr-code.min.js'],
    [
        '/vendor/starbase/qr-code@ecc5a99c314a/qr-code.min.js' => 'sha384-<hash>',
        '/vendor/starbase/qr-code@ecc5a99c314a/vendor/uqr.min.mjs' => 'sha384-<hash>',
    ],
);
{# in the template of a page that shows a QR code #}
<script type="module">import 'sb-qr-code'</script>

Keep the version in the folder name, as Starbase's URLs do. A browser that still has the old file in its cache checks it against the new hash and refuses it, so a copy updated under the same URL breaks the component for returning visitors until their cached copy expires. Versioned folders can then be cached for a year, as this website does with a withStaticCacheControl() closure that returns 'public, max-age=31536000, immutable' for them and null for every other file.

Binding signals to component properties

$signal->bind('value'), or {{ bind(signal, 'value') }} in Twig, writes the property form of Datastar's data-bind, which binds the element's value property:

<sb-slider label="Thrust" unit="%" {{ bind(thrust, 'value') }}></sb-slider>
{# renders data-bind__prop.value="<thrust's id>" #}

Use the property form for web components. Datastar then reads and writes the property, whether or not the module has defined the tag when Datastar reaches the element, and updates the signal on the element's input and change events. Plain bind() binds a custom element's value property only if its tag is already defined, and its value attribute otherwise, which a component does not have to keep up to date. Any other property, such as checked on sb-toggle, needs the property form. Starbase's docs bind every component this way.

A camelCase name is written in kebab case, which Datastar turns back: bind('selectedIndex') gives data-bind__prop.selected-index. For further modifiers, such as the __event.change that Starbase's docs use on toggles, write the attribute with the signal's id:

<sb-toggle label="Thrusters" data-bind__prop.checked__event.change="{{ thrusters.id }}"></sb-toggle>

The usual rules for client values apply: a TAB signal takes the value the browser posts with the next action, and a scoped signal ignores it unless it is declared clientWritable: true (see signal()).

Keeping state on the server

A component holds what only the browser needs: whether a menu is open, where the focus is, a drag in progress. Everything else stays in php-via. The view renders the server's values into the component's attributes, every sync() and broadcast renders them again, and the component reports a change with an event that posts to an action. The action decides what the value becomes:

$app->page('/thrust', function (Context $c): void {
    $thrust = $c->signal(40, 'thrust');

    $c->action(function (Context $c) use ($thrust): void {
        $thrust->setValue(min($thrust->int(), 80));
        $c->sync();
    }, 'setThrust');

    $c->view('thrust.html.twig');
});
{# thrust.html.twig #}
<div id="thrust">
    <sb-slider label="Thrust" unit="%" value="{{ thrust.int }}" {{ bind(thrust, 'value') }}
        data-on:change="@post('{{ setThrust.url }}')"></sb-slider>
    <p>The server holds {{ thrust.int }}.</p>
</div>

sb-slider updates the bound signal while it is dragged and fires change when the user lets go. The post carries the signal, the action caps it at 80 and syncs, and the thumb moves back to 80. Starbase's form components take a new value attribute from the server as their value, and keep the user's edit when the server sends the same markup again, so a re-render for an unrelated reason does not reset them. Every commit posts to the same URL, and each post cancels the one before it if it is still in flight, which suits a slider: the last post carries the latest value. With confirm, Starbase's value components also show a value as pending until the server's attribute matches it, and revert() goes back to the server's value; Starbase's commands and components guide describes the contract.

Rocket components inside keyed lists

Datastar's morph matches elements by id, so a list whose items carry ids keeps its elements when a sync or broadcast reorders it. With the official Datastar 1.0.4 Rocket bundle, a morph that reorders keyed elements containing a Rocket component overflows the stack (Maximum call stack size exceeded). When the same patch also inserts or removes elements, a HierarchyRequestError stops the morph halfway and leaves the page out of step with the server. Two of Starbase's patches, 0002 and 0008, fix it (upstream issues #1218 and #1209), which is why withDatastarRocket() serves Starbase's build. public/DATASTAR.md lists all twelve patches, the source commits and the hashes the test suite checks.

Next steps

  • Templates: shells, Twig layouts and binding
  • Actions: requests that cancel each other
  • API reference: withDatastarRocket(), withImportMap() and getDatastarIntegrity()
  • Starbase: the component catalog