Signals
A signal holds a value that the browser shows. When an action, a timer or another tab changes it, php-via sends the new value to the tabs that use it, and Datastar updates every element bound to it.
Creating a signal
$count = $c->signal(0, 'count'); // initial value and name
$step = $c->signal(1, 'step');
The first argument is the initial value, any JSON-serializable value: an integer, a string, a boolean,
an array or null. The second is the name, which every signal needs: $c->getSignal('count')
finds the signal by it, a template gets the signal under it, and the browser id is built from it. An
empty name throws.
Declaring a TAB signal again with a name the context already has returns the same signal and sets it to the new initial value. php-via logs a warning when that changes the live value.
Reading and writing
$count->int(); // as int
$count->string(); // as string; an array comes out as JSON
$count->float(); // as float
$count->bool(); // 'true', '1', 'yes', 'on' are true, other strings false, other types as PHP casts
$count->array(); // as array
$count->getValue(); // as stored
$count->setValue(42);
$count->increment(); // adds 1 and returns the new value; increment(-1) subtracts
$items->mutate(fn (array $list): array => [...$list, 'new item']);
setValue(), increment() and mutate() take no other arguments.
With several workers, increment() and mutate() change a shared signal in one
step, where setValue($count->int() + 1) can lose a write another worker made in between.
increment() works on integers only.
When the browser gets the new value
Where the write happens decides how the value reaches the browser:
- In an action, the TAB signals it changed go to its tab when it returns, also when it throws, for
the page and its components. The view does not render for that: call
$c->sync()when the HTML has to change too. - A write to a scoped signal broadcasts its scope, so every tab in it renders its view and gets the value. See Sharing a signal.
- Anywhere else, such as a timer or a
spawn()task, call$c->syncSignals()to send the changed signals, or$c->sync()to render the view as well.
$c->setInterval(function () use ($elapsed, $c): void {
$elapsed->increment();
$c->syncSignals(); // no action runs here, so send the value
}, 1000);
$signal->markSynced() drops a TAB signal's pending value, so neither the page nor the
next sync sends it. Use it after a write the browser already shows.
Using a signal in the page
A signal's id in the browser includes the tab, so write it from the signal object, never by hand.
ref() gives the reference for Datastar expressions, and bind() the
data-bind attribute:
$c->view(fn (): string => <<<HTML
<div id="form">
<span data-text="{$count->ref()}">{$count->int()}</span>
<input {$step->bind()} type="number">
<div data-show="{$visible->ref()}">...</div>
</div>
HTML);In a Twig template, each signal is a variable under its name, and the bind() function writes the attribute:
<span data-text="{{ count.ref }}">{{ count.int }}</span>
<input {{ bind(step) }} type="number">
<div data-show="{{ visible.ref }}">...</div>
On a web component, bind a property instead: $volume->bind('value') and
{{ bind(volume, 'value') }} write
data-bind__prop.value. See Web components.
Datastar 1.0.4 updates a signal bound to a checkbox or a radio button on input, not on
change. A script that changes such an input and dispatches change has to
dispatch input, or bind with __event.change.
Values the browser sends
An action's request carries the values the browser holds, and php-via stores them before the action runs. Which signals take them:
- A TAB signal takes the browser's value, so
data-bindworks on it. - A signal with a scope belongs to the server: the browser cannot write it.
clientWritable: trueorfalsesets the rule for one signal, in either direction.Config::withStrictTabSignals()makes TAB signals the server's too, unless they are declared withclientWritable: true.
$status = $c->signal('idle', 'status', clientWritable: false); // only the server sets it
$note = $c->signal('', 'note', 'room:lobby', clientWritable: true); // a shared field every tab can write
A value the browser sends must have the type of the signal's initial value. A form that converts
without loss is stored as that type, such as '5' from a text field for an integer or
'false' from a radio group for a boolean. Any other value is refused like a write to a
signal the browser may not write, and a TAB signal sends the server's value back. A signal declared
with null takes any type. Dev mode logs a warning once per signal.
A value the browser holds first
Some values start in the browser, such as the section a page's own script reads from
location.hash. Declare such a signal with clientSeeded: true. Its initial
value is only the server's fallback: the page leaves the signal out, and every SSE connect gives it the
browser's value before the view renders, until the server writes it. Declaring it again keeps the live
value. It is a TAB signal, and the browser can write it.
$section = $c->signal('overview', 'section', clientSeeded: true);
The browser has to hold the value when the SSE connect of via_head runs, so declare it on
<html> or in <head> before via_head, in a layout of
your own (see Views that render the whole document):
<head>
<meta charset="UTF-8">
<meta data-signals="{ {{ section.id }}: location.hash.slice(1) || 'overview' }">
{{ via_head() }}
</head>
Datastar applies attributes in document order, and a value declared in <body> misses
the first connect, which then renders the fallback.
Sharing a signal
A signal without a scope is private to its tab. Pass a scope as the third argument to share it:
use Mbolli\PhpVia\Scope;
$count = $c->signal(0, 'count'); // this tab only
$likes = $c->signal(0, 'likes', Scope::ROUTE); // every tab on this route
$theme = $c->signal('light', 'theme', Scope::SESSION); // every tab of this browser session
$online = $c->signal(0, 'online', 'room:lobby'); // the tabs in this custom scope
A scoped signal joins its context to its scope, so its writes reach the tab. Its initial value counts
only when the scope does not have the signal yet; every later declaration gets the live value.
Scope::ROUTE and Scope::SESSION resolve to this route's and this session's
scope. After $c->scope() set a shared primary scope, signal() without a scope
throws a LogicException: pass the shared scope to share the signal, or
Scope::TAB to keep it private.
Open another browser tab with this page open, then increment both counters. The TAB counter is yours alone; the ROUTE counter is shared.
A write to a scoped signal broadcasts its scope, so every tab in it renders its view again. Declare it
with autoBroadcast: false to send the value yourself: $c->syncSignals() sends
it to the acting tab and $app->broadcast() to the scope. An action sends only its TAB
signals by itself, so such a signal stays where it is until you do.
Code outside a context, such as a server timer, finds a scoped signal by its name:
$app->getScopedSignalByName(Scope::routeScope('/'), 'likes'). See
Scopes for who a scope reaches.
Next steps
- Actions: where most signal writes happen
- Views: when the HTML around the signals renders
- Scopes: who shares a signal
- API: Signal: every method