symfony-opcua · master
Docs · Using the client

Ad-hoc connections

connectTo($endpoint, $config) for endpoints that aren't in YAML. Fleet patterns, security caveats, and the persistence rules.

$opcua->connectTo($endpoint, $config, $as) opens a connection without a YAML entry. Use it when endpoints are discovered at runtime — fleet registries, per-tenant DB rows, user-provided URLs.

The basic shape

php connectTo
use PhpOpcua\SymfonyOpcua\OpcuaManager;

final class FleetService
{
    public function __construct(private OpcuaManager $opcua) {}

    public function speed(string $serial): ?float
    {
        $client = $this->opcua->connectTo(
            "opc.tcp://plc-{$serial}.factory.local:4840",
            [
                'security_policy'  => 'Basic256Sha256',
                'security_mode'    => 'SignAndEncrypt',
                'client_certificate' => '/etc/opcua/client.pem',
                'client_key'         => '/etc/opcua/client.key',
                'username'           => 'integrations',
                'password'           => $this->vaultPassword($serial),
                'timeout'            => 8.0,
            ],
            as: "fleet:{$serial}",   // optional cache name
        );

        return (float) $client->read('ns=2;s=Speed')->getValue();
    }
}

The config array accepts the same keys as a YAML connections.* entry. Missing keys take sensible defaults (security_policy: None, anonymous, default timeout).

When YAML, when ad-hoc

Scenario Use
3 PLCs, fixed layout YAML
Per-tenant servers, declared statically YAML
200-PLC fleet from a database Ad-hoc
User-provided endpoint in an admin tool Ad-hoc
Edge gateway connecting to whichever PLC is online Ad-hoc

The line: does the config live in code (YAML) or in data (database, registry, request)?

Caching identity

connectTo caches the resulting client under the key you pass as $as. When you omit $as, the fallback key is 'ad-hoc:' . $endpointUrlnot a hash of the config. Two calls with the same key return the same client:

php cache
$a = $opcua->connectTo($url, $config, as: 'fleet:plc-001');
$b = $opcua->connectTo($url, $config, as: 'fleet:plc-001');
// $a === $b within the same OpcuaManager instance

Use stable $as values across the request lifecycle to share a single client.

Fleet pattern with a registry

php src/Opcua/FleetReader.php
namespace App\Opcua;

use App\Repository\PlcUnitRepository;
use PhpOpcua\SymfonyOpcua\OpcuaManager;

final class FleetReader
{
    public function __construct(
        private OpcuaManager $opcua,
        private PlcUnitRepository $repo,
    ) {}

    public function readSpeed(string $serial): ?float
    {
        $unit = $this->repo->findOneBySerial($serial);
        if ($unit === null) {
            return null;
        }

        $client = $this->opcua->connectTo(
            $unit->getEndpoint(),
            [
                'security_policy'    => $unit->getSecurityPolicy(),
                'security_mode'      => $unit->getSecurityMode(),
                'client_certificate' => $unit->getCertPath(),
                'client_key'         => $unit->getKeyPath(),
                'username'           => $unit->getUsername(),
                'password'           => $unit->getDecryptedPassword(),
                'timeout'            => 8.0,
            ],
            as: "fleet:{$serial}",
        );

        return (float) $client->read('ns=2;s=Speed')->getValue();
    }
}

PlcUnit is an arbitrary Doctrine entity — see Recipes · Persistent tag history for a related Doctrine pattern.

Credentials in the config array

Anything in $config (passwords, certs paths) is in memory only — the bundle never logs config arrays. But:

  • Don't serialize a config array into a Messenger message. Send the registry key (serial number, tenant ID) and re-resolve inside the handler:
php messenger handler
#[AsMessageHandler]
final class SampleFleetHandler
{
    public function __construct(private FleetReader $reader) {}

    public function __invoke(SampleFleet $message): void
    {
        // Resolves credentials from DB at handle time
        $speed = $this->reader->readSpeed($message->serial);
        // ... persist
    }
}

The message holds $message->serial; the handler resolves credentials from the secured DB. No secret hits the queue.

User-provided endpoints

If the endpoint URL comes from a user (admin form, API parameter), validate before passing to connectTo():

php validation
use Symfony\Component\Validator\Constraints as Assert;

#[Assert\Regex(
    pattern: '/^opc\.tcp:\/\/[a-zA-Z0-9.-]+\.factory\.local:\d{1,5}$/',
    message: 'Endpoint must point inside *.factory.local',
)]
public string $endpoint;

Or a constraint class for richer validation. The bundle doesn't sandbox connections — if you pass an endpoint, the bundle tries to open it. Network-level egress filtering is the right place for the hard guarantee.

Disconnecting an ad-hoc connection

php disconnect ad-hoc
$opcua->disconnect('fleet:plc-001');
// or
$opcua->disconnectAll();

disconnect accepts the $as name (a string) — it does not accept a client instance. For an ad-hoc connection opened without $as, the cache key is 'ad-hoc:' . $endpointUrl; pass that string to release it.

Mixed style

Named and ad-hoc coexist on the same manager:

php mixed
$historian = $opcua->connect('historian');                 // named
$plc1      = $opcua->connectTo($url, $config, 'fleet:plc-001'); // ad-hoc

The cache holds both, the lifecycle rules are the same.

Pre-validating the endpoint

Symfony's OptionsResolver is a nice place to validate a config array before passing it to connectTo:

php OptionsResolver
use Symfony\Component\OptionsResolver\OptionsResolver;

$resolver = new OptionsResolver();
$resolver->setRequired(['endpoint']);
$resolver->setDefaults([
    'security_policy' => 'Basic256Sha256',
    'security_mode'   => 'SignAndEncrypt',
    'timeout'         => 8.0,
]);
$resolver->setAllowedValues('security_policy', [
    'None','Basic128Rsa15','Basic256','Basic256Sha256',
    'Aes128Sha256RsaOaep','Aes256Sha256RsaPss',
    'ECC_nistP256','ECC_nistP384',
    'ECC_brainpoolP256r1','ECC_brainpoolP384r1',
]);

$normalised = $resolver->resolve($rawConfig);

$client = $opcua->connectTo($url, $normalised, as: $key);

This catches typos at the application layer before the bundle tries to interpret the values.

Documentation