Upgrading to 0.14
This page takes a 0.13 app to 0.14 in the order you meet the problems: Composer first, then what throws when the server starts, then what throws on the first page view, and last what behaves differently without throwing. Most renamed or removed methods still exist in 0.14 and throw an exception whose message names the replacement, so a run of your test suite or one page load per route finds most of the work. They go in 0.15.
The changelog lists every change. Its Upgrading from 0.13 section has one line per renamed name, if all you need is a search and replace list.
Update the package and install Twig
The requirements are the same as for 0.13: PHP 8.4 and ext-openswoole 26.
composer require mbolli/php-via:^0.14
composer require twig/twig # only for Twig templates
php-via no longer installs Twig. An app whose views are closures that return HTML needs nothing
more. An app with withTemplateDir(), template views such as
$c->view('page.html.twig'), $c->render() or $app->getTwig()
needs twig/twig 3.10 or newer. Without it, new Via() throws for
withTemplateDir(), and the other three throw with the command to run.
withTemplateDir() works as before. Config::withTemplateEngine() is the
explicit form and takes any Rendering\TemplateEngine; php-via's Twig adapter is
Twig\TwigEngine, whose second argument is the cache directory. Set one of the two:
passing both throws.
use Mbolli\PhpVia\Twig\TwigEngine;
$config->withTemplateDir(__DIR__ . '/templates');
// or
$config->withTemplateEngine(new TwigEngine(__DIR__ . '/templates', sys_get_temp_dir() . '/twig'));
starfederation/datastar-php 1.0.1 keeps working. The Datastar bundle php-via serves to the
browser is now 1.0.4, see Datastar 1.0.4 in the browser.
Config is frozen once new Via() has it
new Via($config) takes a snapshot of the settings and freezes the Config. A
with* call afterwards throws:
LogicException: Config::withBasePath() was called after new Via($config), which freezes the Config:
a later change would be ignored or only half applied. Make every with* call before new Via().
In 0.13 such a call was ignored or half applied: a late withTemplateDir() left the
template loader empty, a late withBasePath() left Twig's basePath at
/. Move every with* call before new Via(). A clone of a frozen
Config is a new Config that is not frozen, for tests that build several apps from one base.
Renamed and merged Config methods
The old names throw a BadMethodCallException that names the new call, except
getTraceMaxBytes(), which is undefined.
| 0.13 | 0.14 |
|---|---|
withContextCleanupDelay($ms) | withContextTimeouts(cleanupDelayMs: $ms) |
withContextConnectTimeout($ms) | withContextTimeouts(connectMs: $ms) |
withContextRevivalWindow($ms) | withContextTimeouts(revivalWindowMs: $ms) |
withTracing($on) | withDevBar($on); the argument is required |
withTracingWrites($on) | withDevBarOptions(writes: $on) |
withTraceBufferSize($n) | withDevBarOptions(traces: $n) |
withSsePollIntervalMs($ms) | withDevBarOptions(pollMs: $ms) |
getDevMode() | isDevMode() |
withGcInterval($ms) | withGcIntervalMs($ms) |
getTraceMaxBytes() | undefined; its limit was never enforced |
withContextTimeouts() changes only the timers you name, so one call can replace several
of the old setters. A second withDevBarOptions() call changes only what it names too.
Four more changes break code that ran on 0.13:
-
withSwooleSettings(['worker_num' => $n])throws atstart()when it differs fromwithWorkerNum(), which defaults to 1. php-via set up its shared tables and its broker for one worker while N started. Pass the count towithWorkerNum($n). -
start()throws forhook_flagswithSWOOLE_HOOK_STDIOand withoutSWOOLE_HOOK_FILE. See Coroutine hooks. -
withLogLevel()throws for a name it does not know. It used to read any unknown name asinfo.warning,errand the other PSR-3 and syslog names work. Config,Signal,ActionandScopeare final, so a subclass of one of them no longer compiles.
The Config getters other than getBasePath(), isDevMode(), isHttps(),
getDatastarUrl(), getDatastarIntegrity(), getImportMap() and
getContextRevivalWindowMs() are tagged @internal. They still answer, but they
may change without notice. The same holds for these 0.13 public methods:
Signal::isScoped(), getScope(), isClientWritable(),
writeCount() and hasChanged(); Scope::parse(), matches(),
isBuiltIn(), isRouteBased() and isValidWireScope();
Context::getNamedSignals(), getNamedActions(), hasView() and
getShellTemplate(); Via::getScopedSignal(), getNotFoundHandler() and
runGcCycle(); and the Stats::track*() methods and reset().
withBroadcastCoalescing() is deprecated: new Via()
logs a warning when coalescing is off, and 0.15 removes the method. Call
$app->flushBroadcasts() where a broadcast has to land before the next step.
Removed and renamed methods
Each row's old call fails in 0.14, except $app->activeSseCount, which still answers but is
internal. Most throw an exception that names the new call, such as "Via::onStart() was removed in php-via
0.14. Use $app->onWorkerStart($fn)...". Calls marked undefined fail with PHP's "Call to undefined
method", $c->signal($value) and $c->component($fn) with PHP's "Too few
arguments", and cacheUpdates: with PHP's "Unknown named parameter".
| 0.13 | 0.14 |
|---|---|
$app->onStart($fn) | $app->onWorkerStart($fn). It runs in every worker and passes int $workerId: run work meant for one worker where $workerId === 0. |
$app->onShutdown($fn) | $app->onWorkerStop($fn), which passes the worker id too |
$app->config() | $app->getConfig() |
$app->getContextsByScope($scope) | $app->getLocalContexts($scope), this worker's contexts only. $app->countClients($scope) counts the connected tabs on every worker. |
$app->getRenderStats() | $app->getStats()->getStats() |
$app->activeSseCount[$id] | $c->isConnected(). Via's public properties are @internal. |
Via::parseSignals() | undefined; php-via writes posted signals itself |
$c->onDisconnect($fn) | $c->onCleanup($fn), which runs at the same moment |
#[OnDisconnect] | #[OnCleanup]. A class that uses the old attribute throws at Via::mount() and component(). |
$c->interval($ms, $fn) | undefined; $c->setInterval($fn, $ms) |
$c->renderString($src, $data) | $app->getTwig()->createTemplate($src)->render($data) |
$signal->text() | <span data-text="{$signal->ref()}">{$signal->string()}</span> |
$c->action($fn, 'name', $scope) | $c->action($fn, 'name'); the third argument throws an ArgumentCountError. See Scopes. |
$c->signal($value) | $c->signal($value, 'name'), see Signals |
$c->component($fn) | $c->component($fn, 'namespace'), see Components |
$c->view($fn, cacheUpdates: ...) | shareRender: true, or nothing, see Views |
setValue($v, broadcast: false), markChanged: | see Signals |
Code that reached into internals has public replacements now. A patch queued by hand becomes
patchElements(), and PatchMode replaces Datastar's
ElementPatchMode there:
// 0.13
$c->getPatchManager()->queuePatch([
'type' => 'elements',
'content' => $html,
'selector' => '#exports',
'mode' => ElementPatchMode::Append,
]);
// 0.14
use Mbolli\PhpVia\PatchMode;
$c->patchElements($html, '#exports', PatchMode::Append);
$c->getComponentManager()->getParentPageContext() ?? $c becomes
$c->getPageContext(). Tests that built a Context by hand move to
TestApp, see Tests.
Scopes and sessions
In 0.13, $c->scope() also decided the scope of every signal and action declared after it,
and a view outside Scope::TAB shared its update render. In 0.14 a context shares only what
you declare: a signal's scope is its third argument, an action runs for the tab that posts it, and a
view shares its render with shareRender: true.
Pass the scope to every shared signal
A signal without a scope is private to its tab. After scope() set a shared primary scope,
signal() without a scope throws, so that no signal silently changes from shared to private:
LogicException: Context::signal() for 'count' has no scope, and this context's primary scope is 'route:/board'.
Since php-via 0.14 a signal no longer takes the primary scope: pass the scope given to scope() as the third
argument to share it, or Scope::TAB to keep it private to the tab.// 0.13
$c->scope(Scope::ROUTE);
$moves = $c->signal(0, 'moves');
$c->action(fn () => $moves->increment(), 'move', Scope::ROUTE);
// 0.14
$c->scope(Scope::ROUTE);
$moves = $c->signal(0, 'moves', Scope::ROUTE);
$draft = $c->signal('', 'draft', Scope::TAB); // private on a shared page
$c->action(fn () => $moves->increment(), 'move');
Each tab now registers its own action, and the shared state lives in the scoped signal.
#[Action(scope: ...)] in the composition API keeps working.
A scoped signal joins its context to its scope, so its writes reach the tab without
addScope(). scope() replaces only the primary scope and keeps the scopes joined
this way or with addScope(); in 0.13 it replaced the whole list.
A scope's signals go with its last context
In 0.13 a ROUTE, GLOBAL or custom-scope signal kept its value for the life of the worker. In 0.14 a worker drops a scope's signals once the last context that uses the scope is destroyed. With one worker, the next visitor then sees the initial value again, so a vote count or a board kept only in a scoped signal resets when every tab has left. Seed such a signal from GlobalState or your database:
$votes = $c->signal($app->globalState('votes', 0), 'votes', Scope::GLOBAL);
$c->action(function () use ($app, $votes): void {
$votes->increment();
$app->setGlobalState('votes', $votes->int());
}, 'vote');See When a scope's signals go.
ROUTE and SESSION resolve everywhere
scope(), addScope(), removeScope(), signal() and
#[Action(scope: ...)] turn Scope::ROUTE and Scope::SESSION into this
route's and this session's scope. In 0.13, addScope(Scope::ROUTE) joined a scope literally named
route. Calls outside a tab have no route or session to resolve against, so
$app->broadcast(), $app->countClients() and $app->getScopedSignalByName()
throw for a bare Scope::TAB, Scope::ROUTE or Scope::SESSION:
$app->broadcast(Scope::routeScope('/board')); // was $app->broadcast(Scope::ROUTE)
$app->broadcast(Scope::sessionScope($c->getSessionId())); // was $app->broadcast(Scope::SESSION)What $c->broadcast() reaches
On a context whose primary scope is Scope::TAB, $c->broadcast() updates that tab
only. In 0.13 it re-rendered every tab on the worker. To reach a scope the tab joined, call
$app->broadcast($scope); dev mode warns once for a TAB-primary tab that joined other scopes. On a
context with a shared primary scope, $c->broadcast() broadcasts that scope, as before.
One SESSION scope per session
$c->scope(Scope::SESSION) put every user's tabs into one scope named session, so its
broadcasts and shared render reached other users. Each session has its own scope now,
Scope::sessionScope($id). If your app used Scope::SESSION for cross-tab state, it is
private to each visitor from 0.14 on, which is what the scope promised. The scope's name changed too, see
The session id is a hash of the cookie.
Each action reads its own request
Two actions of one tab that ran at the same time shared one request in 0.13. Each action has its own now:
input(), file(), cookie() and getRequestAttribute()
return the values of the request that posted it, and a cookie from setCookie() goes out with that
action's response. Two consequences can change what your code reads:
-
In an action,
getRequestAttribute()returns what global middleware set on the action's request. Per-route middleware does not run on actions, so an attribute it set on the page request is null there. Read it in the page handler and pass it to the action's closure. -
Outside an action, such as in a timer,
input()andcookie()read the page request, where 0.13 returned the tab's last action's values. On a page load,input()reads the query string.
A tab rebuilt after it was away (a revival) runs the route's middleware again, on a GET of the page's URL, so an auth gate applies. When the middleware answers instead of passing the request on, the tab reloads and the page load gets that answer.
view() and shareRender
The signature is:
public function view(callable|string $view, array|callable $data = [], ?string $block = null, bool $shareRender = false): void
A string is a template name. $data and block: go with a template, and
$data may be a callable that runs on every render. A callable view takes neither, and a string
of HTML throws:
// 0.13
$c->view(fn () => $c->render('board.html.twig', ['cells' => $board->cells()]), block: 'board');
// 0.14
$c->view('board.html.twig', fn (): array => ['cells' => $board->cells()], block: 'board');
$c->view(fn (): string => '<div id="hello">Hello</div>'); // HTML goes in a closureShare the update render only where every tab sees the same
In 0.13, every view whose primary scope was not TAB rendered a broadcast once and sent that HTML to every
tab in the scope, unless it passed cacheUpdates: false. In 0.14 each tab renders its own update
unless the view passes shareRender: true. cacheUpdates: no longer exists, and
passing it fails with "Unknown named parameter $cacheUpdates":
- Delete
cacheUpdates: false: every view renders per tab now. -
Add
shareRender: true, after$c->scope(...), to views that are the same for every tab in the scope, such as a shared board or a live ticker. Leave it off for views that show TAB signals, per-user data or components: one tab's HTML would go to every other.
$c->scope(Scope::ROUTE);
$cells = $c->signal([], 'cells', Scope::ROUTE);
$c->view('board.html.twig', fn (): array => ['cells' => $cells->array()], block: 'board', shareRender: true);
shareRender: true on a TAB-primary context throws when the view renders, so the page answers 500.
A view that renders a whole HTML document never shares its render, since the document holds the tab's context
id; pass block: for the update. The shared render is keyed by scope, route pattern and
component, so two routes in one Scope::GLOBAL no longer get each other's HTML. A view without
shareRender costs one render per tab and broadcast: Performance
has the numbers.
A component that used cacheUpdates: false so that every page sync re-rendered it calls
sync() on its own context after a change, or broadcasts its scope.
Signal names and write flags
signal() needs a name: $c->signal(0, 'count'). In 0.13 all unnamed signals of a
context were one object, so two of them on a page were the same signal. A missing name is an
ArgumentCountError, an empty one an InvalidArgumentException.
setValue(), increment() and mutate() take no flags
broadcast: and markChanged:, by name or by position, throw an
ArgumentCountError whose message says what to do. What replaces them depends on what the flag
did:
-
broadcast: falseon a TAB signal changed nothing, since a TAB signal never broadcasts: delete the argument. -
broadcast: falseon a scoped signal kept one write quiet. Declare the signal withautoBroadcast: falseand send its value withsyncSignals()or a broadcast where you want it to go out. -
markChanged: falseon a signal the page has not sent yet, such as in the page handler: writesetValue($v), which keeps the value in the page's seed. -
markChanged: falseon a TAB signal the tab already shows: writesetValue($v)and then$signal->markSynced().
// 0.13
$c->getSignal('page')?->setValue('overview', broadcast: false);
$filter->setValue(' ', markChanged: false, broadcast: false);
// 0.14
$c->getSignal('page')?->setValue('overview');
$filter->setValue(' ');
$filter->markSynced();
A clientSeeded: true signal replaces the pattern of calling markSynced() right after
signal() for a value the browser holds first, such as one the page's script reads from the URL.
The old pattern keeps working.
Changed signals go to the tab after the action
php-via sends the signals an action changed to its tab when the action ends, also when it throws, for the page
and its components. A trailing $c->syncSignals() in an action is no longer needed and sends nothing
twice. Keep syncSignals() in timers, spawn() tasks and lifecycle callbacks, and for
scoped signals declared with autoBroadcast: false. $c->sync() still re-renders the view.
$c->action(function () use ($count): void {
$count->setValue($count->int() + 1);
// no $c->syncSignals() needed
}, 'increment');Values the browser sends keep their type
A value the browser posts for a signal has to have the type of the signal's initial value, or of its
#[Signal] property. A lossless form is stored as that type, such as '5' from a textarea
for a number or 'false' from a radio group for a bool. Any other value is refused like a write to a
signal that is not client-writable; 0.13 stored it as sent. A signal declared with null takes any
type. Dev mode warns once per signal. Signal::bool() follows PHP truthiness for values that are not
strings, so 2 is true.
Components and composition
-
component()needs a namespace, unique on its page, of letters, digits,_and-:$c->component($fn, 'cart'). Two components with one namespace would share their signals and actions, so the second throws. In a loop, build it from the key, such as'item-' . $idfor numeric ids or'item-' . md5($key)for any other key. -
#[OnDisconnect]becomes#[OnCleanup]. A class may have several#[OnCleanup]methods; they run in declaration order. A$ctx->broadcast()there reaches only the tab being destroyed when the class has no#[Broadcast], since its primary scope is thenScope::TAB. To update the other tabs, add#[Broadcast(Scope::ROUTE)]or the scope they share to the class, or call$app->broadcast($scope)with theViathat a factory passed tomount()gives the class. See Lifecycle hooks. -
Under
#[Broadcast]or#[Action(scope: ...)], an action runs on the instance of the tab that posted it. In 0.13 it ran on the first tab's instance, so one tab's click changed another tab's properties. -
A component's scoped
#[Action]URL starts with its namespace,/_action/cats-voteforvotein the componentcats. Templates that use the action'surlneed no change; a hard-coded URL breaks. #[Broadcast]keeps the scopes of the class's scoped#[Signal]properties, so their writes reach the page.- Property changes an
#[Action]method made before it threw reach their signals, as a closure action's writes do. - Components have the session of their page:
getSessionId()andsessionData()work there.
via_head and via_foot in shells and layouts
A custom shell or a layout that renders the whole document copied the tags that connect a page to php-via from the default shell. 0.14 writes them for you, with a CSP nonce when there is one:
| Where | Right after <meta charset> | Before </body> |
|---|---|---|
Shell (withShellTemplate()) | {{ via_head }} | {{ via_foot }} |
| Twig layout | {{ via_head() }} | {{ via_foot() }} |
| Closure that returns a document | $c->viaHead() | $c->viaFoot() |
A 0.13 shell becomes:
<!-- 0.13: copied from the default shell -->
<head>
<meta charset="UTF-8">
<meta data-signals='{{ signals_json }}'>
<meta data-indicator="_connecting" data-on-interval__duration.15s.leading="..." data-on-datastar-fetch="...">
<meta data-init="window.addEventListener('beforeunload', ...)">
{{ head_content }}
</head>
<body>
{{ content }}
{{ foot_content }}
<script type="module" src="{{ base_path }}datastar.js"></script>
</body>
<!-- 0.14 -->
<head>
<meta charset="UTF-8">
{{ via_head }}
{{ head_content }}
</head>
<body>
{{ content }}
{{ foot_content }}
{{ via_foot }}
</body>
{{ signals_json }} still renders for
older shells. A copied bootstrap keeps working, but it loads the unversioned datastar.js, which
browsers keep for up to an hour after an upgrade, and it lacks the listener that reconnects a tab at once after a
session rotation or when its worker stops. Such a tab waits for its next 15 s retry.
php-via logs a warning once per shell, and once per route that renders a whole document, when the page has no
via_head, or when it loads a Datastar script of its own while via_head writes an import
map. Dev mode also warns about via_head without any Datastar script, and about a second import map.
A layout that loads its own Datastar bundle keeps its script tag, leaves out via_foot, and leaves
withDatastarRocket() and withImportMap() off, so that via_head writes no
import map:
<head>
<meta charset="utf-8">
{{ via_head() }}
<script type="module" src="{{ basePath }}js/datastar.bundle.js"></script>
</head>
For a Content-Security-Policy with nonces, global middleware sets the via.csp_nonce request
attribute, and every tag of via_head and via_foot carries it, as do the Dev Bar's tags.
Datastar needs the nonce too, on the <html> element: Content
Security Policy shows the middleware and the layout. The default shell shows its Live Signals panel in dev
mode only.
The session id is a hash of the cookie
$c->getSessionId() and the new via.session request attribute return a hash of the
session cookie, not the cookie's value. The id therefore stays the same when regenerateSession() gives
the session a new cookie, and the cookie's value no longer appears in the page HTML, the Dev Bar, traces or broker
messages.
-
Middleware reads
$request->getAttribute('via.session'). Reading the cookie gives the wrong value, and its name changes withwithSecureCookie(). Every page, action, SSE androute()request carries the attribute, and a page request without a cookie gets the id its response then sets. - Data your app keeps elsewhere under a 0.13 session id, such as rows in a database or keys in Redis, is not found again. Expire it, or let those visitors start fresh.
-
SESSION scopes and SESSION signal ids are
session:plus a SHA-256 hash of the id. Build the scope withScope::sessionScope($id);'session:' . $idno longer matches. Servers that share a broker have to be upgraded together. -
php-via accepts only session ids in the form it issues, 32 lowercase hex characters; any other cookie starts a
new session. Under
withSecureCookie(true)it ignores the plainvia_session_idcookie, so a visitor who carries only that one gets a new session once, which logs them out.
// 0.13
$cookies = $request->getCookieParams();
$sessionId = $cookies['__Host-via_session_id'] ?? $cookies['via_session_id'] ?? null;
// 0.14
$sessionId = $request->getAttribute('via.session');
$auth = $this->app->getSessionData($sessionId, 'auth');
Call $c->regenerateSession() at login and logout, or $app->regenerateSession($request) in
middleware and route() handlers. The session keeps its id, its data, its SESSION signals and its
tabs; the old cookie works for 10 more seconds. Middleware shows a
login.
Datastar 1.0.4 in the browser
php-via serves Datastar 1.0.4 instead of 1.0.1. The SSE format is the same, and PHP code needs no change for it. Check these in the browser:
-
Requests cancel per method and URL, from any element. 1.0.1 cancelled per element. When two
elements post the same action, the second request cancels the first, and a request cancelled before it reaches
the server never runs its action. Give each element a query string, or turn cancellation off where every
request counts:
@post('{$send->url()}?from=button'),@post('...', {requestCancellation: 'disabled'}). See Actions. -
Retries send the current signals and stop after 10 attempts. With
retry: 'error'or'always', HTTP errors count toward them. See Lifecycle. - The
retryingfetch event fires only when a retry is scheduled and carries nomessage, and Datastar no longer logs each retry withconsole.error. data-bindon checkboxes and radios updates the signal oninputinstead ofchange. A script that dispatcheschangehas to dispatchinput, or bind with__event.change.- Deleting a signal, by a patch or by assigning
null, firesdata-on-signal-patch.
Every update and every SSE connect morphs the view into the page and sets each element's attributes to the
server's markup. An attribute the browser set, such as the open that showModal() puts on
a <dialog>, goes with it. List such attributes in data-preserve-attr:
<dialog id="confirm" data-preserve-attr="open">...</dialog>
Views has the whole dialog. The default shell loads
/datastar.js?v=<hash>, so a browser fetches the new bundle after an upgrade.
Routes and static files
-
A path without a file extension, such as
/about, that matches both a route and a file inwithStaticDir()serves the route. Paths with an extension are still served from the static directory first. -
Route parameters arrive percent-decoded:
/files/a%20bgivesa b. An encoded slash arrives as/, so check a parameter you build a file path from. -
withStaticDir()answers 404 for paths with a segment that starts with a dot, except/.well-known/, and for PHP sources. It served.envand.git/configin 0.13. -
With ext-brotli loaded, compressible static files go out with Brotli level 11 without
withBrotli(), and the server opens its port only after compressing the small ones present at start, 2.3 s at most.withBrotli(false)turns that off. A static level of 0 now means no compression. See Static compression. .json,.txt,.html,.xmland.mjsfiles get their content types, where.jsonwasapplication/octet-stream.
Several workers
-
withWorkerNum()above 1 withoutwithBroker()usesSwooleBroker; 0.13 threw atstart().withBroker(new SwooleBroker())keeps working. -
An action or a
download()URL that reaches a worker other than the one holding its tab is passed to that worker, so its TAB signals,sync()renders and scripts reach the browser. In 0.13 they were lost for most of a tab's actions. Behind a proxy that speaks h2c, every client lands on one worker until the proxy's connection carries 1,280 streams. Deployment explains both and what a passed request costs. -
tabState()values survive a tab moving to another worker; with several workers each tab has 1,024 serialized bytes for them by default.withContextDirectorySize(4096, maxTabStateBytes: ...)raises that, where 4096 is the default row count. - Deploy 0.14 with a full restart, not a worker reload: php-via's own classes load in the master process, and the shared tables hold session scopes in the 0.13 form.
-
On libcurl 8.20 or newer, the default
hook_flagsleave OpenSwoole's native curl hook out, which crashed the worker on every curl request to a host name. A curl request then blocks its worker until it returns. See Coroutine hooks.
Tests
The constructors of Context, Signal and Action are @internal,
and Context::setRequestInput() is gone. Testing\TestApp runs an app's pages through
php-via's own request, action and SSE handlers, with no server and no VIA_TEST_MODE:
// 0.13
putenv('VIA_TEST_MODE=1');
$app = new Via(new Config());
$c = new Context('ctx-export', '/', $app);
ExportActions::register($c, $app);
$c->setRequestInput(['format' => 'csv'], []);
$c->executeAction((string) $c->getAction('export')?->id());
$patch = $c->getPatch();
// 0.14
use Mbolli\PhpVia\Testing\TestApp;
$app = new TestApp(new Config(), function (Via $via): void {
$via->page('/', function (Context $c) use ($via): void {
ExportActions::register($c, $via);
$c->view(fn (): string => '<main id="app"><div id="exports"></div></main>');
});
});
$tab = $app->open('/');
$tab->patches(); // the connect's patches
$tab->action('export', ['format' => 'csv']);
$patches = $tab->patches(); // what the action sent
$app->shutdown();
$tab->signal('name') reads what the browser holds, $tab->html() renders the page from
server state, $tab->disconnect(expire: true) and connect() test a revival, and
$app->runTasks() runs spawn() tasks past their first wait. TestApp runs one worker and
renders broadcasts at once, without the broadcast tick.
A worked example: a monitoring dashboard
This is how a real app moved to 0.14: a traffic monitoring dashboard with seven pages, a Twig layout that loads its own Datastar bundle, and a Pest suite that drove actions through php-via's internals. The steps follow the order of this page.
- Composer.
composer require twig/twig, since the app renders Twig templates. -
Config and lifecycle.
app.phpstopped before the server listened, on three tombstones and onestart()check.withContextConnectTimeout(90_000)went: it had kept tabs alive across Datastar's slow reconnects, which the 60 s reconnect timeout now covers.onStart()andonShutdown()becameonWorkerStart()andonWorkerStop(), andworker_nummoved fromwithSwooleSettings()towithWorkerNum(). -
A page that was only a path for middleware. The app served MCP over HTTP from middleware attached
to a page whose handler called
renderString('MCP endpoint'). That page became a plain route, and the middleware a PSR-15 request handler that answers 404 while the feature is off:$app->route(['GET', 'POST', 'DELETE', 'OPTIONS'], '/_mcp', new HttpEndpoint($version)). -
The layout. Eight copied bootstrap lines became
{{ via_head() }}. The layout kept its own Datastar script and left outvia_foot(). -
The view. The page's view passed
cacheUpdates: false, because its tabs show different pages. The argument went; 0.14 renders per tab unless a view asks otherwise. -
Write flags.
broadcast: falsesat on 124setValue()calls in the app and 217 in its tests, all on TAB signals, so a search and replace deleted it. PHPStan found the 15 calls incatchblocks, which the tests did not run. Of twomarkChanged: falsecalls, one in a page handler became a plainsetValue(), and one in a test becamesetValue()plusmarkSynced(). -
Internals. Four
activeSseCountchecks became$c->isConnected(), and five hand-built element patches becamepatchElements()withPatchMode. -
Tests. The suite set action input with
Context::setRequestInput(), 23 times in 12 files. That method is gone, so those tests moved toTestApp, as in Tests.
The app ran on 0.14 after these steps. Three optional changes then removed code the app had written around gaps in 0.13:
-
Three actions caught every exception only to write an
_errorsignal. One$app->onError()callback replaced the threetryblocks; a failing action still answers 500, and the signal the callback writes goes out with it. -
Query results a tab showed were kept across revivals in a GlobalState map of up to 200 snapshots, keyed by
context id.
$c->tabState()andsetTabState()replaced the map and its eviction code. -
An import job broadcast its progress per file, and four static arrays and their timers held each scope to one
render per 250 ms.
Config::withBroadcastThrottle('admin:import', 250)replaced them, and it always delivers the last state of a burst.
New APIs that replace workarounds
Optional, but each replaces something apps wrote by hand. The API reference has the details.
| Instead of | 0.14 |
|---|---|
| A queued patch array | $c->patchElements($html, $selector, PatchMode::Append) |
A script built for execScript() to show a toast | $c->dispatch('toast', ['text' => 'Saved']), heard with data-on:toast__window |
Per-action try/catch that logs | $app->onError(fn (\Throwable $e, ?Context $c, ErrorPhase $phase, ?string $action) => ...) |
Coroutine::create() for work that outlives an action | $c->spawn(fn (Context $c) => ...): its throw reaches onError(), a stopping worker waits for it, and $c->isDestroyed() tells it the tab is gone |
| Revival snapshots in GlobalState | $c->tabState($key) and $c->setTabState($key, $value) |
| A page with a dummy view for JSON, webhooks or MCP | $app->route(['GET', 'POST'], '/api/items/{id}', $psr15Handler) |
| An export sent through the SSE stream | $c->download($fileOrCallable, 'export.csv', 'text/csv'), a one-shot URL |
empty($app->getClients()) before a broadcast | $app->countClients($scope), on every worker |
| A hand-written broadcast throttle | Config::withBroadcastThrottle('import:*', 250) |
| Reading the session cookie in middleware | The via.session request attribute, and regenerateSession() at login |
markSynced() right after signal() | $c->signal($fallback, 'name', clientSeeded: true) |
new Context() in tests | Testing\TestApp |
Next steps
- Scopes: what each scope shares in 0.14
- Views: template and closure views,
shareRender - Middleware:
via.sessionand session rotation - Deployment: several workers, static compression, coroutine hooks, CSP nonces