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 {
        # 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.

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 (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.

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)