helppc/supervisor-bundle
A Symfony bundle that integrates Supervisor process control into your application, letting you configure, manage, and monitor supervisord programs from a single place with minimal setup.
1. Check version requirements, then install
v2.0.0 is a major bump: PHP 8.2+, Symfony 6.4/7/8. If you are still on Symfony 5.4 or PHP 8.1, stay on ^1.0. Otherwise:
composer require helppc/supervisor-bundle:^2.0
// config/bundles.php
return [
// ...
HelpPC\SupervisorBundle\SupervisorBundle::class => ['all' => true],
];
2. Make supervisord reachable over XML-RPC
The bundle is only a client — supervisord has to expose the RPC interface. In supervisord.conf:
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0700
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
Or, if you prefer TCP, uncomment [inet_http_server] with port=127.0.0.1:9001 and credentials. Prefer the unix socket on a single host; TCP only over a private network or SSH tunnel.
3. Configure the bundle
v2.0.0 adds per-server auth — the config is a set of named servers, each with its own host/port/auth/hidden list. Read DependencyInjection/Configuration.php for the exact v2 key names before copy-pasting v1 examples from blog posts:
# config/packages/supervisor.yaml
supervisor:
servers:
default:
host: '%env(SUPERVISOR_HOST)%' # 127.0.0.1 or unix:///var/run/supervisor.sock
port: 9001
username: '%env(SUPERVISOR_USER)%'
password: '%env(SUPERVISOR_PASSWORD)%'
hidden_processes: ['app:*'] # v2: keep noise out of list/dashboard calls
worker:
host: '%env(SUPERVISOR_WORKER_HOST)%'
username: '%env(SUPERVISOR_WORKER_USER)%'
password: '%env(SUPERVISOR_WORKER_PASSWORD)%'
Where to look first: the bundle's DependencyInjection/Configuration.php for the v2 server map and hidden_processes semantics (are hidden entries filtered from getAllProcessInfo, from UI helpers, or both?), Resources/config/services.* for the per-server service ids, and the README for the new restart helper. With 0 stars and one outside contributor so far, the source is the documentation — read the config tree and the client class before wiring anything.
First use case — a read-only status command:
#[AsCommand(name: 'app:supervisor:list')]
final class ListProcessesCommand extends Command
{
public function __construct(private SupervisorClient $supervisor) { parent::__construct(); }
protected function execute(InputInterface $input, OutputInterface $output): int
{
$rows = [];
foreach ($this->supervisor->getAllProcessInfo() as $p) {
$rows[] = [$p['group'].':'.$p['name'], $p['statename'], $p['pid'], $p['description']];
}
$output->writeLn((new Table($output))->setHeaders(['Process', 'State', 'PID', 'Desc'])->setRows($rows));
return Command::SUCCESS;
}
}
That single command validates the whole stack (config → socket permissions → RPC) before you build anything that mutates state. On a multi-server setup, run it once per configured server to prove each credential pair.
Read-only dashboard, cached aggressively. getAllProcessInfo() is one RPC round trip but PHP opens a fresh socket per request — and with v2's multi-server config, N servers means N sockets per page. Put each server's result behind a 5–15 s cache (cache.app) on the status page, keyed by server name; never call it on every page load of a busy admin panel.
Use hidden_processes for signal, not convenience. Configure it in the bundle config rather than filtering in your controller. That way every consumer — commands, health checks, dashboard — sees the same process list. Keep the list narrow (one-shot maintenance programs, the web process itself) and log what you hide, otherwise you will be debugging a "missing process" that was silently filtered.
Per-server auth: inject the per-server client, not "the" client. v2's main value is driving two supervisord instances with different credentials. Wrap each in your own manager keyed by name:
final class SupervisorRegistry
{
/** @param array<string, SupervisorManager> $managers */
public function __construct(private array $managers) {}
public function manager(string $server = 'default'): SupervisorManager
{
return $this->managers[$server] ?? throw new \InvalidArgumentException("Unknown server $server");
}
}
Tag your managers and inject them as a tagged_iterator so adding a server is a config change, not a code change.
Mutations behind a console command, not a controller. The cleanest pattern for start/stop/restart is a thin command plus a controller that dispatches it or calls a service method. Commands are cron-able, scriptable from deploy hooks, and trivially testable. v2's new restart helper collapses what used to be a stop/start pair per process:
final class SupervisorManager
{
public function __construct(private SupervisorClient $client) {}
public function restartGroup(string $group): void
{
foreach ($this->client->getAllProcessInfo() as $p) {
if ($p['group'] !== $group) { continue; }
$name = $p['group'].':'.$p['name'];
try { $this->client->restartProcess($name); }
catch (FaultException $e) when ($e->getCode() === FaultCodes::NOT_RUNNING) {
$this->client->startProcess($name); // idempotent restart
}
}
}
}
Confirm the exact method name and argument signature in the v2 client before relying on it; if restart is only a helper over stop+start, the fault-code fallback above is still the safe shape.
Wrap the client in your own SupervisorManager facade rather than injecting the bundle's client everywhere. You get fault-code normalisation, retry/timeout policy, group-prefix helpers, multi-server routing, and the ability to swap the library out if this package stalls.
Deploy hook — restart workers, never supervisord. The single most valuable day-to-day use: after composer install/cache:clear, restart long-running queue workers so they pick up new code.
# deploy.yaml
- run: bin/console app:supervisor:restart-group messenger
Target the messenger:* group only. Do not call the supervisor.restart RPC (or a v2 wrapper around it) from a deploy script that itself runs under supervisor — you'll kill the process tree mid-deploy (see Gotchas).
Log tailing with offsets. Supervisor distinguishes readProcessStdoutLog/readProcessStderrLog (full read) from tailProcessStdoutLog/tailProcessStderrLog (offset + length). For a "follow logs" UI, poll the tail method and keep the returned end-offset client-side:
[$chunk, $newOffset, $overflow] = $client->tailProcessStdoutLog($name, $offset, 32_000);
Return {chunk, offset} as JSON to a small JS poller. Never call the full-read variant on a process that has been running for days.
Group-level bulk operations. Supervisor names are group:name. Build helpers that filter getAllProcessInfo() by $p['group'] === 'messenger' and act on each — there is no atomic "restart group" RPC, even with v2's restart helper.
Health/alerting integration. Feed statename !== 'RUNNING' for critical processes into your existing health endpoint or an app:supervisor:check cron that exits non-zero. With per-server config you can now cover both the app host and the worker host from one command — iterate servers, aggregate failures, exit non-zero if any is unhealthy.
UI integration. For a mutating admin page: firewall rule (ROLE_ADMIN), CSRF token on each action form, POST-only routes, a server-name parameter validated against the configured list, and a flash message built from the RPC fault code rather than a generic 500.
v2.0.0 is a breaking change. PHP 8.2+ and Symfony 6.4+ are hard requirements, and the servers: map is a different shape from v1's flat config. Half of every v1 blog post and README snippet will now be wrong for you. Diff Configuration.php against your existing supervisor.yaml during the upgrade, clear the cache, and re-run your status command per server. Plan the change as a code change, not a composer bump.
Verify hidden_processes before trusting it. It is new and single-contributor. Check whether it filters at the client boundary (all callers, including your health check) or only in UI helpers. If it hides a process from your health check, you will get green dashboards over a dead worker.
Per-server auth means per-server permissions. The web user needs read (and, for mutations, write) access to each socket or TCP endpoint. Test as the web user, per server: sudo -u www-data bin/console app:supervisor:list. Do not assume the credentials that work for default work for worker.
The bundle can't fix supervisord config for you. The two failure modes you will hit first are [rpcinterface:supervisor] missing (everything 404s at the RPC level) and unix_http_server socket permissions. Both are per-host problems; fixing one server does not fix the other.
Fault codes are the real API. startProcess on a running process returns ALREADY_STARTED, stopProcess on a stopped one returns NOT_RUNNING, and unknown names return BAD_NAME. Treat these as expected control flow, not exceptions to bubble to the user. Map them to idempotent no-ops or friendly messages. This matters more in v2 because the new restart helper may throw NOT_RUNNING on a process that was already down before the restart.
supervisor.restart restarts supervisord itself. If PHP-FPM or your web process is managed by supervisor, calling it from a web request is a self-inflicted outage. Restart individual programs/groups instead; use supervisor.restart only from an out-of-band shell. A v2 restart helper is easy to confuse with this — read what it actually calls.
Security of the RPC endpoint. inet_http_server has no TLS and Basic auth is optional. Do not expose port 9001 beyond 127.0.0.1 or a private network; anyone with access can start arbitrary configured programs. Per-server credentials are an improvement for separation of duties, not a substitute for network isolation. Put your Symfony admin routes behind the firewall and never ship the credentials via a public SUPERVISOR_* env on a frontend build.
No connection reuse. PHP has no persistent XML-RPC connection here — every call is a new socket, and multi-server config multiplies that. On pages with N actions you'll pay N handshakes; on a two-server dashboard you pay two round trips minimum. Batch reads (getAllProcessInfo() once per server, then filter) instead of looping calls.
Timeouts beat hangs. If supervisord is down, the default transport timeout can stall a request — and in v2 a down server should not take the whole page down with it. Set an explicit short timeout per server and catch transport exceptions to render a partially-degraded dashboard rather than a 500. Supervisor being down is exactly when you most want your status page to load.
Offset semantics in log tail are fiddly. tailProcessStdoutLog(name, offset, length) returns at most length bytes from offset; pass a negative offset to tail from the end, and note the overflow flag (the log was truncated by logfile_maxbytes). Persist the returned end-offset; don't guess it from strlen($chunk).
Config is compiled. In prod, changing config/packages/supervisor.yaml requires cache:clear — especially relevant in v2 where you may be adding servers and credentials. Half the "it ignored my host change" tickets are this.
Pin the version and read the client. Two releases in, one outside contributor, zero dependents: treat the new per-server plumbing, hidden_processes filtering, and the restart helper as unreviewed-by-others code. Read the diff from v1.0.6...v2.0.0 once, pin 2.0.0 exactly in composer.json, and keep it behind your own SupervisorManager facade so a replacement is a one-file change. MIT licence means you can fork and patch freely if the maintainer goes quiet.
Extension points. The useful ones are Symfony's own: decorate the client service (decorates:) per server, or define your own service with the same interface and override it per environment in services_prod.yaml — e.g. a stub client in test so functional tests never touch a real supervisord. With multiple servers configured in v2, decoration by service id is the cleanest place to add metrics, logging, or a circuit breaker around a flaky remote host.
How can I help you explore Laravel packages today?