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 {
# Required for SSE: disable buffering
flush_interval -1
# Optional: pass real IP to app
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 {
flush_interval -1
}
}
}
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 {
flush_interval -1
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');
// Add ->withWorkerNum(swoole_cpu_num())->withBroker(new SwooleBroker())
// for multi-worker CPU parallelism (see above).
Security hardening
Two Config options should always be set in production:
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.
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.
Same machine — SwooleBroker
SwooleBroker uses OpenSwoole's built-in inter-worker
pipes and shared memory (OpenSwoole\Table). No external infrastructure required:
use Mbolli\PhpVia\Broker\SwooleBroker;
$config = (new Config())
->withWorkerNum(swoole_cpu_num())
->withBroker(new SwooleBroker());
Sticky sessions required. Session data is per-worker. Configure your
load balancer to route each session to the same worker (e.g. Caddy lb_policy cookie
or nginx ip_hash). GlobalState and broadcasts work across workers without sticky sessions.
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 sessions (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 - API Reference — Config class options (
withBasePath,withBrotli,withCertificate,withH2c,withTrustedOrigins,withSecureCookie)