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 production setup with systemd, Caddy, and process management.
Requirements
- PHP 8.4+
- OpenSwoole extension (
pecl install openswooleor packaged asphp8.4-openswoole) - A reverse proxy (Caddy or nginx) to handle TLS and serve static assets
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
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
Caddy reverse proxy
Caddy handles TLS automatically. The key requirement is disabling response buffering for SSE
(the /_sse endpoint streams indefinitely):
myapp.example.com {
# Serve static assets directly
handle /public/* {
root * /var/www/myapp
file_server
}
# Proxy everything else to OpenSwoole
reverse_proxy localhost:3000 {
header_up X-Real-IP {remote_host}
}
}
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
This sets the / Twig global used by all action URLs and navigation
links throughout your templates. The value must be a relative path (/myapp,
/sub/path, etc.). An \InvalidArgumentException is thrown at startup
if an invalid value is supplied (absolute URL, protocol-relative path, etc.).
Configure the matching Caddy block:
example.com {
handle /myapp/* {
reverse_proxy localhost:3000
}
}
Brotli compression
php-via can compress pages, static assets, and live SSE streams with Brotli. It requires the ext-brotli PHP extension and HTTP/2 (needed for streaming compression to work in browsers). A hard error is thrown at startup if either requirement is missing.
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; compressed once and cached for the worker lifetime)
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')
->withStaticDir(__DIR__ . '/public');
// For multi-worker CPU parallelism, see "Multi-worker scaling" below.
// Sticky routing is required in all multi-worker configurations.
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–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 coroutine hook (SWOOLE_HOOK_ALL). 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.
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 is absent
or does not exactly match one of the listed origins are rejected with
403 Forbidden. Non-browser clients (curl, server-to-server) that omit
Origin are still allowed when no list is configured (same-host fallback only
applies to browser-sent requests).
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.
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.
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.
Graceful restart
To deploy new code without dropping connections, reload the service:
systemctl reload myapp
# or:
kill -USR1 $(cat /var/run/myapp.pid)
php-via listens for SIGTERM and SIGINT and calls your onShutdown
callbacks before stopping. Register cleanup callbacks for any timers or open resources.
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.
Why sticky routing is mandatory
Each php-via context — the SSE connection, signals, and views for one browser tab — lives in
the worker process memory that handled the initial page GET. The
context ID is embedded in every subsequent action POST. If a proxy routes that
POST to a different worker, that worker has no record of the context and
returns 400 Invalid context.
Sticky routing ensures that all requests for a given browser session always reach the same upstream. Without stickiness, 50–90% of action requests fail depending on the number of workers — this is a correctness problem, not merely a performance one. Verified by benchmark: at N=8 workers without stickiness, OK% collapses to ~50% regardless of workload; with cookie-sticky Caddy and N=1, 2, or 4 workers: 100% OK.
Multiple independent processes (no broker)
The simplest multi-core approach: run N independent php-via processes on separate ports, each with a single internal worker. A cookie-sticky load balancer routes each browser session permanently to one upstream. Processes share nothing — no Redis, no broker, no inter-process coordination.
Limitation: cross-process broadcasts (Scope::ROUTE,
Scope::GLOBAL, Scope::SESSION across processes) are not
supported. Use SwooleBroker 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.
Same machine — SwooleBroker
SwooleBroker uses OpenSwoole's built-in inter-worker
pipes and shared memory (OpenSwoole\Table). No external infrastructure required,
and cross-worker broadcasts (Scope::GLOBAL, Scope::ROUTE) work
automatically:
use Mbolli\PhpVia\Broker\SwooleBroker;
$config = (new Config())
->withWorkerNum(swoole_cpu_num())
->withBroker(new SwooleBroker());
Sticky routing required — for the same context-ownership reason as above.
Configure Caddy with lb_policy cookie or nginx with ip_hash.
GlobalState and broadcasts still propagate across all workers correctly even without
the browser landing on the same worker, but action POSTs and SSE connections must be sticky.
GlobalState is backed by an OpenSwoole\Table with capacity fixed at startup
(default: 1 024 keys × 4 096 bytes). Tune with
Config::withGlobalStateTableSize(int $maxRows, int $maxValueBytes) before calling
start().
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. Session data still requires sticky routing (same constraint as same-machine).
use Mbolli\PhpVia\Broker\RedisBroker;
$config = (new Config())
->withWorkerNum(4)
->withBroker(new RedisBroker($_ENV['REDIS_HOST'], 6379));
Static assets
Configure a static directory to serve files directly from OpenSwoole in development:
$config->withStaticDir(__DIR__ . '/public');
In production, serve static files from Caddy (or nginx) directly — it's faster and avoids passing binary content through PHP.
Next steps
- Lifecycle — onStart, onShutdown, cleanup hooks
- Actions → URL — why
@post()is required - Broker — SwooleBroker, RedisBroker, NatsBroker
- API Reference — Config class options (
withBasePath,withBrotli,withCertificate,withH2c,withTrustedOrigins,withSecureCookie,withEmbeddable)