DownloadOPC UA PHP Client ? AI Skills Reference
> Task-oriented recipes for AI coding assistants. Feed this file to your AI (Claude, Cursor, Copilot, GPT, etc.) so it knows how to use the php-opcua ecosystem correctly.
How to use this file
Add this file to your AI assistant's context:
- Claude Code: copy to your project's CLAUDE.md or reference via --add-file
- Cursor: add to .cursor/rules/ or .cursorrules
- GitHub Copilot: add to .github/copilot-instructions.md
- Other tools: paste into system prompt or project context
Ecosystem Overview
| Package | Install | Purpose |
|---------|---------|---------|
| php-opcua/opcua-client | composer require php-opcua/opcua-client | Core OPC UA client ? required |
| php-opcua/opcua-session-manager | composer require php-opcua/opcua-session-manager | Session persistence daemon ? optional, for multi-request apps |
| php-opcua/laravel-opcua | composer require php-opcua/laravel-opcua | Laravel integration ? optional, provides Facade + config |
| php-opcua/opcua-cli | composer require php-opcua/opcua-cli | CLI tool ? optional, for terminal operations |
| php-opcua/opcua-client-nodeset | composer require php-opcua/opcua-client-nodeset | Pre-built OPC UA companion types ? optional |
Requirements: PHP >= 8.2, ext-openssl. No other extensions needed.
Skill: Connect to an OPC UA Server
When to use
The user wants to connect to a PLC, SCADA system, sensor, or any OPC UA-compliant device.
Code
use PhpOpcua\Client\ClientBuilder;
// Minimal ? no security
$client = ClientBuilder::create()
->connect('opc.tcp://192.168.1.100:4840');
// ... do operations ...
$client->disconnect();
Important rules
-
Always call `disconnect()` when done (or use try/finally)
-
The endpoint format is always `opc.tcp://host:port`
-
Default OPC UA port is 4840
-
`ClientBuilder::create()` is the only entry point ? never instantiate `Client` directly
-
All configuration must happen BEFORE `connect()` ? the client is immutable after connection
Skill: Connect with Security and Authentication
When to use
The user needs encrypted connections, username/password, or certificate-based authentication.
Code
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Security\SecurityPolicy;
use PhpOpcua\Client\Security\SecurityMode;
// Username/password with encryption
$client = ClientBuilder::create()
->setSecurityPolicy(SecurityPolicy::Basic256Sha256)
->setSecurityMode(SecurityMode::SignAndEncrypt)
->setUserCredentials('operator', 'secret')
->connect('opc.tcp://192.168.1.100:4840');
// With explicit client certificate
$client = ClientBuilder::create()
->setSecurityPolicy(SecurityPolicy::Basic256Sha256)
->setSecurityMode(SecurityMode::SignAndEncrypt)
->setClientCertificate('/certs/client.pem', '/certs/client.key', '/certs/ca.pem')
->setUserCredentials('operator', 'secret')
->connect('opc.tcp://192.168.1.100:4840');
// X.509 certificate authentication (no password)
$client = ClientBuilder::create()
->setSecurityPolicy(SecurityPolicy::Basic256Sha256)
->setSecurityMode(SecurityMode::SignAndEncrypt)
->setClientCertificate('/certs/client.pem', '/certs/client.key')
->setUserCertificate('/certs/user.pem', '/certs/user.key')
->connect('opc.tcp://192.168.1.100:4840');
// ECC security ? NIST (auto-generates ECC certificate if none provided)
$client = ClientBuilder::create()
->setSecurityPolicy(SecurityPolicy::EccNistP256)
->setSecurityMode(SecurityMode::SignAndEncrypt)
->setUserCredentials('admin', 'admin123')
->connect('opc.tcp://192.168.1.100:4848');
// ECC security ? Brainpool (European alternative to NIST)
$client = ClientBuilder::create()
->setSecurityPolicy(SecurityPolicy::EccBrainpoolP256r1)
->setSecurityMode(SecurityMode::SignAndEncrypt)
->connect('opc.tcp://192.168.1.100:4849');
Important rules
-
If `setClientCertificate()` is omitted but security policy/mode are set, a self-signed cert is auto-generated in memory (RSA for RSA policies, ECC for ECC policies ? good for testing, not production)
-
RSA policies: `None`, `Basic128Rsa15`, `Basic256`, `Basic256Sha256`, `Aes128Sha256RsaOaep`, `Aes256Sha256RsaPss`
-
ECC policies: `EccNistP256`, `EccNistP384`, `EccBrainpoolP256r1`, `EccBrainpoolP384r1`
-
Available modes: `None`, `Sign`, `SignAndEncrypt`
-
For production use `Basic256Sha256`, `Aes256Sha256RsaPss`, or any ECC policy. Choose NIST for interoperability, Brainpool for EU regulatory compliance (BSI TR-03116)
-
Auth methods are: anonymous (default), username/password (`setUserCredentials`), X.509 certificate (`setUserCertificate`)
-
ECC policies use ECDH key agreement instead of RSA encryption; password authentication uses EccEncryptedSecret protocol automatically
Skill: Read Values from a Server
When to use
The user wants to read process variables ? temperatures, pressures, motor speeds, counters, status values, etc.
Code
use PhpOpcua\Client\Types\NodeId;
// Read a single value ? string NodeId format
$dv = $client->read('i=2259'); // ServerState
$dv = $client->read('ns=2;i=1001'); // Namespace 2, numeric ID
$dv = $client->read('ns=2;s=Temperature'); // Namespace 2, string ID
// Access the result
echo $dv->getValue(); // The actual value (unwrapped from Variant)
echo $dv->statusCode; // 0 = Good
echo $dv->sourceTimestamp; // DateTimeImmutable or null
// Read multiple values ? fluent builder (preferred)
$results = $client->readMulti()
->node('i=2259')->value()
->node('ns=2;i=1001')->displayName()
->node('ns=2;s=Temperature')->value()
->execute();
foreach ($results as $dv) {
echo $dv->getValue() . "\n";
}
// Read multiple values ? array syntax
$results = $client->readMulti([
['nodeId' => 'i=2259'],
['nodeId' => 'ns=2;i=1001'],
]);
Important rules
-
All methods accept string NodeIds (`'i=2259'`, `'ns=2;s=MyNode'`) OR `NodeId` objects ? use strings for simplicity
-
`getValue()` unwraps the Variant and returns the PHP-native value
-
Always check `$dv->statusCode` ? `0` means Good, non-zero means the read failed or the value is uncertain
-
Use `StatusCode::isGood($dv->statusCode)` for proper status checking
-
Common well-known nodes: `i=2259` (ServerState), `i=2258` (CurrentTime), `i=2256` (ServerStatus), `i=85` (Objects folder)
-
Use `getServerBuildInfo()` to read all server build metadata in one call (see Server BuildInfo skill below)
Skill: Server BuildInfo
When to use
The user wants to identify the OPC UA server ? product name, manufacturer, version, build number, or build date.
Code
// All at once ? single readMulti() call, returns BuildInfo DTO
$info = $client->getServerBuildInfo();
echo $info->productName; // ?string (ns=0;i=2262)
echo $info->manufacturerName; // ?string (ns=0;i=2263)
echo $info->softwareVersion; // ?string (ns=0;i=2264)
echo $info->buildNumber; // ?string (ns=0;i=2265)
echo $info->buildDate; // ?DateTimeImmutable (ns=0;i=2266)
// Individual fields (one server read each)
$client->getServerProductName(); // ?string
$client->getServerManufacturerName(); // ?string
$client->getServerSoftwareVersion(); // ?string
$client->getServerBuildNumber(); // ?string
$client->getServerBuildDate(); // ?DateTimeImmutable
Important rules
-
These nodes are mandatory on every OPC UA server ? they always exist
-
`getServerBuildInfo()` is more efficient than calling individual methods ? it reads all 5 nodes in a single `readMulti()` request
-
Returns `null` for any field the server does not populate
-
`BuildInfo` is a `readonly` DTO with 5 public properties
Skill: Add and Remove Nodes at Runtime
When to use
The user wants to dynamically create or delete nodes and references in the server's address space ? e.g., creating temporary variables, folders, or configuring custom structures at runtime.
Code
use PhpOpcua\Client\Types\NodeClass;
use PhpOpcua\Client\Types\QualifiedName;
use PhpOpcua\Client\Types\NodeId;
use PhpOpcua\Client\Types\StatusCode;
// Add a Variable node
$results = $client->addNodes([
[
'parentNodeId' => 'i=85', // Objects folder
'referenceTypeId' => 'i=35', // Organizes
'requestedNewNodeId' => 'ns=2;s=MyVariable',
'browseName' => new QualifiedName(2, 'MyVariable'),
'nodeClass' => NodeClass::Variable,
'typeDefinition' => 'i=63', // BaseDataVariableType
'dataType' => NodeId::numeric(0, 6), // Int32
'accessLevel' => 3, // CurrentRead | CurrentWrite
],
]);
if (StatusCode::isGood($results[0]->statusCode)) {
echo "Created: {$results[0]->addedNodeId}\n";
}
// Delete nodes
$statusCodes = $client->deleteNodes([
['nodeId' => 'ns=2;s=MyVariable', 'deleteTargetReferences' => true],
]);
// Add a reference between existing nodes
$statusCodes = $client->addReferences([
[
'sourceNodeId' => 'ns=2;s=FolderA',
'referenceTypeId' => NodeId::numeric(0, 35), // Organizes
'isForward' => true,
'targetNodeId' => 'ns=2;s=NodeB',
'targetNodeClass' => NodeClass::Variable,
],
]);
// Delete a reference
$statusCodes = $client->deleteReferences([
[
'sourceNodeId' => 'ns=2;s=FolderA',
'referenceTypeId' => NodeId::numeric(0, 35),
'isForward' => true,
'targetNodeId' => 'ns=2;s=NodeB',
'deleteBidirectional' => true,
],
]);
Important rules
-
NodeManagementModule is in ClientBuilder::defaultModules() ? no opt-in needed. The builder does not probe the server at connect time.
-
Not all servers implement node management. Servers that do not (e.g. UA-.NETStandard) reply with a top-level `ServiceFault` carrying `BadServiceUnsupported (0x800B0000)`. The client surfaces this as `Exception\ServiceUnsupportedException` (subclass of `ServiceException`) on the first call to `addNodes()` / `deleteNodes()` / `addReferences()` / `deleteReferences()`. Catch `ServiceUnsupportedException` if you want a capability-specific fallback; existing `ServiceException` handlers still match.
-
All 8 node classes are supported: Object, Variable, Method, ObjectType, VariableType, ReferenceType, DataType, View.
-
Class-specific attributes (e.g., `dataType`, `accessLevel` for Variable; `executable` for Method) are encoded automatically.
-
`addNodes()` returns `AddNodesResult[]` ? each result has `statusCode` and `addedNodeId`.
-
`deleteNodes()`, `addReferences()`, `deleteReferences()` return `int[]` status codes.
-
If `displayName` is omitted, the browse name is used automatically.
-
Always clean up created nodes in tests/temporary scenarios.
-
String NodeIds work everywhere: `'i=85'`, `'ns=2;s=MyNode'`.
Skill: Write Values to a Server
When to use
The user wants to write setpoints, commands, or any value to a PLC or OPC UA server.
Code
use PhpOpcua\Client\Types\BuiltinType;
use PhpOpcua\Client\Types\StatusCode;
// Auto-detect type (recommended) ? reads the node's DataType first, caches it
$status = $client->write('ns=2;i=1001', 42);
// Explicit type ? when you know the type and want to skip the extra read
$status = $client->write('ns=2;i=1001', 42, BuiltinType::Int32);
// Check result
if (StatusCode::isGood($status)) {
echo "Write successful";
}
// Write multiple values ? fluent builder
$results = $client->writeMulti()
->node('ns=2;i=1001')->int32(42)
->node('ns=2;i=1002')->double(3.14)
->node('ns=2;s=Label')->string('active')
->execute();
// Write multiple values ? auto-detect types
$results = $client->writeMulti()
->node('ns=2;i=1001')->value(42)
->node('ns=2;i=1002')->value(3.14)
->execute();
Important rules
-
Auto-detect (`write($nodeId, $value)` without type) is the default and recommended approach ? it reads the node's DataType, caches it, and writes with the correct type
-
If you know the type, pass it explicitly to avoid the extra read: `write($nodeId, $value, BuiltinType::Int32)`
-
Common BuiltinType values: `Boolean`, `Int16`, `Int32`, `Int64`, `UInt16`, `UInt32`, `UInt64`, `Float`, `Double`, `String`, `DateTime`, `ByteString`
-
The return value is a status code integer ? use `StatusCode::isGood()` to check
-
Write failures typically return `StatusCode::BadNotWritable` or `StatusCode::BadTypeMismatch`
Skill: Browse the Address Space
When to use
The user wants to discover what nodes, variables, objects, or methods are available on a server.
Code
use PhpOpcua\Client\Types\NodeClass;
// Browse a folder
$refs = $client->browse('i=85'); // Objects folder
foreach ($refs as $ref) {
echo "{$ref->displayName} ({$ref->nodeId}) [{$ref->nodeClass->name}]\n";
}
// Browse with node class filter ? only variables
$refs = $client->browse('i=85', nodeClasses: [NodeClass::Variable]);
// Browse with node class filter ? only objects and variables
$refs = $client->browse('i=85', nodeClasses: [NodeClass::Object, NodeClass::Variable]);
// Browse all (automatic continuation point handling)
$allRefs = $client->browseAll('i=85');
// Recursive browse ? returns a tree
$tree = $client->browseRecursive('i=85', maxDepth: 3);
foreach ($tree as $node) {
echo $node->reference->displayName . "\n";
foreach ($node->children as $child) {
echo " " . $child->reference->displayName . "\n";
}
}
// Resolve a human-readable path to a NodeId
$nodeId = $client->resolveNodeId('/Objects/MyPLC/Temperature');
$value = $client->read($nodeId);
Important rules
-
`i=85` is the Objects folder ? the standard starting point for browsing
-
`i=84` is the Root folder (parent of Objects, Types, Views)
-
`browse()` returns `ReferenceDescription[]` with properties: `nodeId`, `displayName`, `browseName`, `nodeClass`, `isForward`, `referenceTypeId`, `typeDefinition`
-
`browseRecursive()` returns `BrowseNode[]` ? each has `reference` and `children`
-
Browse results are cached by default ? use `useCache: false` to bypass
-
`resolveNodeId()` translates paths like `/Objects/Server/ServerStatus` to NodeId objects
Skill: Call Methods on the Server
When to use
The user wants to invoke OPC UA methods ? trigger operations, run diagnostics, execute PLC commands.
Code
use PhpOpcua\Client\Types\Variant;
use PhpOpcua\Client\Types\BuiltinType;
use PhpOpcua\Client\Types\StatusCode;
$result = $client->call(
'i=2253', // Parent object NodeId (where the method lives)
'i=11492', // Method NodeId
[ // Input arguments as Variant array
new Variant(BuiltinType::UInt32, 1),
],
);
if (StatusCode::isGood($result->statusCode)) {
echo $result->outputArguments[0]->value; // Access output arguments
}
// Method with multiple arguments
$result = $client->call(
'ns=2;i=100',
'ns=2;i=200',
[
new Variant(BuiltinType::Double, 3.0),
new Variant(BuiltinType::Double, 4.0),
],
);
Important rules
-
The first argument is the parent object NodeId, the second is the method NodeId
-
Input arguments must be wrapped in `Variant` objects with explicit types
-
`$result` is a `CallResult` DTO with `statusCode`, `inputArgumentResults`, and `outputArguments`
-
Output arguments are `Variant` objects ? access values via `->value`
Skill: Subscribe to Real-Time Data Changes
When to use
The user wants to be notified when sensor values change, monitor variables in real time, or watch for events.
Code
use PhpOpcua\Client\Types\NodeId;
// 1. Create a subscription
$sub = $client->createSubscription(publishingInterval: 500.0); // 500ms
// 2. Add monitored items
$monitored = $client->createMonitoredItems($sub->subscriptionId, [
['nodeId' => 'ns=2;i=1001', 'clientHandle' => 1],
['nodeId' => 'ns=2;i=1002', 'clientHandle' => 2],
]);
// Or use the fluent builder
$monitored = $client->createMonitoredItems($sub->subscriptionId)
->item('ns=2;i=1001', clientHandle: 1)
->item('ns=2;i=1002', clientHandle: 2)
->execute();
// 3. Poll for notifications
$response = $client->publish();
foreach ($response->notifications as $notif) {
echo "Handle {$notif['clientHandle']}: {$notif['dataValue']->getValue()}\n";
}
// 4. Clean up
$client->deleteSubscription($sub->subscriptionId);
Important rules
-
Subscriptions require a polling loop ? call `publish()` repeatedly to receive notifications
-
In standard PHP (request/response), the subscription dies with the process. Use `opcua-session-manager` for persistent subscriptions across requests
-
`createSubscription()` returns a `SubscriptionResult` with `subscriptionId`
-
`createMonitoredItems()` returns `MonitoredItemResult[]` with `monitoredItemId`
-
Always `deleteSubscription()` before disconnecting to clean up server resources
-
`publishingInterval` is in milliseconds
Skill: Read Historical Data
When to use
The user wants to pull past values ? trend analysis, logs, aggregated statistics from OPC UA historians.
Code
// Raw historical values
$values = $client->historyReadRaw(
'ns=2;i=1001',
startTime: new \DateTimeImmutable('-1 hour'),
endTime: new \DateTimeImmutable(),
);
foreach ($values as $dv) {
echo "[{$dv->sourceTimestamp->format('H:i:s')}] {$dv->getValue()}\n";
}
// Aggregated (processed) ? e.g., average over 1-minute intervals
$values = $client->historyReadProcessed(
'ns=2;i=1001',
startTime: new \DateTimeImmutable('-1 hour'),
endTime: new \DateTimeImmutable(),
processingInterval: 60000.0, // 60 seconds in ms
aggregateType: NodeId::numeric(0, 2341), // Average
);
// Values at specific timestamps
$values = $client->historyReadAtTime('ns=2;i=1001', [
new \DateTimeImmutable('-30 minutes'),
new \DateTimeImmutable('-15 minutes'),
new \DateTimeImmutable('now'),
]);
Important rules
-
Not all OPC UA servers support history ? the server must have a historian configured
-
Common aggregate type NodeIds: `2341` (Average), `2342` (Interpolative), `2346` (Minimum), `2347` (Maximum), `2352` (Count)
-
`processingInterval` is in milliseconds
-
Returns `DataValue[]` ? same format as regular reads
Skill: Handle Server Certificate Trust
When to use
The user connects to a server for the first time and needs to handle certificate trust, or wants TOFU (Trust On First Use).
Code
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\TrustStore\FileTrustStore;
use PhpOpcua\Client\TrustStore\TrustPolicy;
use PhpOpcua\Client\Exception\UntrustedCertificateException;
// Auto-accept on first use (TOFU) ? good for development
$client = ClientBuilder::create()
->setTrustStore(new FileTrustStore()) // ~/.opcua/trusted/
->autoAccept(true)
->connect('opc.tcp://192.168.1.100:4840');
// Strict trust ? reject unknown certificates
$client = ClientBuilder::create()
->setTrustStore(new FileTrustStore('/var/opcua/trust'))
->setTrustPolicy(TrustPolicy::Fingerprint)
->connect('opc.tcp://192.168.1.100:4840');
// throws UntrustedCertificateException if cert not in store
// Handle untrusted certificate interactively
try {
$client = ClientBuilder::create()
->setTrustStore(new FileTrustStore())
->setTrustPolicy(TrustPolicy::Fingerprint)
->connect('opc.tcp://192.168.1.100:4840');
} catch (UntrustedCertificateException $e) {
echo "Fingerprint: " . $e->getFingerprint() . "\n";
// Trust it programmatically:
$client->trustCertificate($e->getCertificate());
}
// Disable trust validation entirely
$client = ClientBuilder::create()
->setTrustPolicy(null)
->connect('opc.tcp://192.168.1.100:4840');
Important rules
-
Trust policies: `Fingerprint` (match SHA-256 only), `FingerprintAndExpiry` (+ expiry check), `Full` (full CA chain validation)
-
`setTrustPolicy(null)` disables validation ? this is the default (no trust store)
-
TOFU (`autoAccept(true)`) is vulnerable on first connection ? use for dev only
-
`autoAccept(true, force: true)` also accepts changed certificates ? very insecure
Skill: Keep Sessions Alive Across PHP Requests
When to use
The user has a web application (Laravel, Symfony, plain PHP) and wants to avoid the 50-200ms OPC UA handshake on every HTTP request.
Prerequisites
composer require php-opcua/opcua-session-manager
Code
# 1. Start the daemon (separate terminal or Supervisor/systemd)
php vendor/bin/opcua-session-manager
use PhpOpcua\SessionManager\Client\ManagedClient;
// 2. Use ManagedClient instead of ClientBuilder ? same API
$client = new ManagedClient();
$client->connect('opc.tcp://192.168.1.100:4840');
$value = $client->read('i=2259');
echo $value->getValue();
// Do NOT disconnect if you want the session to persist!
// The daemon keeps it alive.
// In the next request ? the session is automatically reused (~5ms instead of ~155ms)
$client = new ManagedClient();
$client->connect('opc.tcp://192.168.1.100:4840');
$client->wasSessionReused(); // true
Important rules
-
`ManagedClient` implements the same `OpcUaClientInterface` as the direct `Client` ? it's a drop-in replacement
-
The daemon creates a Unix socket at `/tmp/opcua-session-manager.sock` by default
-
If the daemon is not running, `ManagedClient` will throw a `DaemonException` ? there is no automatic fallback to direct connections (use `laravel-opcua` for that)
-
Sessions expire after `--timeout` seconds of inactivity (default: 600)
-
Call `disconnect()` only when you want to explicitly close the session
-
For production use Supervisor or systemd to keep the daemon running
-
Use `--auth-token` or `OPCUA_AUTH_TOKEN` env var for IPC security
Skill: Integrate with Laravel
When to use
The user has a Laravel application and wants OPC UA with Facade, .env config, named connections, and automatic session manager integration.
Prerequisites
composer require php-opcua/laravel-opcua
php artisan vendor:publish --tag=opcua-config
Code
# .env
OPCUA_ENDPOINT=opc.tcp://192.168.1.100:4840
use PhpOpcua\LaravelOpcua\Facades\Opcua;
// Connect and read
$client = Opcua::connect();
$value = $client->read('i=2259');
echo $value->getValue();
$client->disconnect();
// Named connections (defined in config/opcua.php)
$plc1 = Opcua::connect('plc-line-1');
$plc2 = Opcua::connect('plc-line-2');
Opcua::disconnectAll();
// Ad-hoc connection
$client = Opcua::connectTo('opc.tcp://10.0.0.50:4840', [
'username' => 'operator',
'password' => 'secret',
]);
// Session manager integration ? automatic
// If the daemon is running, connections persist across requests.
// If not, direct connections are used. Zero code changes.
php artisan opcua:session # start daemon
Important rules
-
The `Opcua` Facade proxies to `OpcuaManager` ? it manages multiple named connections
-
Config follows Laravel conventions: `config/opcua.php` with `.env` variables
-
The package auto-detects the session manager daemon ? if its socket exists, `ManagedClient` is used; otherwise, direct `Client`
-
Named connections work like `config/database.php` ? define multiple servers in `connections` array
-
Logger and cache are automatically injected from Laravel's service container
Skill: Test OPC UA Code Without a Real Server
When to use
The user wants to unit test code that uses OPC UA without a physical PLC or server.
Code
use PhpOpcua\Client\Testing\MockClient;
use PhpOpcua\Client\Types\DataValue;
use PhpOpcua\Client\Types\StatusCode;
// Create a mock client
$mock = MockClient::create();
// Register handlers
$mock->onRead(function ($nodeId) {
return match ((string) $nodeId) {
'ns=2;s=Temperature' => DataValue::ofDouble(23.5),
'i=2259' => DataValue::ofInt32(0),
default => DataValue::bad(StatusCode::BadNodeIdUnknown),
};
});
$mock->onWrite(function ($nodeId, $value) {
return StatusCode::Good;
});
$mock->onBrowse(function ($nodeId) {
return []; // empty reference list
});
// Use the same API as a real client
$value = $mock->read('ns=2;s=Temperature');
echo $value->getValue(); // 23.5
// Assert calls
echo $mock->callCount('read'); // 1
$mock->resetCalls();
DataValue factory methods
DataValue::ofBoolean(true);
DataValue::ofInt32(42);
DataValue::ofUInt32(100);
DataValue::ofDouble(3.14);
DataValue::ofFloat(2.5);
DataValue::ofString('hello');
DataValue::of($value, BuiltinType::Int32);
DataValue::bad(StatusCode::BadNodeIdUnknown); // bad status
Important rules
-
`MockClient` implements `OpcUaClientInterface` ? it can be injected anywhere a real client is expected
-
Handlers: `onRead()`, `onWrite()`, `onBrowse()`, `onCall()`, `onResolveNodeId()`, `onGetEndpoints()`
-
Call tracking: `getCalls()`, `getCallsFor($method)`, `callCount($method)`, `resetCalls()`
-
Fluent builders (`readMulti()`, `writeMulti()`) work with MockClient
Skill: Discover Custom Data Types
When to use
The user reads structured values from a server and gets raw bytes or arrays instead of typed data.
Code
// Auto-discover all custom types from the server
$client->discoverDataTypes();
// Now structured reads return decoded values
$point = $client->read('ns=2;s=MyPoint')->getValue();
// ['x' => 1.5, 'y' => 2.5, 'z' => 3.5]
// Or use pre-built companion types (e.g., Robotics, DI, Machinery)
// composer require php-opcua/opcua-client-nodeset
use PhpOpcua\Nodeset\Robotics\RoboticsRegistrar;
$client = ClientBuilder::create()
->loadGeneratedTypes(new RoboticsRegistrar())
->connect('opc.tcp://192.168.1.100:4840');
Important rules
-
Call `discoverDataTypes()` AFTER connecting ? it queries the server's type system
-
For custom codecs, implement `ExtensionObjectCodec` and register via `ExtensionObjectRepository`
-
Each client has its own isolated codec registry ? no global state
-
`opcua-client-nodeset` provides pre-generated types for 51 OPC Foundation companion specs
Skill: Add Logging and Caching
When to use
The user wants to debug OPC UA communication or optimize performance with caching.
Code
use PhpOpcua\Client\ClientBuilder;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
// Add PSR-3 logging
$logger = new Logger('opcua');
$logger->pushHandler(new StreamHandler('php://stderr', Logger::DEBUG));
$client = ClientBuilder::create()
->setLogger($logger)
->connect('opc.tcp://localhost:4840');
// Logs: handshake, secure channel, reads, retries, errors...
// Cache configuration
use PhpOpcua\Client\Cache\FileCache;
$client = ClientBuilder::create()
->setCache(new FileCache('/tmp/opcua-cache', 600)) // 600s TTL
->connect('opc.tcp://localhost:4840');
// Per-call cache control
$refs = $client->browse('i=85', useCache: false); // skip cache
$client->invalidateCache('i=85'); // clear one node
$client->flushCache(); // clear all
Important rules
-
Any PSR-3 logger works (Monolog, Laravel's logger, etc.)
-
Without a logger, a `NullLogger` is used (zero overhead)
-
Browse, resolve, endpoint, and discovery results are cached by default (`InMemoryCache`, 300s TTL)
-
Any PSR-16 cache driver works (`InMemoryCache`, `FileCache`, Laravel Cache, Redis)
-
Read values are NEVER cached ? only metadata and browse results
-
`setReadMetadataCache(true)` enables caching of node metadata (DataType, DisplayName) ? not values
Skill: Listen to OPC UA Events (PSR-14)
When to use
The user wants to react to OPC UA lifecycle events ? log connections, track reads/writes, handle alarms.
Code
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Event\DataChangeReceived;
use PhpOpcua\Client\Event\AlarmActivated;
use PhpOpcua\Client\Event\Connected;
$client = ClientBuilder::create()
->setEventDispatcher($yourPsr14Dispatcher)
->connect('opc.tcp://localhost:4840');
// In your listener:
class HandleDataChange {
public function __invoke(DataChangeReceived $event): void {
echo "Value changed on sub {$event->subscriptionId}: "
. $event->dataValue->getValue() . "\n";
}
}
// Laravel integration:
// In EventServiceProvider:
protected $listen = [
\PhpOpcua\Client\Event\AfterRead::class => [LogOpcuaReads::class],
\PhpOpcua\Client\Event\AlarmActivated::class => [HandleAlarm::class],
];
Important rules
-
47 event types covering: connection, session, subscription, data change, alarms, read/write, browse, cache, retry, trust store
-
Zero overhead when no dispatcher is set (`NullEventDispatcher`)
-
All events are readonly DTOs with a `$client` property
-
Any PSR-14 `EventDispatcherInterface` works
Skill: Configure Timeout, Retry, and Batching
When to use
The user needs to tune connection behavior ? slow networks, unreliable connections, large read/write operations.
Code
$client = ClientBuilder::create()
->setTimeout(10.0) // 10 seconds network timeout
->setAutoRetry(3) // retry up to 3 times on failure
->setBatchSize(100) // max 100 nodes per read/write batch
->setDefaultBrowseMaxDepth(20) // browseRecursive depth limit
->connect('opc.tcp://192.168.1.100:4840');
Important rules
-
`setTimeout()` is in seconds (float)
-
`setAutoRetry(n)` automatically reconnects and retries on `ConnectionException`
-
`setBatchSize(n)` splits large `readMulti`/`writeMulti` operations transparently
-
The client auto-discovers server limits (`MaxNodesPerRead`, `MaxNodesPerWrite`) and respects them
-
All configuration must happen before `connect()` ? immutable after connection
Skill: Endpoint Discovery
When to use
The user wants to discover what security configurations a server supports before connecting.
Code
$client = ClientBuilder::create()
->connect('opc.tcp://192.168.1.100:4840');
$endpoints = $client->getEndpoints('opc.tcp://192.168.1.100:4840');
foreach ($endpoints as $ep) {
echo "{$ep->securityPolicyUri}\n";
echo " Mode: {$ep->securityMode->name}\n";
echo " Auth: " . implode(', ', array_map(
fn($t) => $t->tokenType->name,
$ep->userIdentityTokens
)) . "\n";
}
Skill: Create a Custom Module
When to use
The user wants to add a new OPC UA service set to the client that is not provided by the 8 built-in modules -- e.g., QueryFirst/QueryNext, custom vendor-specific services, or domain-specific operations.
Code
use PhpOpcua\Client\Module\ServiceModule;
use PhpOpcua\Client\Kernel\ClientKernelInterface;
use PhpOpcua\Client\Module\ReadWrite\ReadWriteModule;
class MyQueryModule extends ServiceModule
{
private ?MyQueryService $queryService = null;
public function requires(): array
{
return [ReadWriteModule::class]; // declare dependencies
}
public function register(): void
{
$this->client->registerMethod('queryFirst', $this->queryFirst(...));
$this->client->registerMethod('queryNext', $this->queryNext(...));
}
public function boot(): void
{
$this->queryService = new MyQueryService($this->kernel->getSessionService());
}
public function reset(): void
{
$this->queryService = null;
}
public function queryFirst(array $filter): array
{
return $this->kernel->executeWithRetry(function () use ($filter) {
$this->kernel->ensureConnected();
$request = $this->queryService->encodeQueryFirstRequest($filter);
$this->kernel->send($request);
$response = $this->kernel->receive();
return $this->queryService->decodeQueryFirstResponse($response);
});
}
public function queryNext(string $continuationPoint): array
{
// similar pattern
}
}
Register it on the builder:
use PhpOpcua\Client\ClientBuilder;
$client = ClientBuilder::create()
->addModule(new MyQueryModule())
->connect('opc.tcp://localhost:4840');
// Custom methods accessible via __call()
$results = $client->queryFirst(['nodeClass' => NodeClass::Variable]);
Important rules
-
Extend `ServiceModule` and implement `register()`, `boot()`, `reset()`
-
Use `$this->client->registerMethod()` in `register()` to inject methods onto the Client
-
Use `$this->kernel` to access shared infrastructure (send, receive, retry, etc.)
-
Declare dependencies via `requires()` -- the registry resolves them in order
-
Custom module methods are accessible via `__call()` (not typed on the interface)
-
If two modules register the same method name, `ModuleConflictException` is thrown -- use `replaceModule()` instead
-
Module DTOs and protocol services should live alongside the module class for co-location
Skill: Replace a Built-in Module
When to use
The user wants to swap a built-in module with a custom implementation -- e.g., adding custom retry logic to reads, logging all writes to a database, or implementing a custom browse strategy.
Code
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Module\ReadWrite\ReadWriteModule;
use PhpOpcua\Client\Module\ServiceModule;
class MyCustomReadWriteModule extends ReadWriteModule
{
public function register(): void
{
parent::register(); // keep all built-in methods
// Override just the read method
$this->client->registerMethod('read', $this->customRead(...));
}
public function customRead(string|NodeId $nodeId, int $attributeId = 13, bool $refresh = false): DataValue
{
// Custom logic before the read
Log::info("Reading {$nodeId}");
// Call the kernel to do the actual read
$result = parent::read($nodeId, $attributeId, $refresh);
// Custom logic after the read
MyAuditLog::record('read', $nodeId, $result->statusCode);
return $result;
}
}
$client = ClientBuilder::create()
->replaceModule(ReadWriteModule::class, new MyCustomReadWriteModule())
->connect('opc.tcp://localhost:4840');
// All modules that call $this->client->read() automatically use the replacement
$value = $client->read('i=2259'); // uses MyCustomReadWriteModule
Important rules
-
`replaceModule()` swaps a built-in module class with your custom implementation
-
Your replacement must provide the same methods -- other modules may depend on them
-
Extend the original module class when you only want to override specific behavior
-
All other modules that call `$this->client->read()` automatically use the replacement -- no additional wiring needed
-
You cannot use `addModule()` if your module has the same method names as an existing one -- use `replaceModule()` instead
-
`hasModule(ReadWriteModule::class)` returns `false` after replacement; `hasModule(MyCustomReadWriteModule::class)` returns `true`
-
Check module availability with `hasMethod()` and `hasModule()` for defensive code
Common Mistakes to Avoid
1. Configuring after connect
// WRONG ? Client is immutable after connect()
$client = ClientBuilder::create()->connect('opc.tcp://...');
$client->setTimeout(10.0); // This method doesn't exist on Client
// CORRECT
$client = ClientBuilder::create()
->setTimeout(10.0)
->connect('opc.tcp://...');
2. Forgetting to disconnect
// WRONG ? leaks connections
$client = ClientBuilder::create()->connect('opc.tcp://...');
$value = $client->read('i=2259');
// script ends without disconnect
// CORRECT
$client = ClientBuilder::create()->connect('opc.tcp://...');
try {
$value = $client->read('i=2259');
} finally {
$client->disconnect();
}
3. Using arrays instead of DTOs
// WRONG ? old array access style
$sub = $client->createSubscription(500.0);
echo $sub['subscriptionId'];
// CORRECT ? public readonly properties
$sub = $client->createSubscription(publishingInterval: 500.0);
echo $sub->subscriptionId;
4. Ignoring status codes
// WRONG ? assuming success
$dv = $client->read('ns=99;i=99999');
echo $dv->getValue(); // could be null if node doesn't exist
// CORRECT ? check status
$dv = $client->read('ns=99;i=99999');
if (StatusCode::isGood($dv->statusCode)) {
echo $dv->getValue();
} else {
echo "Read failed: {$dv->statusCode}";
}
5. Expecting sessions to persist in plain PHP
// WRONG in a web context ? session dies with the request
$client = ClientBuilder::create()->connect('opc.tcp://...');
$sub = $client->createSubscription(500.0);
// next request: subscription is gone
// CORRECT ? use session manager for persistent subscriptions
$client = new ManagedClient();
$client->connect('opc.tcp://...');
NodeId String Format Reference
| Format | Example | Meaning |
|--------|---------|---------|
| i=<number> | i=2259 | Namespace 0, numeric identifier |
| ns=<n>;i=<number> | ns=2;i=1001 | Namespace n, numeric identifier |
| s=<string> | s=Temperature | Namespace 0, string identifier |
| ns=<n>;s=<string> | ns=2;s=Temperature | Namespace n, string identifier |
| g=<guid> | g=12345678-1234-1234-1234-123456789012 | GUID identifier |
| ns=<n>;g=<guid> | ns=2;g=... | Namespace n, GUID identifier |
All methods accepting NodeId also accept these string formats.
BuiltinType Reference
| Type | PHP Type | Common Use |
|------|----------|------------|
| Boolean | bool | Digital I/O, flags |
| Int16 | int | Small signed integers |
| Int32 | int | Standard integers, counters |
| Int64 | int | Large counters |
| UInt16 | int | Unsigned small integers |
| UInt32 | int | Status codes, handles |
| UInt64 | int | Large unsigned values |
| Float | float | Single-precision measurements |
| Double | float | High-precision measurements |
| String | string | Labels, names, descriptions |
| DateTime | DateTimeImmutable | Timestamps |
| ByteString | string | Binary data, certificates |
| Byte | int | Single byte values |
| SByte | int | Signed single byte |
Exception Hierarchy
| Exception | When |
|-----------|------|
| ConnectionException | Cannot connect, timeout, network error |
| ServiceException | Server rejected the request |
| UntrustedCertificateException | Server certificate not in trust store |
| WriteTypeDetectionException | Auto-detect write type failed |
| WriteTypeMismatchException | Detected type doesn't match the value |
| InvalidNodeIdException | Invalid NodeId string format |
| DaemonException | Session manager daemon communication error (opcua-session-manager) |
|