Named connections
Switch between configured connections by name with $opcua->connect("name"). Caching rules, dynamic routing, and the multi-tenant pattern.
The bundle resolves connection names against
connections.* in config/packages/php_opcua_symfony_opcua.yaml.
The pattern mirrors doctrine.connections and
messenger.transports.
The shape
php_opcua_symfony_opcua:
default: plc-line-a
connections:
plc-line-a:
endpoint: 'opc.tcp://plc-a.factory.local:4840'
plc-line-b:
endpoint: 'opc.tcp://plc-b.factory.local:4840'
historian:
endpoint: 'opc.tcp://historian.factory.local:4841'
Switching at the call site
use PhpOpcua\SymfonyOpcua\OpcuaManager;
final class PlantService
{
public function __construct(private OpcuaManager $opcua) {}
public function dailyReport(): array
{
// Default
$live = $this->opcua->connect()->read('ns=2;s=Output');
// Named
$lineB = $this->opcua->connect('plc-line-b')->read('ns=2;s=Output');
$hist = $this->opcua->connect('historian')->historyReadRaw(
'ns=4;s=DailyTotal',
new \DateTimeImmutable('-1 day'),
new \DateTimeImmutable(),
);
return ['live' => $live, 'lineB' => $lineB, 'history' => $hist];
}
}
connect() (no arg) is shorthand for connect($default).
connect() vs connection()
The bundle exposes two methods that look similar:
| Method | What it does |
|---|---|
$opcua->connection($name) |
Returns a client. In managed mode, doesn't open a session yet. |
$opcua->connect($name) |
Same — but in managed mode, opens the session immediately. |
For most application code, connect() is what you want.
connection() exists for cases where you want to configure the
managed client before opening (e.g., adjusting timeouts at
runtime — though most setters can be applied after connect()
too).
In direct mode, both behave identically — the client is already connected by the time it returns.
Method resolution
$opcua->read(...) (without connect()) also works — the
manager's __call proxy forwards to the default connection:
$dv = $opcua->read('ns=2;s=Speed');
// equivalent to
$dv = $opcua->connect()->read('ns=2;s=Speed');
The implicit form is convenient in scripts; the explicit form
(->connect()->read(...)) is more readable in controllers.
Caching
Each connection resolves to one client instance per OpcuaManager
instance. The first connect('plc-line-b') opens it; subsequent
calls return the cached client.
- HTTP request scope: typically the Symfony container is recreated per request, so the cache is per-request.
- Long-running workers (Messenger consumer, daemon): the cache persists for the worker's lifetime — desirable for performance.
See Connection lifecycle.
Disconnect
$opcua->disconnect('plc-line-b');
$opcua->disconnectAll();
After disconnect, the next connect('plc-line-b') opens a
fresh client. The bundle does not register a global shutdown
hook; cached clients are released by normal PHP object teardown
when the process exits. Explicit disconnectAll() is useful in
long-running workers if you need a deterministic point at which
to release sessions.
Dynamic routing
For multi-tenant or per-request routing, encapsulate the "which connection" decision in a service:
namespace App\Opcua;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
final class ConnectionResolver
{
public function __construct(private TokenStorageInterface $tokens) {}
public function forCurrentUser(): string
{
$user = $this->tokens->getToken()?->getUser();
if ($user instanceof \App\Entity\User) {
return 'plc-tenant-' . $user->getTenant()->getSlug();
}
return 'default';
}
}
…then in the controller:
final class TagsController
{
public function __construct(
private OpcuaManager $opcua,
private ConnectionResolver $resolver,
) {}
public function show(): JsonResponse
{
$name = $this->resolver->forCurrentUser();
$dv = $this->opcua->connect($name)->read('ns=2;s=Speed');
return new JsonResponse(['value' => $dv->getValue()]);
}
}
The resolver is one autowireable class; controllers stay slim.
Unknown connection names
$opcua->connect('typo');
// → InvalidArgumentException: OPC UA connection [typo] is not configured.
Validation happens at resolution time, not at config-load — so typos surface on the first call. In tests, exercise unhappy paths to catch them in CI.
Listing configured connections
php bin/console debug:config php_opcua_symfony_opcua connections
Or, from a service that already has OpcuaManager injected,
write a small helper that introspects the constructor $config:
// Inside a custom service that extends or wraps OpcuaManager:
$names = array_keys($this->configArray['connections'] ?? []);
The bundle does not export the merged config as a container
parameter — there is no %php_opcua_symfony_opcua.config% or
php_opcua_symfony_opcua_connections parameter. If you need it
in another service, copy the relevant slice into your own
parameters: block in services.yaml, or write a small
service that takes OpcuaManager and exposes the connection
names you need.
Naming conventions
Recap from Configuration · Connections:
- Lowercase, kebab-case (
plc-line-a,historian,plc-tenant-acme). - Role over instance (
plc-line-anotplc-192-168-1-10). - Include tenancy when relevant (
plc-tenant-acme).
Health endpoint for every connection
namespace App\Controller;
use PhpOpcua\SymfonyOpcua\OpcuaManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class HealthController extends AbstractController
{
public function __construct(
private OpcuaManager $opcua,
/** @var list<string> */
private array $connectionNames, // bound via services.yaml
) {}
#[Route('/health/opcua', methods: ['GET'])]
public function opcua(): JsonResponse
{
$results = [];
foreach ($this->connectionNames as $name) {
try {
$this->opcua->connect($name)->read('i=2256'); // ServerStatus
$results[$name] = 'ok';
} catch (\Throwable $e) {
$shortName = (new \ReflectionClass($e))->getShortName();
$results[$name] = 'unhealthy: ' . $shortName;
}
}
return $this->json($results);
}
}
Bind the list explicitly in services.yaml (the bundle doesn't
export a %php_opcua_symfony_opcua.config% parameter):
parameters:
app.opcua_connections: ['default', 'plc-line-a', 'historian']
services:
App\Controller\HealthController:
arguments:
$connectionNames: '%app.opcua_connections%'
class_basename() is a Laravel helper and is not available in
Symfony; the example above uses
(new \ReflectionClass($e))->getShortName() instead.
Where to read next
- Ad-hoc connections — when YAML can't enumerate every endpoint.
- Connection lifecycle — open, reuse, close semantics.