Documentation menu

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 a php8.4-openswoole package of version 26)
  • twig/twig 3.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.target
systemctl 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 brotli

Dev 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 / -k

Prod 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 only

Compression 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 line

OPcache & 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=32M

Key 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=function with OpenSwoole. Function-mode JIT compiles usleep() / 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%. Use jit=tracing instead: 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

FlagMakes these suspend instead of block
SWOOLE_HOOK_TCP, _UDP, _UNIX, _UDG, _SSL, _TLSPHP 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_FUNCTIONstream_select() and stream_socket_pair()
SWOOLE_HOOK_FILEOpening, 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_STDIOEvery stream on a file descriptor: STDIN, STDOUT, STDERR, temporary files, and the pipes of exec(), system(), passthru(), shell_exec(), backticks and popen()
SWOOLE_HOOK_SLEEPsleep(), usleep() and time_nanosleep(), which become timers
SWOOLE_HOOK_PROCproc_open() and the other proc_* functions
SWOOLE_HOOK_NATIVE_CURLAll 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 pgsql or pdo_pgsql. OpenSwoole's own OpenSwoole\Coroutine\PostgreSQL client suspends; php --ri openswoole shows postgresql => enabled when the extension has it.
  • SQLite. Short queries on a local file are fine.
  • Extensions with their own network client, such as mongodb, amqp and grpc.
  • The stat family: stat(), filemtime(), file_exists(), is_file(), realpath().
  • include and require.
  • echo and error_log(). php-via's logger writes with echo, 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

BugWorkaround
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 and popen() 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 STDERR block 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).

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 (not 0.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.session attribute 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) and setGlobalState($k, globalState($k) + 1) lose updates when two workers read the same value. Use Signal::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 needs increment() or mutate() 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 a Scope::SESSION signal and use mutate().
  • 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 shared tables are sized at startup. Config::withGlobalStateTableSize() (default 1 024 keys × 32 KB per value), withScopedSignalTableSize() and withContextDirectorySize() tune them. Row counts are a floor: OpenSwoole admits more keys than requested, then rejects by hash, and a full GlobalState table throws \OverflowException. GlobalState keys are at most 63 characters.

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 needed
myapp.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)