Skip to content

Latest commit

 

History

History
1150 lines (893 loc) · 41 KB

File metadata and controls

1150 lines (893 loc) · 41 KB

OPC 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->statusCode0 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
  • Cached values go through Cache\WireCacheCodec — JSON-only, gated by Wire\WireTypeRegistry (no unserialize() anywhere on the cache path). Corrupted or unknown payloads raise Exception\CacheCorruptedException and are treated as cache misses. Swap the codec with ClientBuilder::setCacheCodec(?CacheCodecInterface) if you need a custom on-disk format. Pre-v4.3.0 cache entries are discarded on first access — flush persistent caches on upgrade.

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

All exceptions live in PhpOpcua\Client\Exception and extend OpcUaException (which extends \RuntimeException).

Exception Parent When
ConfigurationException OpcUaException Invalid configuration on ClientBuilder
ConnectionException OpcUaException Cannot connect, timeout, network error, disconnect required
EncodingException OpcUaException Binary codec encode/decode failure
InvalidNodeIdException OpcUaException Malformed NodeId string
CacheCorruptedException OpcUaException Cache payload cannot be decoded (treated as cache miss internally; pre-v4.3.0 entries fall here)
ModuleConflictException OpcUaException Two modules register the same method (use replaceModule() to swap intentionally)
MissingModuleDependencyException OpcUaException A module's requires() lists an unregistered class
ProtocolException OpcUaException Generic OPC UA protocol violation
HandshakeException ProtocolException Server returned ERR during HEL/ACK (carries $errorCode)
MessageTypeException ProtocolException Unexpected message type (carries $expected / $actual)
SecurityException OpcUaException Generic crypto failure
CertificateParseException SecurityException Missing fields in parsed certificate
OpenSslException SecurityException An openssl_* call returned false
SignatureVerificationException SecurityException OPN or MSG signature did not verify
UnsupportedCurveException SecurityException ECC curve not supported (carries $curveName)
UntrustedCertificateException SecurityException Server certificate not in trust store (carries $fingerprint, $certDer)
ServiceException OpcUaException Server rejected the request — carries getStatusCode()
ServiceUnsupportedException ServiceException Server returned BadServiceUnsupported (0x800B0000) — typical when calling addNodes/deleteNodes/addReferences/deleteReferences on a server that does not implement NodeManagement (e.g. UA-.NETStandard)
WriteTypeDetectionException OpcUaException Write type auto-detection failed
WriteTypeMismatchException OpcUaException Detected type does not match a value passed to write() (carries $nodeId, $expectedType, $givenType)

Catching OpcUaException matches every exception above. Catching ServiceException matches ServiceUnsupportedException. Catching ProtocolException matches both HandshakeException and MessageTypeException. Catching SecurityException matches the 5 security subclasses.

The session-manager daemon's DaemonException lives in the php-opcua/opcua-session-manager package, not here.