Connection lifecycle
When connections open, when they close, what survives across requests and worker processes. Lifecycle rules for FPM, Messenger consumers, scheduler workers.
How the bundle decides when to open, reuse, and close connections — across the runtime shapes a Symfony app lives in.
The lifecycle in five steps
- Resolve —
$opcua->connect($name)orconnectTo(...)asksOpcuaManager. - Open if needed — if the cache is empty for that name, the manager opens a new client.
- Use — calls go through; errors bubble as
OpcUaExceptionsubclasses. - Cache — the client stays on the manager for the lifetime of the manager instance.
- Close —
disconnect(), end of request, or worker shutdown.
Manager scope
OpcuaManager is bound as a container service (not a
singleton in the DI sense — Symfony 6+ services are
shared-by-default and instantiated lazily per kernel boot).
| Runtime | Container / kernel scope | Manager lifetime |
|---|---|---|
| PHP-FPM (one request) | One request | One request |
| Console command (one execution) | One process | One process |
Messenger consumer (messenger:consume) |
Until restart / memory cap | Long-lived |
| Scheduler worker | Until restart | Long-lived |
Functional tests (KernelTestCase) |
One test method | One test method |
Reuse mode (framework.session.handler_id HTTP/2 worker etc.) |
Worker boot | Long-lived |
The implication: in FPM the cache is per-request and connections always open fresh. In long-running workers the cache persists across messages — the desirable behaviour for high-frequency work.
Open semantics
$opcua->connect($name):
- Looks up
$nameinconnections.*. ThrowsInvalidArgumentExceptionif missing. - Returns the cached client if present.
- Otherwise constructs and caches a new client:
- Direct mode: builds via
ClientBuilder::create()and calls->connect($endpoint). - Managed mode (daemon reachable +
enabled = true): constructs aManagedClientpointing at the daemon socket and opens a session there.
- Direct mode: builds via
Opening is eager on the first call. After that, calls reuse
the cached client. Disconnects are explicit (disconnect) or
implicit (worker shutdown).
Use semantics
A live client holds the OPC UA session. Calls go over that session until:
- An explicit
$opcua->disconnect(). - A network error severs the session (
ConnectionException). - The server times out the session — surfacing as a
ServiceExceptionwithBad_SessionIdInvalid/Bad_SessionNotActivatedon the next request.
The bundle does not auto-reconnect transparently. A failed call surfaces as an exception; the next call attempts a fresh open if you didn't catch and act on the failure.
Configure auto_retry in YAML to get automatic retry on
ConnectionException:
connections:
default:
endpoint: '%env(OPCUA_ENDPOINT)%'
auto_retry: 3 # 3 attempts before bubbling
Disconnect semantics
$opcua->disconnect(); // default connection
$opcua->disconnect('plc-line-b'); // named connection
$opcua->disconnectAll(); // every cached connection
disconnect() accepts only ?string $name = null. To close an
ad-hoc connection, pass the $as key you supplied to
connectTo($endpoint, $config, $as).
After disconnect, the next connect(...) opens fresh. No
halfway state.
Error semantics
| Exception | Manager response |
|---|---|
ConnectionException |
Cache entry invalidated; next call reopens |
ServiceException (incl. Bad_SessionIdInvalid) |
Cache stays — server reached, request rejected |
SecurityException / HandshakeException on open |
Cache entry not populated; exception bubbles |
You don't need explicit disconnect() after a
ConnectionException — the manager handles it.
Long-running workers — when to recycle
In Messenger consumers, the manager can hold a connection for hours. Two reasons to proactively recycle:
- Server-side session timeout — OPC UA servers enforce a
MaxSessionTimeout(often 1-2 hours). The bundle only learns about it when the next call fails. - Resource drift — subscriptions may be invalidated server-side; cached metadata may grow stale.
Symfony-native ways to recycle:
Bound the worker's lifetime
messenger:consume supports --time-limit and --memory-limit:
php bin/console messenger:consume async --time-limit=3600 --memory-limit=512M
After an hour or 512 MB, the worker exits cleanly — Supervisor restarts it. All connections close gracefully.
Periodic disconnect command
#[AsCommand(name: 'app:opcua:recycle')]
final class RecycleOpcuaCommand extends Command
{
public function __construct(private readonly OpcuaManager $opcua)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->opcua->disconnectAll();
$output->writeln('All OPC UA connections recycled.');
return Command::SUCCESS;
}
}
Schedule it with Symfony Scheduler — see Console and scheduler.
Managed mode — daemon-held sessions
When session_manager.enabled = true and the daemon is reachable,
"open" means acquire a session from the daemon. The daemon
holds the actual TCP connection; the Symfony process holds an
IPC handle.
Consequences:
- Multiple Symfony processes (FPM workers, Messenger consumers)
can share the same daemon-held session — matched on
(endpoint + credentials + security). - Worker restarts don't close server-side sessions.
- The daemon's
session_manager.timeoutgoverns when an idle session is actually closed.
See Session manager · Overview.
Tests
In KernelTestCase, each test boots a fresh kernel by default
— so each test starts with a clean OpcuaManager. No leaks
between tests.
When you mock OpcuaManager via static::getContainer()->set(...),
the mock is per-test by construction.
Disconnect-on-shutdown
The bundle does not register a global
register_shutdown_function. Connections cached in
OpcuaManager::$connections are released when the process exits
(PHP's normal object teardown). If you want a deterministic
hook — for example, to log a clean shutdown — register one in
your application kernel (kernel.terminate listener) or in your
long-running command's terminate():
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
public function __invoke(TerminateEvent $event): void
{
$this->opcua->disconnectAll();
}
For Messenger consumers, the --time-limit / --memory-limit
exit path will trigger OpcuaManager's destructor and the
underlying client's disconnect() at process teardown.
Octane / FrankenPHP / RoadRunner-style runtimes
Long-running PHP runtimes (Octane is a Laravel thing, but FrankenPHP works the same way with Symfony) keep the kernel alive across requests. The connection cache persists across requests handled by the same worker — fast.
Don't mutate global config from a request handler under FrankenPHP/Swoole/RoadRunner. The mutation leaks into the next request handled by the same worker. Use named or ad-hoc connections instead — those are request-scoped.
See Recipes · Production deployment for the FrankenPHP topology.
A complete lifecycle picture
FPM worker boots [Manager cache: ∅]
↓
HTTP request → controller injects OpcuaManager
↓
$opcua->connect('plc') → open Client [cache: {plc → Client}]
↓
$client->read(...)
↓
return response
↓
PHP process exit (FPM end-of-request, worker `--time-limit`)
→ OpcuaManager destructed; cached clients released [cache: ∅]
For Messenger workers, the cache survives between messages — only worker restart resets it.
Where to read next
- Using builders — the fluent operation APIs.
- Messenger — async workers' lifecycle.
- Production supervisor — the daemon's lifecycle.