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 production setup with systemd, Caddy, and process management.

Requirements

  • PHP 8.4+
  • OpenSwoole extension (pecl install openswoole or packaged as php8.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=function with OpenSwoole. Function-mode JIT compiles usleep() / 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%. Use jit=tracing instead — 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.

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 (not 0.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)