symfony-opcua · v4.3.x
Docs · Using the client

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

  1. Resolve$opcua->connect($name) or connectTo(...) asks OpcuaManager.
  2. Open if needed — if the cache is empty for that name, the manager opens a new client.
  3. Use — calls go through; errors bubble as OpcUaException subclasses.
  4. Cache — the client stays on the manager for the lifetime of the manager instance.
  5. Closedisconnect(), 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):

  1. Looks up $name in connections.*. Throws InvalidArgumentException if missing.
  2. Returns the cached client if present.
  3. Otherwise constructs and caches a new client:
    • Direct mode: builds via ClientBuilder::create() and calls ->connect($endpoint).
    • Managed mode (daemon reachable + enabled = true): constructs a ManagedClient pointing at the daemon socket and opens a session there.

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 ServiceException with Bad_SessionIdInvalid / Bad_SessionNotActivated on 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:

text auto_retry
connections:
    default:
        endpoint:   '%env(OPCUA_ENDPOINT)%'
        auto_retry: 3      # 3 attempts before bubbling

Disconnect semantics

php disconnect surface
$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:

  1. 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.
  2. 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:

bash terminal
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

php src/Command/RecycleOpcuaCommand.php
#[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.timeout governs 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.

See Testing · Kernel tests.

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

text end-to-end
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.

Documentation