Deployment
php-via runs as a long-lived OpenSwoole server process. Unlike traditional PHP-FPM setups, the process stays running: all state lives in memory. This page covers the service, the reverse proxy, several workers, static files, coroutine hooks and security settings.
Requirements
- PHP 8.4+
- OpenSwoole 26 (
pecl install openswoole-26.2.0, or aphp8.4-openswoolepackage of version 26) twig/twig3.10 or newer, only for Twig templates:composer require twig/twig- ext-brotli, optional: static files get Brotli level 11 once it is loaded, pages and streams with
withBrotli() - A reverse proxy (Caddy or nginx) to handle TLS
systemd service
Create a service unit at /etc/systemd/system/myapp.service:
[Unit]
Description=My php-via app
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php /var/www/myapp/app.php
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure
RestartSec=5s
Environment=APP_ENV=production
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable myapp
systemctl start myapp
systemctl status myapp
ExecReload makes systemctl reload send SIGUSR1 to the OpenSwoole master, which
restarts the workers (see Graceful restart). With ext-brotli loaded, the server
compresses the static files present at start before it opens its port, for up to 2.3 s (see
Static compression), so a health check right after start can fail.
Caddy reverse proxy
Caddy handles TLS automatically, and SSE needs no setting: reverse_proxy flushes a
response with Content-Type: text/event-stream to the client immediately, whatever
flush_interval says (Caddy docs).
A plain reverse_proxy streams /_sse as php-via writes it:
myapp.example.com {
# Files in /var/www/myapp/public, linked as /public/...
handle /public/* {
root * /var/www/myapp
file_server
}
# Proxy everything else to OpenSwoole
reverse_proxy localhost:3000 {
header_up X-Real-IP {remote_host}
}
}
The live site runs this pattern: HTTP/2 cleartext from Caddy to php-via, which runs with
withH2c() (see Brotli) and one worker, and no flush setting.
versions h2c is what an h2c:// upstream sets, and the zero timeouts
state Caddy's default of none, so a stream that stays open for hours is never cut. With more than
one worker, h2c sends every client to one of them: see which worker holds a tab
before you pick the upstream protocol.
myapp.example.com {
reverse_proxy 127.0.0.1:3000 {
transport http {
versions h2c
read_timeout 0
write_timeout 0
}
}
}
Behind nginx, the X-Accel-Buffering: no header of every SSE response turns off
nginx's proxy buffering for it (nginx docs).
Set proxy_buffering off for /_sse only if proxy_ignore_headers
drops that header.
An idle /_sse stream writes an SSE comment after 15 seconds without output, so a proxy
idle timeout longer than that, such as nginx's default proxy_read_timeout of 60 seconds,
does not cut it. If you raise Config::withSseKeepAliveMs(), keep it below your proxy's
timeout.
Sub-path mounting
If your app is hosted at a sub-path (e.g. example.com/myapp/), tell php-via
the base path at startup with withBasePath():
$config = (new Config())
->withBasePath('/myapp'); // also accepts '/myapp/' (trailing slash is added automatically)
Action URLs, the SSE and Datastar URLs that via_head and via_foot write, the
{{ base_path }} shell placeholder and the basePath Twig
global all carry it. The value must be a relative path (/myapp, /sub/path, etc.).
withBasePath() throws an \InvalidArgumentException for anything else, such as an
absolute URL or a protocol-relative path. Call it before new Via(), which freezes the Config.
Configure the matching Caddy block:
example.com {
handle /myapp/* {
reverse_proxy localhost:3000
}
}Brotli compression
withBrotli() compresses pages and live SSE streams with Brotli.
It requires the ext-brotli
PHP extension and either withCertificate() or withH2c(), since browsers ask for Brotli
over HTTPS only; start() throws when one is missing. withH2c() turns on HTTP/2 on the port,
which keeps answering HTTP/1.1 too, and pages and streams get Brotli over either protocol. Static assets get
Brotli whenever ext-brotli is loaded, without withBrotli() and unless withBrotli(false):
see Static compression.
Each SSE stream holds its own Brotli encoder, which grows with the traffic it carries and never
shrinks. At the default level 4 a busy stream settles at about 9 MB, so 2 000 busy
connections need about 17 GB; an idle stream stays near 7 KB. Level 1 uses about
570 KB per busy stream and a third of the CPU, for about 37% more bytes on the wire:
withBrotli(dynamicLevel: 1). Measurements are in PERFORMANCE.md.
pecl install brotliDev mode: self-signed cert, no proxy
Generate a self-signed certificate once:
openssl req -x509 -newkey rsa:2048 -keyout dev.key -out dev.crt \
-days 3650 -nodes -subj "/CN=localhost"Then configure php-via to terminate TLS directly:
$config = (new Config())
->withCertificate(__DIR__ . '/dev.crt', __DIR__ . '/dev.key')
->withBrotli();
// Access via https://localhost:3000 (browser will warn about self-signed cert, accept once)
// curl needs --insecure / -kProd mode: Caddy holds certs, php-via compresses
Use withH2c() so OpenSwoole speaks HTTP/2 cleartext (h2c) on the internal port
while Caddy terminates TLS externally. You must disable Caddy's own encode
directive. Otherwise Caddy would double-compress what php-via already compressed.
myapp.example.com {
reverse_proxy h2c://localhost:3000 {
header_up X-Real-IP {remote_host}
}
# No `encode` directive: php-via handles Brotli
}$config = (new Config())
->withH2c() // enables open_http2_protocol, satisfies withBrotli(), no certs needed here
->withBrotli()
->withHost('127.0.0.1')
->withPort(3000);
// Caddy handles TLS; OpenSwoole speaks h2c on the internal port onlyCompression levels:
- Pages and SSE: level 4 (fast; runs in the hot path)
- Static assets: level 11 (max ratio; never compressed in a worker's event loop, see Static compression)
Production config
Tune php-via for production in your app.php:
$config = (new Config())
->withHost('127.0.0.1') // bind locally, let Caddy handle external
->withPort(3000)
->withLogLevel(getenv('APP_ENV') === 'production' ? 'info' : 'debug')
->withTemplateDir(__DIR__ . '/templates') // Twig: composer require twig/twig
->withStaticDir(__DIR__ . '/public')
->withSecureCookie(true)
->withTrustedOrigins(['https://myapp.example.com']);
// For multi-worker CPU parallelism, see "Multi-worker scaling" below.
$app = new Via($config); // freezes the Config: every with* call goes before this lineOPcache & JIT
OPcache and PHP's JIT compiler are disabled for CLI processes by default.
Enable them explicitly in your server's php.ini (or via -d flags):
opcache.enable=1
opcache.enable_cli=1 ; required: CLI is off by default
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0 ; set to 1 in development
opcache.jit=tracing ; safe with OpenSwoole; omit if no CPU-bound actions
opcache.jit_buffer_size=32MKey findings
Results below are from a synthetic benchmark on a single WSL2 host (PHP 8.4.20, OpenSwoole 25.2.0). Absolute numbers vary by hardware. Treat deltas as directional guidance, not guarantees.
-
JIT tracing gives ~7.9× speedup on CPU-bound actions (tight loops, float
arithmetic, template rendering). A Mandelbrot workload went from 349 req/s (no OPcache) to
2,744 req/s with
jit=tracing. - OPcache alone doubles CPU throughput without JIT. Enabling OPcache only still gives roughly +120% on computation-heavy actions, with no JIT risk.
- IO-bound workloads are unaffected. If your actions mostly wait on database queries, external APIs, or file IO, OPcache and JIT add <10%. Invest in connection pooling instead.
- SQLite-bound actions see modest JIT gains (+3 to 8%). Synchronous SQLite calls are blocking C-extension calls: the coroutine scheduler cannot yield during them, so 100 concurrent coroutines still serialize on SQLite regardless of JIT profile. Reduce query count with in-process caching (e.g. caching per-server-lifetime results that only change on writes) rather than relying on JIT to compensate.
-
Never use
opcache.jit=functionwith OpenSwoole. Function-mode JIT compilesusleep()/sleep()as regular blocking calls, bypassing OpenSwoole's sleep hook (SWOOLE_HOOK_SLEEP, see Coroutine hooks). This turns a non-blocking 2 ms yield into a thread-blocking sleep and reduces IO concurrency by ~89%. Usejit=tracinginstead: it does not have this regression.
Coroutine hooks
A worker serves all its tabs from one thread, so a call that blocks the thread stops every stream,
action and page view on that worker until it returns. OpenSwoole's coroutine hooks replace blocking
PHP functions with versions that suspend only the calling coroutine, and the
hook_flags server setting picks which ones.
php-via sets hook_flags to Via::defaultHookFlags():
SWOOLE_HOOK_ALL, without SWOOLE_HOOK_NATIVE_CURL when libcurl is 8.20 or
newer, where that hook crashes workers (see the bug list). The framework
itself needs only the socket hooks, for RedisBroker: the rest
of it uses OpenSwoole's coroutine APIs, and it reads its shell template, static files and Dev Bar
assets once per worker. The other hooks are for your code, so that database queries, HTTP calls
over PHP streams, file access, sleeps and shell commands in actions and views suspend instead of
stopping the worker. Curl requests, such as Guzzle's curl handlers and Symfony's
CurlHttpClient, suspend only under SWOOLE_HOOK_NATIVE_CURL: on libcurl 8.20
or newer the default leaves it out, and each curl request blocks the worker until it returns.
File and stdio hooks cost CPU: each hooked file call is handed to a thread pool and back, 6 to
8 µs per handoff, and a 91 KB read takes 142 µs instead of 4.5 µs. Change the flags
with withSwooleSettings(['hook_flags' => ...]), which wins over the default.
What each hook covers
| Flag | Makes these suspend instead of block |
|---|---|
SWOOLE_HOOK_TCP, _UDP, _UNIX, _UDG, _SSL, _TLS | PHP stream sockets: PDO MySQL and mysqli (mysqlnd), phpredis, predis, php-amqplib, file_get_contents('https://...'). Host names resolve on the thread pool. RedisBroker needs TCP, TLS with tls: true, or UNIX for a socket path. |
SWOOLE_HOOK_STREAM_FUNCTION | stream_select() and stream_socket_pair() |
SWOOLE_HOOK_FILE | Opening, reading, writing and seeking files, fstat(), unlink(), mkdir(), rename() and directory reads, each on the thread pool. stat(), realpath(), include and fclose() still block. |
SWOOLE_HOOK_STDIO | Every stream on a file descriptor: STDIN, STDOUT, STDERR, temporary files, and the pipes of exec(), system(), passthru(), shell_exec(), backticks and popen() |
SWOOLE_HOOK_SLEEP | sleep(), usleep() and time_nanosleep(), which become timers |
SWOOLE_HOOK_PROC | proc_open() and the other proc_* functions |
SWOOLE_HOOK_NATIVE_CURL | All curl_* functions, so Guzzle's curl handlers and Symfony's CurlHttpClient run on the event loop. Only in an OpenSwoole built with --enable-hook-curl: php --ri openswoole then shows hook-curl => enabled. |
SWOOLE_HOOK_ALL leaves out SWOOLE_HOOK_BLOCKING_FUNCTION
(gethostbyname() and exec() on the thread pool), SWOOLE_HOOK_SOCKETS
(ext-sockets) and SWOOLE_HOOK_CURL, which has no effect in OpenSwoole 26.2.
Calls no hook covers
These block the worker under every flag set, and every tab on it waits:
- PostgreSQL through
pgsqlorpdo_pgsql. OpenSwoole's ownOpenSwoole\Coroutine\PostgreSQLclient suspends;php --ri openswooleshowspostgresql => enabledwhen the extension has it. - SQLite. Short queries on a local file are fine.
- Extensions with their own network client, such as
mongodb,amqpandgrpc. - The stat family:
stat(),filemtime(),file_exists(),is_file(),realpath(). includeandrequire.echoanderror_log(). php-via's logger writes withecho, so a log pipe that backs up stalls the worker.
A hooked connection belongs to one coroutine at a time. When two coroutines use the same PDO or Redis connection at once, OpenSwoole stops the worker with a "bound to another coroutine" error, so give each coroutine its own connection or take one from a pool.
OpenSwoole 26.2 bugs and workarounds
| Bug | Workaround |
|---|---|
| With libcurl 8.20 or newer, the native curl hook segfaults the worker on a curl request to any host name, whether it resolves or not (curl#21558). IP addresses work. | Via::defaultHookFlags() leaves SWOOLE_HOOK_NATIVE_CURL out on these versions, so a curl request blocks the worker until it returns. Flags you pass need the same, and start() logs a warning when they keep it. Guzzle's StreamHandler and OpenSwoole\Coroutine\Http\Client use the socket hooks and still suspend. |
The hooked curl_setopt() rejects CURLOPT_PREREQFUNCTION, so with SWOOLE_HOOK_NATIVE_CURL on, Guzzle 7.15's default client throws a ValueError on every request, sync and async. | Guzzle's StreamHandler, Symfony's CurlHttpClient, or OpenSwoole\Coroutine\Http\Client |
With SWOOLE_HOOK_NATIVE_CURL on PHP 8.4 or newer, every request with a header callback leaks about 2.8 KB (OpenSwoole issue #391). Guzzle's curl handlers and Symfony's CurlHttpClient always set one. | Recycle workers with the max_request setting, or use a client without curl |
mysqli_init() followed by a failed real_connect() corrupts the heap and crashes the worker, so a database restart takes workers down. | new mysqli(...) or PDO MySQL, which survive a refused connection |
The coroutine flock() treats LOCK_UN | LOCK_NB as a lock request, so Symfony Lock's FlockStore hangs on its second acquire in a worker. | A Redis or database lock store |
fsync() returns false with the warning "Can't fsync this stream!" under SWOOLE_HOOK_FILE or SWOOLE_HOOK_STDIO, and the file is not synced. | Call it in a process whose flags have neither, such as Via::noFileIoHookFlags() |
Hooks without file and stdio I/O
Via::noFileIoHookFlags() keeps the socket, stream, sleep and proc_open() hooks and drops
SWOOLE_HOOK_FILE and SWOOLE_HOOK_STDIO. It keeps SWOOLE_HOOK_NATIVE_CURL where
libcurl is older than 8.20 and leaves it out where the hook crashes workers; on OpenSwoole 26.2 with libcurl 8.20
or newer it returns 1790. File and stdio calls then run on the worker's own thread: a 1.3 KB
read takes 2 µs instead of 44 µs and flock() 0.7 µs instead of 28 µs,
fsync() works, and workers start no thread pool for files. A connection by host name still
starts one for DNS.
use Mbolli\PhpVia\Via;
$config->withSwooleSettings(['hook_flags' => Via::noFileIoHookFlags()]);It suits an app that runs no shell commands, holds no file lock across a suspension and reads only small local files. Under it:
exec(),system(),passthru(),shell_exec(), backticks andpopen()block the worker while the command runs: a command that takes one second froze it for 1 002 ms, against 1.5 ms under the default.proc_open()still suspends.- A
flock()held across a database query, an HTTP call or a sleep deadlocks the worker for good once a second request on it waits for the same lock: the waiting request blocks the worker, so the holder never resumes to release it. A lock around a write with nothing in between is fine. - Large reads and reads from slow volumes such as NFS or FUSE block the worker: a 64 MB read held it for 36 ms.
- Writes to
STDERRblock when the pipe behind it backs up, as Docker's and supervisord's log capture can. - On libcurl 8.20 or newer, curl requests block the worker until they return, as under the default. On older
libcurl the flags include
SWOOLE_HOOK_NATIVE_CURL, so curl requests suspend, and the other two native curl bugs in the list above apply.
Never drop SWOOLE_HOOK_FILE alone. With SWOOLE_HOOK_STDIO still on,
include reads through the thread pool and suspends halfway through a file, and a concurrent
request then fails with "Class not found". In a load test, php-via's website lost about half its requests that way.
start() throws a \RuntimeException for that combination before it binds the
port, and for a RedisBroker whose flags lack the socket hook its connection needs.
Capping the AIO thread pool
With SWOOLE_HOOK_FILE or SWOOLE_HOOK_STDIO on, each worker starts a thread pool
on its first hooked file call: one thread per CPU core, growing to eight per core while tasks wait
longer than 1 ms, with the extra threads exiting after a second idle. With worker_num
at the core count, the thread count grows with the square of the cores. Cap it with
OpenSwoole\Util::setAio() before start():
OpenSwoole\Util::setAio(['aio_core_worker_num' => 2, 'aio_worker_num' => 4]);
$app->start();
The same keys in withSwooleSettings() stop the server at start-up with "Invalid server
option". setAio() accepts misspelled keys without an error, so check the thread count in
/_stats.
Checking the flags at runtime
OpenSwoole\Runtime::getHookFlags() returns the flags in effect in a worker; test one hook
with (OpenSwoole\Runtime::getHookFlags() & SWOOLE_HOOK_FILE) !== 0. The Dev Bar's Stats panel
shows them wherever the Dev Bar is on. In dev mode, /_stats reports them under runtime, with the worker's thread pool
(aio_worker_num threads, aio_task_num tasks) and its event loop lag, which
grows when a call blocks the worker:
"runtime": {
"hook_flags": 2147452927,
"aio_task_num": 0,
"aio_worker_num": 2,
"event_loop_lag_ms": 0,
"event_loop_lag_max_ms": 0,
"event_loop_lag_avg_ms": 0
}
2147452927 is SWOOLE_HOOK_ALL without SWOOLE_HOOK_NATIVE_CURL on OpenSwoole
26.2. Scripts and tests that call Coroutine::run() get SWOOLE_HOOK_ALL when
no flags are set, and Coroutine::set(['hook_flags' => 0]) does not turn it off; only a
server runs under exactly the flags it is given.
Security hardening
Trusted origins: CSRF protection
By default, when no trusted-origin list is configured, php-via falls back to a
same-host check: the Origin header of every action request must match
the Host header. This already blocks cross-site requests in most cases.
For a stronger guarantee, set an explicit allowlist:
$config->withTrustedOrigins(['https://myapp.example.com']);
With an allowlist configured, action requests whose Origin header does not
exactly match one of the listed origins are rejected with 403 Forbidden.
A request without an Origin header is rejected outside dev mode, with or
without an allowlist. Browsers send Origin on every POST, so this only affects
non-browser clients (curl scripts, server-to-server calls, uptime checks). If you have such
clients, opt in:
$config->withAllowMissingOrigin();
The Dev Bar's /_via/signal and /_via/reset and the tab-close beacon POST /_session/close apply the same check.
A page served with Referrer-Policy: no-referrer sends its tab-close beacon with
Origin: null, which is denied; the SSE disconnect then schedules the cleanup instead.
Note: GET /_action/… always returns 405 Method Not Allowed
regardless of this setting (see Actions → URL for details).
Secure cookie
The session cookie uses HttpOnly and SameSite=Lax by default.
Behind HTTPS, add the Secure flag (and the __Host- prefix, which
prevents subdomain injection) by enabling:
$config->withSecureCookie(true);Do not enable this in local HTTP development: the browser will silently drop the cookie.
php-via accepts only session ids in the form it issues, 32 lowercase hex characters, and starts a new session for
any other cookie value. Under withSecureCookie(true) it reads only the __Host- cookie. Code
never needs the cookie: $c->getSessionId() and the via.session request attribute hold a
hash of it, which stays the same when the cookie changes. Give the session a new cookie at login and logout with
$c->regenerateSession(), or $app->regenerateSession($request) in middleware, so that a
cookie someone planted or read before opens no page of the account, and reaches its open tabs for 2 seconds at most.
Middleware shows a login.
Embedding in a cross-origin iframe
A browser judges a cookie's same-site status against the top-level page, so the
default SameSite=Lax session cookie is withheld when your app runs inside an
<iframe> on a different origin. The live SSE stream is gated on that cookie,
so the connection never attaches. withEmbeddable() fixes this in one call:
$config->withEmbeddable('https://parent.example'); // origins allowed to frame you (optional)It sets the session cookie to SameSite=None; Secure; Partitioned (CHIPS, so it
survives third-party-cookie phase-out) and, when you pass one or more origins, emits
Content-Security-Policy: frame-ancestors on page responses to restrict who may
frame the app. It implies withSecureCookie(true) and requires HTTPS
(withCertificate()) or h2c (withH2c()). start() hard-errors
otherwise, because a SameSite=None cookie without Secure is dropped.
frame-ancestors (who may frame you) is unrelated to
withTrustedOrigins() (which allowlists the action POST Origin, always
your app's own origin). Do not conflate them: an embedded action POST still passes the CSRF
check unchanged.
Content Security Policy
For a policy with nonces, a global middleware creates the nonce and passes it to php-via in the
via.csp_nonce request attribute. php-via puts the nonce on every tag of
via_head and via_foot, the import map included, and on the Dev Bar's tags:
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class CspNonce implements MiddlewareInterface {
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
$nonce = base64_encode(random_bytes(16));
$response = $handler->handle($request->withAttribute('via.csp_nonce', $nonce));
return $response->withHeader('Content-Security-Policy', "default-src 'self'; script-src 'nonce-{$nonce}'; object-src 'none'; base-uri 'none'");
}
}
$app->middleware(new CspNonce());
The header the middleware adds to the response it gets back from $handler->handle() goes
out with the page, and so does any other security header. A
<meta http-equiv="Content-Security-Policy"> in the head, before via_head,
works too, but a meta policy cannot set frame-ancestors or report-to. With
withEmbeddable(), php-via sends its frame-ancestors policy in the same header,
and the browser enforces both.
Datastar reads the nonce from a data-nonce attribute on the <html> element. With it,
Datastar runs data-* expressions, the scripts of execScript() and dispatch(),
and the reload php-via sends a tab it cannot rebuild, through script elements that carry the nonce. Without it,
Datastar compiles expressions with Function(), which needs 'unsafe-eval' in
script-src, and a nonce policy blocks those scripts. The default shell writes the attribute when the
page request carries a nonce. A shell of your own writes it with
{{ via_html_attrs }}, a Twig layout with
{{ via_html_attrs() }}:
{# layout.html.twig #}
<!DOCTYPE html>
<html lang="en"{{ via_html_attrs() }}>
<head>
<meta charset="utf-8">
{{ via_head() }}
</head>
<body>
{% block content %}{% endblock %}
{{ via_foot() }}
</body>
</html>
With the policy above, the default shell, a shell with the placeholder and this layout run the counter,
dispatch() and execScript() in Chromium with no CSP errors. See
Templates → Content-Security-Policy for a callable that returns a whole document.
Dev Bar in production
The Dev Bar is off outside dev mode, and its endpoints answer 404. withDevBar(true) turns it on in
production, read-only: signal writes stay off whatever withDevBarOptions(writes:) says, and no
visitor can clear the trace buffer. Read-only does
not mean private. Everyone who opens a page sees the traces, logs, scopes and context ids of every visitor's tabs,
with SESSION scopes as hashes, and the server's request counts and runtime figures in the Stats panel. Its endpoints under /_via/ do not pass through
your middleware, so keep it off on a site where that is too much, or block /_via, /_via/*
and /_traces for other visitors in the proxy. See Dev Bar.
Quick checklist
- Set
withTrustedOrigins([…])to your production domain(s). - Set
withSecureCookie(true)when serving over HTTPS. - Bind the server to
127.0.0.1(not0.0.0.0) and let Caddy/nginx face the internet. - Use
@post()(not@get()) for all action buttons and form submissions. - Call
regenerateSession()at login and logout. - Read the session id from the
via.sessionattribute in middleware, never from the cookie. - Leave
withDevBar()off unless every visitor may see every other visitor's traces.
Graceful restart
To deploy new app code without stopping the server, reload it. SIGUSR1 to the master process
restarts the workers:
systemctl reload myapp # with ExecReload in the unit above
# or, with ['pid_file' => '/run/myapp.pid'] in withSwooleSettings():
kill -USR1 $(cat /run/myapp.pid)
A stopping worker asks its tabs to reconnect at once and ends their streams, which fires
onClientDisconnect. It waits for its spawn() tasks and then runs your
onWorkerStop callbacks, exactly as on SIGTERM or SIGINT, within
max_wait_time (3 s by default). The same happens when a worker is recycled after
max_request. A callback cannot tell a reload from a stop, so keep "server going down" announcements
out of onWorkerStop.
What a reconnecting tab gets depends on the worker count. With one worker, the records that let php-via rebuild a tab live in the worker's memory and go with it, so each open tab reloads its page. With several workers, the records are in shared memory and survive the reload, so each tab is rebuilt by revival and keeps the signal values it holds.
A reload runs new code only for classes the master process has not loaded. php-via's own classes, and whatever
app.php loads before start(), stay as they were: see the
thin bootstrap pattern. Upgrading php-via, or changing code the
master loaded, needs a full restart (systemctl restart myapp), and with several workers that also
clears the shared tables.
Multi-worker scaling
php-via defaults to a single worker process. That's the right choice for most deployments: a single worker handles thousands of concurrent SSE connections without context-switch overhead. Use multiple workers when you have CPU-bound actions (computation, rendering) and need parallelism on a multi-core machine.
Same machine: withWorkerNum()
One php-via process with several OpenSwoole workers. No external infrastructure, and no sticky
routing: the load balancer sees one port and OpenSwoole picks the worker for each connection.
Without withBroker(), SwooleBroker carries broadcasts
between the workers.
$config = (new Config())
->withWorkerNum(\OpenSwoole\Util::getCPUNum());
Set the count with withWorkerNum(). A worker_num in withSwooleSettings() that
differs from it makes start() throw, since php-via sets up its shared tables for the count
withWorkerNum() gives.
A tab lives on one worker: the one that holds its SSE stream, or until the stream connects the one
that rendered the page. A worker that gets an action or a download() URL of a tab it does not
hold passes the request to the tab's worker over OpenSwoole's worker pipe and sends back that worker's
answer, with its cookies and a rotated session cookie. Middleware and the handler run on the tab's
worker only. Passing a request on adds about 0.04 ms to it, and a passed action costs the server
about twice the CPU of one that reaches the tab's worker (0.18 against 0.09 ms in a benchmark of
TAB actions). An upload goes over as it arrived, so file() and move_uploaded_file()
work there. A download() URL names the worker that made it, so once the tab's stream has moved
to another worker, the URLs made before answer 404.
OpenSwoole hands each connection to one worker, and every request on that connection goes there. So which worker a request reaches depends on how the proxy talks to php-via:
-
h2c, as in the Caddy setup above: every client shares the proxy's one upstream
connection, and with it one worker, until that connection carries 1 280 streams at once, OpenSwoole's limit
per connection. OpenSwoole 26.2 ignores a lower
http2_max_concurrent_streams. Below that, further workers take no load and nothing is passed on. - HTTP/1.1: the proxy opens a connection per concurrent request, and the connections spread across the workers. A tab's stream and its actions then travel on different connections, so with N workers about (N−1)/N of a tab's actions reach a worker that passes them on.
Use HTTP/1.1 when several workers are there for CPU-bound handlers. In Caddy that is a plain upstream address
without h2c://. Keep withH2c() in the config: the port then answers both protocols, and
withBrotli() still compresses pages and streams over HTTP/1.1. With one worker per server, h2c
costs nothing.
myapp.example.com {
reverse_proxy 127.0.0.1:3000 {
transport http {
read_timeout 0
write_timeout 0
}
}
}
The stream is not passed on. When it reconnects to another worker, that worker rebuilds the context by
re-running the route handler, as a revival does, and the worker
that held the tab drops its copy and hands over what the browser cannot send: the scripts and
patchElements() patches still queued, the TAB signal values the browser has not received,
and the values of server-owned TAB signals. An action still running there finishes on the old copy,
and what it sends follows the tab once it ends; a spawn() task still running there sends
nothing more, as for a destroyed context. Other server-side state, such as
#[Persist] properties, starts over. Scoped signal values, GlobalState, session data, tab
state and the connected-client list are in shared memory, and withActionRateLimit()
counts across workers.
A worker waits up to withContextTimeouts(forwardMs: ...) (60 s by default) for the tab's
worker to answer, and then answers 504; the action may still finish, and its cookies go out with the
tab's next action response. When the tab's worker stops or crashes after it got the request, the
worker that passed it answers 503 and does not run the action itself, since it may have run. The
next request of the tab finds no live worker for it, so the worker it reaches rebuilds the context
and takes the tab. A stopping worker asks its tabs to reconnect at once, so their streams move to the workers that
remain.
Each worker rewrites the directory entries of its open streams every quarter of the directory TTL
or of the revival window, whichever is shorter (150 s by default). A context that does not fit
in the directory is logged, and its actions on other workers answer 400. An action of a tab whose
context no running worker holds any more rebuilds it on the worker it reaches, which then holds the
tab for the reconnect timeout (withContextTimeouts(reconnectMs: ...)), see
Lifecycle.
Each worker keeps the getClients() list it built last, about 2.3 KB per client,
and rebuilds it after a client connects or leaves on any worker. A broadcast reads the list once for
all the views it renders. A client past the capacity set by withContextDirectorySize()
is left out of the list with a warning. When a worker crashes, its clients leave the list as soon
as OpenSwoole restarts it, and every 60 s the first worker drops the clients of processes that
no longer exist.
What stays per worker. The server logs these at start-up:
-
Read-modify-write on shared values.
setValue($s->int() + 1)andsetGlobalState($k, globalState($k) + 1)lose updates when two workers read the same value. UseSignal::increment()/incrementGlobalState()for counters,Signal::mutate()/mutateGlobalState()for anything else, and#[Signal(..., atomic: true)]in the composition API. During a broadcast each scoped signal is read from shared memory once and reused for every tab the flush renders, so a view that computes a new value from one needsincrement()ormutate()as well. Session data has no atomic update: two tabs of one session that read a key and write it back at the same moment can lose one write, so keep a value several tabs change at once in aScope::SESSIONsignal and usemutate(). - PHP statics and objects in your own code. A simulation held in a static diverges per worker.
-
Server-owned TAB signals. When a tab's stream reconnects to another worker after
the worker that held it stopped or crashed, the context rebuilt there starts them from the handler's
initial value. Keep state that has to survive that in a scoped signal or in
tabState().
The scoped signal table takes one row per scoped signal of every scope that a context on some worker
uses, and the row goes when the last such context is destroyed. Size it for the peak: live scopes
× signals per scope, so 300 open chat rooms with 3 signals each take 900 rows. The default
1 024 rows covers that. When it is full, a write that needs a new row is dropped and an error that
names withScopedSignalTableSize() is logged at most every 10 s per worker. The
signal's value then no longer reaches the other workers.
The context directory keeps a row for every page view until its revival window ends. A page whose
stream never opened, which is every crawler hit, holds its row for the connect timeout plus the
revival window: 30 s + 600 s by default. Size it for peak open tabs plus peak page views
per second × 630; the default 4 096 rows (about 8 000 in practice) covers about
12 page views per second. When it is full, new contexts get no row and a warning is logged at most
every 10 s per worker; those tabs work, but their actions on other workers answer 400. With
withContextTimeouts(revivalWindowMs: 0) a row holds only the tab's worker and goes when the
context is destroyed: actions on other workers still reach the tab, but a stream that reaches another worker
reloads the page, since no worker rebuilds a tab.
Session data takes one row per session that holds data, with all of its keys, and
withSessionTableSize() sizes it (default 1 024 sessions × 16 KB per
session). A write that takes a session past its byte cap throws \OverflowException.
Rows have no age limit: a session holds its row from its first write until its last key is
cleared or it is evicted, so the count includes visitors who left long ago, not only the ones
online now. Past the session count, the first worker drops the least recently used sessions every
second and logs a warning, and their data is gone. Size the count for every session that stores
data in the time you want it kept, such as a login's lifetime. Values are serialized, so they must
be serializable and an object comes back as a copy.
The session table reserves about twice the session count, rounded up to a power of two, times the bytes per session: 33 MB at the defaults, 260 MB for 5 000 sessions. About 9 MB of that is resident from start-up. The rest becomes resident as sessions store data and stays resident after they are cleared or evicted, so set container memory limits for the full reservation, or lower the bytes per session. If the kernel refuses the reservation, the server fails at start-up.
Multiple independent processes (no broker)
Run N php-via processes on separate ports, each with a single worker. Processes share nothing, so a cookie-sticky load balancer has to route each browser session to one upstream: a context, its signals and its session data exist only in the process that rendered the page, and any other process answers 400 Invalid context.
Limitation: cross-process broadcasts (Scope::ROUTE,
Scope::GLOBAL, Scope::SESSION across processes) are not
supported. Use one process with several workers, or RedisBroker, if you need those scopes.
// app.php: run N copies with different VIA_PORT values
$config = (new Config())
->withPort((int) (getenv('VIA_PORT') ?: 3001))
->withWorkerNum(1); // one worker per process, no broker neededmyapp.example.com {
reverse_proxy localhost:3001 localhost:3002 localhost:3003 localhost:3004 {
lb_policy cookie caddy_lb # sticky: correctness-critical, not optional
health_uri /_health
health_interval 5s
}
}
Start N copies via systemd template units or a process supervisor that varies
VIA_PORT. With cookie-sticky routing in place, throughput scales
near-linearly with the number of processes up to CPU saturation.
Multiple servers: RedisBroker / NatsBroker
For deployments across multiple machines or containers, use RedisBroker or NatsBroker. These carry scope invalidations over the network so every server reacts to changes on any other server. The shared memory above is per machine, so contexts, scoped signal values and GlobalState are not shared between servers: route each browser session to one server with cookie-sticky load balancing.
use Mbolli\PhpVia\Broker\RedisBroker;
$config = (new Config())
->withWorkerNum(4)
->withBroker(new RedisBroker($_ENV['REDIS_HOST'], 6379));Static assets
withStaticDir() serves the files of a directory from the workers, out of memory and with Brotli
level 11 (see Static compression); a 91 KB stylesheet costs 0.054 ms of
CPU per request.
$config->withStaticDir(__DIR__ . '/public');A proxy can serve the same directory instead, which keeps those requests off the workers entirely.
Leave /datastar.js to php-via: it serves the plain Datastar bundle, or the Rocket build
with withDatastarRocket(). A proxy that serves it from disk has to pick the matching
file, datastar.js or datastar-rocket.js from
vendor/mbolli/php-via/public/. An app that
pins Datastar with
getDatastarIntegrity() needs a proxy that passes the file on unchanged; compressing
it is fine.
Every static response from php-via, including the framework's own
/datastar.js and /via.css, carries an ETag and
Last-Modified, and honors If-None-Match / If-Modified-Since
with a 304. A HEAD request gets the same headers and no body, with the
Content-Length of the body a GET would get; over HTTP/2, OpenSwoole 26.2 leaves
that header out. The Cache-Control policy on top of that follows
devMode by default (no-cache in dev, so edits show up on the next reload;
public, max-age=3600, must-revalidate otherwise) and can be overridden explicitly.
The default policy caches /datastar.js?v=<hash> with the served bundle's hash for a
year (immutable), since a new bundle gets a new URL.
$config->withStaticCacheControl('public, max-age=31536000, immutable'); // e.g. if you fingerprint filenames yourself
Or pass a closure to fine-tune the value per file. It receives the absolute file path and the
resolved MIME type (no charset suffix, e.g. text/css), and returns
null to keep the default policy for that file, the year for
/datastar.js?v= included:
$config->withStaticCacheControl(function (string $filePath, string $mimeType): ?string {
if (preg_match('/\.[0-9a-f]{8,}\./', basename($filePath)) || str_starts_with($mimeType, 'font/')) {
return 'public, max-age=31536000, immutable'; // fingerprinted assets and fonts
}
return null;
});
A string is always taken literally, never invoked as a function name. Pass a closure, or
first-class callable syntax ($obj->method(...)) for an existing method.
Each worker keeps a file in memory after its first request and reads it again only when its modification
time or size changes. It keeps files up to 2 MiB, and up to 16 MiB in all; a bigger file, or one
that no longer fits, goes out with sendfile(). Brotli forms have their own memory, see
Static compression. Dev mode checks the file on every
request, so an edit shows up on the next reload, and when the memory is used up it first drops files
that were deleted or changed, such as earlier builds of a bundle with a hash in its name. In
production, reload the workers after changing files (see Graceful restart).
A request path whose last segment has a file extension, such as /css/site.css, is
looked up in the static directory before routing. A path without one, such as an ACME challenge
token, is looked up only when no route matches, so a route and an extension-less file with the same
path serve the route. Paths under /_action/, /_via/ and
/_session/ are never looked up before routing, even with a dot in them. php-via
resolves the directory's real path once per worker, so a static directory behind a symlink that a
deployment switches keeps serving the old target until the workers reload.
Paths never served
php-via answers 404 without a look at the disk for a path with a segment that starts with a dot, such as
/.env, /.git/config or /assets/.cache/app.js, and for PHP sources:
.php with or without a version digit (.php5), .phps,
.phpt, .pht, .phtml, .phar and .inc.
/.well-known/ is the exception, for ACME challenges and similar. The path is percent-decoded
first, so /%2eenv is refused too and /my%20file.css finds my file.css.
A link inside the directory that points at such a file answers 404 as well. A route with such a path still
works.
Static compression
Whenever ext-brotli is loaded, php-via sends compressible static files with Brotli at level 11 to clients
that accept it: withStaticDir() files of the types below, /datastar.js,
/via.css and the Dev Bar assets. This needs neither withBrotli() nor HTTP/2, since
a static response is compressed whole, but browsers ask for Brotli over HTTPS only. Each of these responses carries Vary: Accept-Encoding
and the same weak ETag for every encoding, so a revalidation gets a 304 whichever
form the client holds.
- Compressed: css, js, mjs, json, map, webmanifest, wasm, svg, ico, ttf, otf, txt, md, csv, html, htm, xml, rss, atom.
- Sent as they are: woff2, woff, png, jpg, webp, gif, avif, pdf, mp4, webm and mp3, which are compressed already, and any type php-via does not list.
Level 11 takes 90 to 140 ms per 100 KB of JavaScript or CSS, 2.1 s for a 1.7 MB library, so a worker never compresses at that level itself:
- At start, php-via compresses the files of up to 128 KiB that exist, in the master process, so every worker inherits them without a copy of its own. It starts a file only while its expected time fits a 2 s budget. Level 11 takes 1.3 to 2.1 µs per byte of JavaScript or CSS, and a file can take longer than expected, so the step ends at most one 128 KiB file, about 0.3 s, past the budget: by 2.3 s. The port opens after this step, so a restart takes that much longer, and readiness checks and rolling restarts have to allow for it: the website (11 files, 300 KB compressed to 64 KB) takes 0.3 s. Dev mode skips it.
-
Right after start, the first worker has a helper process compress the files left out,
bigger files and those past the budget, up to 32 MiB of them, and keeps the results with the files
compressed at start. Another worker asks the helper on its first request for such a file, and the helper
answers from the results it keeps. php-via starts the helper with
withStaticDir()or in dev mode. -
Later, a file that changed, or one left out that the helper has not compressed yet, goes
to the helper on its first Brotli request. Until
its result arrives, a worker sends the file at level 4 when it is up to 256 KB and uncompressed when it is bigger, with
Cache-Control: no-store, so neither a browser nor a CDN keeps that stand-in. The helper compresses one file at a time at the lowest CPU priority, so on a core a worker needs, the worker goes first. An edited 91 KB stylesheet got level 11 after 0.1 s, a 546 KB library after 0.9 s and a 2.3 MB bundle after 3 s.
To skip that work for a file, ship it precompressed: foo.css.br next to foo.css.
php-via sends the sidecar as it is when it is at least as new as the file, even without ext-brotli. That is
the way to ship large assets:
brotli --best public/js/app.js # writes public/js/app.js.br; rerun after each build
Build sidecars in the deploy step, after the files are in place, and keep them out of git. A fresh clone,
or a Docker COPY of one, gives a file and its sidecar the same modification time even when the
sidecar was not rebuilt after the file's last change. php-via then takes the stale sidecar for a fresh one,
and Brotli clients get the old content.
Memory stays bounded: up to 32 MiB of Brotli forms compressed at start, shared by all workers, which
the first worker tops up for itself with those the helper compressed right after start; per worker up to
16 MiB of Brotli forms of files compressed later, 2 MiB each; the helper keeps
16 MiB of results for the other workers and needs about 50 MB more while it compresses. A file
over 8 MiB, or one whose Brotli form exceeds 2 MiB, goes out uncompressed unless it has a sidecar,
and a sidecar over 2 MiB goes out with sendfile(). With the website's own files a server
uses about as much memory as before; with three large libraries added (2.3 MB, 1.7 MB and
546 KB) it uses about 15 MB more in all, for the helper and the forms compressed at start.
Turn static Brotli off with withBrotli(false), or keep Brotli for pages and SSE only with
withBrotli(staticLevel: 0), for example when a CDN compresses. A proxy in front needs no
change: Caddy's encode and nginx's gzip leave a response alone that already
carries a Content-Encoding.
Next steps
- Lifecycle: onWorkerStart, onWorkerStop, cleanup hooks
- Actions → URL: why
@post()is required - Broker: SwooleBroker, RedisBroker, NatsBroker
- Performance: what a page view, a tab and a broadcast cost, and how many fit on a server
- Upgrading to 0.14: what to change in a 0.13 app
- API Reference: Config class options (
withBasePath,withBrotli,withCertificate,withH2c,withTrustedOrigins,withSecureCookie,withEmbeddable)