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?
| Scenario | Broker | External dep? |
|---|---|---|
| Single worker (default) | InMemoryBroker | None |
| Multiple workers, same machine | SwooleBroker | None |
| Multiple servers / containers | RedisBroker or NatsBroker | Redis / 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.
| Broker | Connections per worker |
|---|---|
InMemoryBroker | 0 |
SwooleBroker | 0 (in-process pipes) |
RedisBroker | 2 (publish + subscribe) |
NatsBroker | 1 |
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