symfony-opcua
A symfony-bundle over php-opcua/opcua-client. Three things to remember:
- No Facade, no static helpers. Type-hint
OpcuaManagerorOpcUaClientInterfacein your constructor. Autowiring resolves them. OpcuaManager::connect($name)returns the underlyingOpcUaClientInterfacedirectly. Anythingopcua-clientcan do, the client returned can do.- Bundle uses
AbstractBundle+DefinitionConfigurator+loadExtension()(modern Symfony 6.1+ style). No XML/YAML service definitions to ship — wiring is code-driven.
What this package is for
| You want to | Use |
|---|---|
| Read / write OPC UA nodes from a controller, service, command | Inject OpcUaClientInterface (default conn) or OpcuaManager |
| Talk to multiple OPC UA servers | Named connections in YAML, $opcuaManager->connection('plc-1') |
| Connect to a runtime-discovered endpoint | $opcuaManager->connectTo($url, $configOverrides, as: 'cache-key') |
| Avoid one new TCP connection per HTTP request | Run php bin/console opcua:session as a supervised daemon |
| React to data changes, alarms, etc. via PSR-14 → Symfony events | Configure auto_publish: true + per-connection auto_connect: true, register #[AsEventListener] |
| Test code that touches OPC UA without a server | MockClient + self::getContainer()->set(OpcUaClientInterface::class, $mock) |
| Stream notifications to API Platform / EasyAdmin / Mercure | Standard Symfony event listeners on DataChangeReceived etc. |
Mental model
Controller/Service
└── $opcuaManager (autowired)
└── ->connection($name)
├── shouldUseSessionManager() == true?
│ └── ManagedClient (IPC → daemon → TCP → server)
│ └── TransportFactory picks UnixSocketTransport (Linux/macOS) or TcpLoopbackTransport (Windows)
└── shouldUseSessionManager() == false?
└── ClientBuilder::create()->...->connect() (direct TCP, new connection per call)
Both branches expose the same OpcUaClientInterface. Your code does not know which it has.
Quick start
composer require php-opcua/symfony-opcua
# If Flex is not enabled, register in config/bundles.php:
# PhpOpcua\SymfonyOpcua\PhpOpcuaSymfonyOpcuaBundle::class => ['all' => true],
cp vendor/php-opcua/symfony-opcua/config/opcua.yaml config/packages/php_opcua_symfony_opcua.yaml
# .env
OPCUA_ENDPOINT=opc.tcp://plc.example:4840
OPCUA_USERNAME=operator
OPCUA_PASSWORD=changeme
OPCUA_AUTH_TOKEN=long-random-secret
namespace App\Controller;
use PhpOpcua\Client\OpcUaClientInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class PlcController extends AbstractController
{
public function __construct(
private readonly OpcUaClientInterface $opcua,
) {}
#[Route('/server-state', methods: ['GET'])]
public function state(): JsonResponse
{
$state = $this->opcua->read('i=2259')->getValue();
return $this->json(['state' => $state, 'running' => $state === 0]);
}
}
The 3 patterns you will use 90% of the time
Pattern A — one-shot read/write (no daemon)
Best for HTTP requests, console commands, Messenger handlers. The bundle opens a TCP connection per call.
public function showServerState(OpcUaClientInterface $opcua): array
{
$state = $opcua->read('i=2259')->getValue();
return ['state' => $state, 'running' => $state === 0];
}
Pattern B — daemon-backed, transparent session reuse
When you run php bin/console opcua:session under systemd/supervisor, every client call goes through the daemon. Sessions are reused.
php bin/console opcua:session --timeout=600 --max-sessions=100
Application code is unchanged. Same $opcua->read(...), now backed by ManagedClient automatically.
Pattern C — auto_publish + Symfony event listeners
# config/packages/php_opcua_symfony_opcua.yaml
php_opcua_symfony_opcua:
session_manager:
auto_publish: true
connections:
plc-1:
endpoint: '%env(PLC1_ENDPOINT)%'
auto_connect: true
subscriptions:
- publishing_interval: 500.0
monitored_items:
- { node_id: 'ns=2;s=Temperature', client_handle: 1 }
namespace App\EventListener;
use PhpOpcua\Client\Event\DataChangeReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
final class SensorReadingListener
{
public function __invoke(DataChangeReceived $event): void
{
// Persist, broadcast, alert, etc.
}
}
Service-container surface
The bundle registers (all public so they show up in debug:container):
| Service | Class | What it is |
|---|---|---|
PhpOpcua\SymfonyOpcua\OpcuaManager |
OpcuaManager |
The connection manager |
opcua |
alias of OpcuaManager |
Backwards-compat alias |
PhpOpcua\Client\OpcUaClientInterface |
factory: $opcuaManager->connection(null) |
Default connection, autowire target |
opcua.command.session |
SessionCommand |
The console command (tagged console.command) |
php bin/console debug:container --tag=console.command | grep opcua
php bin/console debug:autowiring OpcUaClient
Available console commands
| Command | Purpose |
|---|---|
opcua:session |
Start the session-manager daemon. Options: --timeout, --cleanup-interval, --max-sessions, --socket-mode. Other settings (log channel, cache pool, auth token, cert dirs, auto-publish) come from session_manager YAML config. |
The command is registered automatically when the bundle is loaded.
Inherited methods (v4.4.0)
The bundle is a thin wrapper — every method on OpcUaClientInterface works through $opcuaManager->connect(). v4.4.0 added 21 new methods (Part 11 §6.9 HistoryUpdate, Part 5 §C.2/C.3 File Transfer, Part 13 Aggregates). They are reachable as ordinary client calls:
// HistoryUpdate
$opcua->historyInsertData('ns=2;s=Backfill', $dataValues);
// File transfer
$handle = $opcua->openFile($fileNode, OpenFileMode::Read);
$bytes = $opcua->readFile($fileNode, $handle, 65536);
$opcua->closeFile($fileNode, $handle);
// Aggregates
$bucketed = $opcua->historyAggregate('ns=2;s=Temp', $start, $end, 60000.0, AggregateFunction::Average);
When to follow the references
Progressive disclosure — only load what the task needs:
references/CONFIG.md— every YAML key, env vars, named connections, defaults, ServiceLocator wiring for cache pool / log channelreferences/SESSION_MANAGER.md— daemon command, systemd + supervisor configs, IPC endpoints, auto-publish lifecycle, mixed-version upgradereferences/EVENTS.md— all 56 PSR-14 events, payload shapes,#[AsEventListener]patterns, Messenger-friendly handlersreferences/INTEGRATIONS.md— Messenger, API Platform, EasyAdmin, Mercure, FrankenPHP/Octane-style worker mode, Doctrine, Monolog channelsreferences/SECURITY.md— policies, modes, trust store, cert auto-generation, X.509 user auth, env-driven configreferences/TESTING.md— Pest setup, MockClient in test container,self::getContainer()->set(...),KernelTestCasepatternsreferences/PITFALLS.md— common gotchas: services as request-scoped, daemon vs worker, mixed daemon versionsassets/recipes.md— copy-pasteable end-to-end recipes for the most common tasks
Idiomatic patterns
- Type-hint
OpcUaClientInterfacefor the default connection. Type-hintOpcuaManagerwhen you need to switch between named connections. - YAML defines wiring, env defines secrets.
%env(OPCUA_PASSWORD)%in YAML;OPCUA_PASSWORD=...in.env.local. - Use named connections per server. Don't string-build endpoints in code. Define
plc-1,plc-2,historianin YAML. - Don't disconnect in HTTP requests when the daemon is enabled. Let the session manager handle lifecycle.
- For auto-published subscriptions, never call
publish()yourself. Returnsauto_publish_activeerror. - Bind Messenger handlers to a dedicated transport for OPC UA event work so a slow PLC doesn't back-pressure the main queue.
- Use a dedicated Monolog channel
opcua. Configurelog_channel: opcuainsession_manager. Keeps OPC UA noise out of the main log. - Run the daemon under a dedicated UID with
socket_mode: 0600. The web user must be in the daemon's group (or share UID). - In FrankenPHP / Swoole, the bundle's
OpcuaManageris already long-lived per worker — just enable the daemon for transparent session reuse across requests. - Override
OpcUaClientInterfacein test containers for hermetic tests; bypass the daemon entirely.
Exit codes (opcua:session)
| Code | Meaning |
|---|---|
| 0 | Daemon exited cleanly (SIGTERM/SIGINT) |
| 1 | Configuration error (invalid socket_path, missing required key) |
| 2 | Bind failure (port in use, socket-path EACCES, parent dir missing) |
| 3 | Runtime error inside daemon loop (logged via Monolog channel) |
Non-zero exits should trigger Restart=on-failure in systemd / autorestart=true in supervisor.
Versioning
The Symfony bundle versions lock-step with php-opcua/opcua-client and php-opcua/opcua-session-manager. Upgrade order:
- Daemon first. Stop
opcua:session,composer update, restart. - Application second.
composer update php-opcua/symfony-opcua.
Reverse order → BadMethodCallException when a v4.4 application calls a v4.4-only method against a v4.3 daemon.
What this skill does NOT cover
- The raw OPC UA protocol — see the
opcua-clientskill. - The session-manager daemon's IPC protocol — see the
opcua-session-managerskill. - CLI usage — see the
opcua-cliskill. - Companion-spec types (DI, IA, AutoID, etc.) — see the
opcua-client-nodesetskill. - Laravel patterns — see the
laravel-opcuaskill (mirror of this one for Laravel).