php-opcua/opcua-client
A PHP client library for OPC UA, the industrial communication protocol used to exchange data with PLCs, SCADA systems and industrial devices. Browse, read and write nodes securely from your PHP applications.
The package is a pure-PHP OPC UA stack: one client object owns the TCP transport, secure channel, session, and service calls. That means your first job is simply to establish a session and read one node — everything else builds on that.
Requirements and install
composer.json for the exact constraint).ext-openssl for certificate/signing support; no C/C++ OPC UA SDK or extension required.composer require php-opcua/opcua-client
Where to look first
examples/ — usually contains a connect/read script and a subscription script. Copy one wholesale before reading the source.src/Client.php — the public facade for connect, read, write, browse, call, and disconnect.src/SecurityPolicy/ and src/Transport/ — only needed once anonymous/no-security works.tests/ integration fixtures — useful for seeing the exact NodeIds and endpoint style the maintainers test against.Confirm the autoload namespace and constructor signature from composer.json and the example files; this package is small enough that the source is the definitive API reference.
Minimal first connection
<?php
require 'vendor/autoload.php';
use PhpOpcUa\Client\Client;
$client = new Client('opc.tcp://127.0.0.1:4840');
try {
$client->connect();
$value = $client->read('ns=2;s=Demo.Static.Scalar.Double');
var_dump($value);
} finally {
$client->disconnect();
}
First real use case
Pick one PLC tag you already know from UaExpert or the server’s tag browser. Hardcode its ns=2;s=... NodeId, read it once, and print the value. Once that works, switch to the namespace-array lookup described below so you are not hardcoding namespace indexes.
OPC UA sessions are expensive. Do not connect per HTTP request or per queue job. Register one configured client per process and manage its lifecycle explicitly.
final class OpcUaService
{
private ?Client $client = null;
public function __construct(private array $config) {}
public function client(): Client
{
if ($this->client === null) {
$this->client = new Client($this->config['endpoint']);
$this->client->setSecurityPolicy($this->config['security_policy']);
$this->client->connect();
}
return $this->client;
}
public function close(): void
{
$this->client?->disconnect();
$this->client = null;
}
}
In Laravel, bind it as a singleton and configure it from config/opcua.php:
$this->app->singleton(OpcUaService::class, fn () => new OpcUaService(config('opcua')));
Most real integrations read dozens of tags per cycle. Use the array form so the client sends one Read service request rather than one TCP round trip per tag.
$nodes = [
'ns=2;s=Line1.Speed',
'ns=2;s=Line1.Temperature',
'ns=2;s=Line1.Pressure',
];
$values = $client->read($nodes); // typically keyed by NodeId
Namespace indexes differ between servers and even between projects on the same server. Read the standard NamespaceArray node (i=2255) and map the URI to the index before building NodeIds.
$namespaceArray = $client->read('i=2255')->getValue();
$index = array_search('urn:my-company:line1', $namespaceArray, true);
$nodeId = "ns={$index};s=Line1.Speed";
Cache this mapping for the life of the session, not forever.
When tags are configured dynamically or you are exploring a server, browse from the Objects folder (i=85) or a known folder node.
$references = $client->browse('ns=2;s=Line1');
foreach ($references as $reference) {
echo $reference->getBrowseName() . ' => ' . $reference->getNodeId() . PHP_EOL;
}
Persist discovered NodeIds in your database or config after the first browse; do not walk the address space on every request.
Always send the type the server expects. Writing a PHP integer into a Double tag or a scalar into an array tag is a common source of BadTypeMismatch.
$client->write('ns=2;s=Line1.Setpoint', 42.5, 'Double');
Method calls are the normal way to trigger commands on a server — start a line, reset a counter, acknowledge an alarm.
$results = $client->call(
'ns=2;s=Line1',
'ns=2;s=Line1.ResetCounter',
[]
);
Inspect the result status code; a method can be invoked successfully but return a bad application-level status.
Polling every tag on a timer is the wrong default. Use monitored items and a subscription when the server supports it.
$subscription = $client->createSubscription(1000); // publishing interval, ms
$subscription->monitor('ns=2;s=Line1.Speed', function ($value) {
echo "Speed: {$value}\n";
});
$client->run(); // or the package's publish loop equivalent
For long-running workers, run a bounded publish loop inside a supervised process rather than an infinite loop in a web request.
Start with anonymous/no-security against a test server, then add:
Basic256Sha256.Keep certificates outside the repository and inject paths via environment variables.
opc.tcp://host:port/path from the server config. If discovery fails, try bypassing it and connecting directly.ns=2. Namespace indexes are per-server and can change after a server project update. Always resolve via NamespaceArray.BadSessionIdInvalid/connection loss, and re-create subscriptions after reconnecting.StatusCodes rather than throwing. A batch read can partially succeed; inspect each result.Basic256Sha256 fails, first try no security, then verify the client cert is DER/PEM in the format the package expects and that the server trust list includes it.composer.json and add an integration test against a local OPC UA test server (open62541, Prosys, or the package’s own fixtures) so upgrades are validated.Client directly.How can I help you explore Laravel packages today?