Reading
Reading values — single, batched, non-Value attributes — and the DataValue shape every Symfony service will reach for.
The most common operation. Through the autowired client:
use PhpOpcua\Client\OpcUaClientInterface;
final class SpeedService
{
public function __construct(private OpcUaClientInterface $client) {}
public function current(): ?float
{
$dv = $this->client->read('ns=2;s=Speed');
return $dv->statusCode === 0 ? (float) $dv->getValue() : null;
}
}
read() returns a DataValue — value + status + timestamps.
The DataValue shape
| Field | Type | Meaning |
|---|---|---|
value |
mixed | Decoded value — PHP type per BuiltinType |
statusCode |
int | 0 = good, otherwise per-spec failure |
sourceTimestamp |
?DateTimeImmutable |
When the device produced the value |
serverTimestamp |
?DateTimeImmutable |
When the OPC UA server stamped it |
type |
BuiltinType enum |
Wire-level type |
dimensions |
?array<int> |
Array dims if multi-dimensional |
getValue() returns the value; statusCode === 0 is "Good".
Batch reads
For more than ~3 nodes, batch:
$values = $this->client->readMulti(null)
->node('ns=2;s=Speed')
->node('ns=2;s=Temperature')
->node('ns=2;s=Pressure')
->execute();
// $values is an array of DataValue, same order as nodes
Order preserved. One round-trip — much faster than N sequential
read() calls. The client chunks to the server's
MaxNodesPerRead automatically.
Non-Value attributes
OPC UA nodes have 22 attributes. To read others:
use PhpOpcua\Client\Types\AttributeId;
$display = $this->client
->read('ns=2;s=Speed', AttributeId::DisplayName)
->getValue(); // "Speed"
$access = $this->client
->read('ns=2;s=Speed', AttributeId::AccessLevel)
->getValue(); // byte bitmask
The signature of read() is
read(NodeId|string $nodeId, int $attributeId = AttributeId::Value, bool $refresh = false): DataValue. The second parameter is
the attribute id, not a bool $refresh.
Common non-Value attributes:
AttributeId |
Returns | Use case |
|---|---|---|
DisplayName |
Localized text | UI labels |
Description |
Localized text | Tag tooltips |
DataType |
NodeId | Inferring writeable BuiltinType |
NodeClass |
int (enum) | Browse-filtering |
AccessLevel |
byte bitmask | Is the node writeable / historizable? |
Historizing |
bool | History recording flag |
ArrayDimensions |
array of int | Array sizing |
Cast PHP types
What you get back per BuiltinType:
| BuiltinType | PHP type |
|---|---|
Boolean |
bool |
SByte, Byte |
int |
Int16...UInt32 |
int |
Int64, UInt64 |
int (on 64-bit PHP) |
Float, Double |
float |
String |
string |
DateTime |
DateTimeImmutable |
Guid |
string (UUID format) |
ByteString |
string (binary) |
LocalizedText |
array {locale, text} |
QualifiedName |
array {ns, name} |
When persisting via Doctrine, cast explicitly:
$reading = new PlcReading();
$reading->setNodeId('ns=2;s=Speed');
$reading->setValue((float) $dv->getValue());
$reading->setStatus($dv->statusCode);
$reading->setSourceAt($dv->sourceTimestamp);
$this->em->persist($reading);
$this->em->flush();
Status code handling
Two common patterns:
Treat bad as failure
$dv = $this->client->read('ns=2;s=Speed');
if ($dv->statusCode !== 0) {
throw new \RuntimeException(
sprintf('Bad read: status=0x%X', $dv->statusCode)
);
}
$speed = $dv->getValue();
Store both value and quality
$reading = (new PlcReading())
->setNodeId('ns=2;s=Speed')
->setValue($dv->getValue())
->setStatusCode($dv->statusCode)
->setGood($dv->statusCode === 0)
->setSourceAt($dv->sourceTimestamp);
$this->em->persist($reading);
Production apps usually store both — the value with a quality marker is more useful than the value alone.
Status helpers
use PhpOpcua\Client\Types\StatusCode;
if (StatusCode::isGood($dv->statusCode)) { /* ... */ }
if (StatusCode::isUncertain($dv->statusCode)) { /* ... */ }
if (StatusCode::isBad($dv->statusCode)) { /* ... */ }
$name = StatusCode::getName($dv->statusCode); // 'Good' / 'BadCommunicationError' / …
Error handling
Per failure mode:
| Exception | Trigger | Right response |
|---|---|---|
ConnectionException |
TCP layer issue | Retry with backoff |
ServiceException |
Server returned Bad_* (e.g. expired session, bad node id) |
Surface; retry only if status indicates a transient cause |
EncodingException |
Wire decode failed | Bug — report |
namespace App\Service;
use PhpOpcua\Client\Exception\{ConnectionException, ServiceException};
use PhpOpcua\Client\OpcUaClientInterface;
use Psr\Log\LoggerInterface;
final class ResilientSpeedReader
{
public function __construct(
private OpcUaClientInterface $client,
private LoggerInterface $logger,
) {}
public function read(): ?float
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$dv = $this->client->read('ns=2;s=Speed');
return $dv->statusCode === 0 ? (float) $dv->getValue() : null;
} catch (ConnectionException $e) {
$this->logger->warning('Speed read retry', [
'attempt' => $attempt, 'error' => $e->getMessage(),
]);
usleep(200_000 * $attempt);
}
}
$this->logger->error('Speed read failed after 3 attempts');
return null;
}
}
For app-wide retry, set auto_retry per connection in YAML —
see Connections.
Reading across connections
$plant = [];
foreach (array_keys($this->opcuaConfig['connections']) as $name) {
try {
$dv = $this->opcua->connect($name)->read('ns=2;s=Speed');
$plant[$name] = [
'speed' => $dv->getValue(),
'good' => $dv->statusCode === 0,
];
} catch (\Throwable $e) {
$plant[$name] = ['error' => $e->getMessage()];
}
}
For very wide plants (50+ connections), parallelise with Messenger — see Integrations · Messenger.
Caching read results
Don't cache OPC UA read values in your application cache unless you genuinely want stale data — values are the truth of the device.
If you need cheap polling, use a subscription that maintains the latest value in Symfony's cache:
// In an event listener on DataChangeReceived — the event carries
// $clientHandle, not $nodeId; resolve through your own map.
$cache->save(
$cache->getItem('opcua.latest.' . $event->clientHandle)
->set([
'value' => $event->dataValue->getValue(),
'status' => $event->dataValue->statusCode,
'at' => $event->dataValue->sourceTimestamp?->format('c'),
])
->expiresAfter(300)
);
See Recipes · Persistent tag history and Events · Data events.
A read endpoint
namespace App\Controller;
use PhpOpcua\Client\Exception\OpcUaException;
use PhpOpcua\Client\OpcUaClientInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class TagController extends AbstractController
{
public function __construct(private OpcUaClientInterface $client) {}
#[Route('/api/tags/{node}', methods: ['GET'], requirements: ['node' => '.+'])]
public function show(string $node): JsonResponse
{
try {
$dv = $this->client->read($node);
} catch (OpcUaException $e) {
return $this->json(['error' => $e->getMessage()], 502);
}
return $this->json([
'node_id' => $node,
'value' => $dv->getValue(),
'status' => $dv->statusCode,
'good' => $dv->statusCode === 0,
'source_at' => $dv->sourceTimestamp?->format('c'),
]);
}
}
Where to read next
- Writing — the dual operation with authorisation, audit, and rate-limiting patterns.
- Subscriptions — read the same value repeatedly without polling.
- Recipes · Persistent tag history — full Doctrine persistence pattern.