Documentation menu

Multi-node Broker

By default php-via keeps all state in a single worker process. That's correct for most deployments — OpenSwoole handles thousands of concurrent SSE connections comfortably in one worker. When you need multiple workers on the same machine (CPU parallelism) or multiple servers, a broker fans out broadcast() calls so every worker can react to changes that happened on any other worker.

When do you need a broker?

ScenarioBrokerExternal dep?
Single worker (default)InMemoryBrokerNone
Multiple workers, same machineSwooleBrokerNone
Multiple servers / containersRedisBroker or NatsBrokerRedis / NATS

TAB scope is always fully isolated per-connection — no broker ever carries TAB broadcasts.

InMemoryBroker (default)

No configuration needed. Correct for single-worker deployments. Has zero overhead and no external dependencies. Calling withWorkerNum(N) with N > 1 and leaving InMemoryBroker as the broker is a startup error — php-via will throw a \RuntimeException with a clear message.

// These are equivalent — InMemoryBroker is the implicit default
$config = new Config();
$config = (new Config())->withBroker(new InMemoryBroker());

SwooleBroker (multi-worker, same machine)

Uses OpenSwoole's built-in inter-worker pipe (Server::sendMessage / onPipeMessage) to fan-out scope invalidations across all worker processes on the same machine. No external infrastructure required. GlobalState is backed by OpenSwoole\Table — shared memory that is allocated in the master process and mmap'd into every worker on fork.

use Mbolli\PhpVia\Broker\SwooleBroker;

$config = (new Config())
    ->withWorkerNum(swoole_cpu_num())  // use all CPU cores
    ->withBroker(new SwooleBroker());

Sticky sessions required. Session data is stored per-worker. Configure your load balancer to route each session to the same worker (e.g. Caddy's lb_policy cookie). GlobalState and broadcasts work correctly across all workers without sticky sessions.

Limits (configurable). GlobalState is backed by an OpenSwoole\Table with a fixed capacity allocated at startup: 1 024 keys × 4 096 bytes per value by default. Increase with Config::withGlobalStateTableSize(int $maxRows, int $maxValueBytes). An \OverflowException is thrown if a value exceeds the configured size.

RedisBroker

Uses Redis pub/sub to relay scope invalidations across nodes. Requires the ext-redis PHP extension and SWOOLE_HOOK_ALL (enabled automatically by php-via). The broker opens two coroutine connections per worker: one for publish, one for the subscribe loop.

Basic setup

use Mbolli\PhpVia\Broker\RedisBroker;

$config->withBroker(new RedisBroker('127.0.0.1', 6379));

Authentication

Pass $password for requirepass setups, or both $password and $username for Redis 6+ ACL users. Keep credentials out of source code — use environment variables or a secrets manager.

// requirepass (Redis default auth)
$config->withBroker(new RedisBroker(
    host: 'redis.internal',
    password: $_ENV['REDIS_PASSWORD'],
));

// ACL user (Redis 6+)
$config->withBroker(new RedisBroker(
    host: 'redis.internal',
    password: $_ENV['REDIS_PASSWORD'],
    username: 'php-via',
));

The minimal ACL permissions required are +subscribe +publish +ping &via:broadcast (or whichever channel name you configure).

TLS

// TLS — verify against system CA bundle
$config->withBroker(new RedisBroker(
    host: 'redis.internal',
    port: 6380,
    password: $_ENV['REDIS_PASSWORD'],
    tls: true,
));

// TLS with a custom CA (self-signed or internal PKI)
$config->withBroker(new RedisBroker(
    host: 'redis.internal',
    port: 6380,
    password: $_ENV['REDIS_PASSWORD'],
    tls: true,
    tlsCaFile: '/etc/ssl/my-ca.pem',
));

Custom channel

If multiple apps share the same Redis instance, give each app a distinct channel so their broadcast traffic doesn't cross-contaminate:

$config->withBroker(new RedisBroker(channel: 'myapp:broadcast'));

Full constructor signature

new RedisBroker(
    string  $host     = '127.0.0.1',
    int     $port     = 6379,
    ?string $password = null,   // #[SensitiveParameter]
    ?string $username = null,
    string  $channel  = 'via:broadcast',
    bool    $tls      = false,
    ?string $tlsCaFile = null,
)

NatsBroker

Uses a raw OpenSwoole coroutine TCP socket with the minimal NATS protocol — no extra PHP extension required beyond OpenSwoole itself. Good choice for containerised deployments where NATS already runs for other services.

Basic setup

use Mbolli\PhpVia\Broker\NatsBroker;

$config->withBroker(new NatsBroker('127.0.0.1', 4222));

Token authentication

$config->withBroker(new NatsBroker(
    host: 'nats.internal',
    authToken: $_ENV['NATS_TOKEN'],
));

TLS

// TLS — verify against system CA bundle (port 4243 is the TLS convention)
$config->withBroker(new NatsBroker(host: 'nats.internal', port: 4243, tls: true));

// TLS + custom CA + token
$config->withBroker(new NatsBroker(
    host: 'nats.internal',
    port: 4243,
    authToken: $_ENV['NATS_TOKEN'],
    tls: true,
    tlsCaFile: '/etc/ssl/my-ca.pem',
));

Custom subject

$config->withBroker(new NatsBroker(subject: 'myapp.broadcast'));

Full constructor signature

new NatsBroker(
    string  $host      = '127.0.0.1',
    int     $port      = 4222,
    ?string $authToken = null,          // #[SensitiveParameter]
    string  $subject   = 'via.broadcast',
    bool    $tls       = false,
    ?string $tlsCaFile = null,
)

Error observability

Both Redis and NATS brokers reconnect automatically with exponential backoff (1 s → 30 s cap). Register an error handler to receive a \Throwable on every connection drop:

$config->onBrokerError(function (\Throwable $e): void {
    // Safe summary only — do NOT log $e->getMessage() verbatim if it may
    // contain connection strings or credentials (OWASP: sensitive data exposure).
    error_log('[broker] connection lost: ' . get_class($e));
    $metrics->increment('broker.error');
});

The handler fires for every reconnect attempt. When the broker is in the backoff window, isConnected() returns false and the /_health endpoint reports "status":"degraded".

Health endpoint

Every php-via server exposes GET /_health — no configuration needed, no devMode gate. Use it with your load balancer, Kubernetes liveness probes, or uptime monitors:

curl https://myapp.example.com/_health
{
  "status": "ok",
  "version": "0.7.0",
  "broker": {
    "driver": "RedisBroker",
    "connected": true
  },
  "connections": {
    "contexts": 42,
    "sse": 38
  }
}

HTTP 200 when the broker is connected. HTTP 503 when the broker is in the reconnect window ("status":"degraded"). The response contains no IP addresses, credentials, or per-user data.

Example Caddy health check to remove a degraded node from rotation:

reverse_proxy node1:3000 node2:3000 {
    health_uri      /_health
    health_interval 5s
    health_timeout  2s
    health_status   200
}

Connection count

Each php-via worker process opens its own connection(s) to the broker backend.

BrokerConnections per worker
InMemoryBroker0
SwooleBroker0 (in-process pipes)
RedisBroker2 (publish + subscribe)
NatsBroker1

Plan your Redis/NATS maxclients / connection limits accordingly. With withWorkerNum(N), multiply the per-worker count by N.

Next steps

  • Broadcasting — the broadcast() API and scope targets
  • Scopes — which scopes cross node boundaries
  • Deployment — systemd, Caddy, TLS setup