ExtensionObject Codecs
Getting Started
Introduction ConnectionCore
Browsing Reading-writing Method-call Subscriptions History-readReference
Types Error-handling Security Architecture Extension-object-codecs Testing Events Trust-storeExtensionObject Codecs
The Problem
OPC UA ExtensionObject is a container for custom structures -- alarm details, diagnostic info, PLC-specific types, anything beyond the standard built-in types.
Without a codec, you get an ExtensionObject DTO with raw binary data:
use PhpOpcua\Client\Types\ExtensionObject;
$result = $client->read($nodeId);
$value = $result->getValue();
// ExtensionObject { typeId: NodeId, encoding: 1, body: '<binary blob>', value: null }
// $value->typeId, $value->encoding, $value->body
// $value->isRaw() === trueThe codec system lets you register decoders that turn these blobs into PHP arrays or objects. When a codec is registered, DataValue::getValue() auto-extracts the decoded value directly.
Writing a Codec
Implement ExtensionObjectCodec with decode() and encode():
use PhpOpcua\Client\Encoding\ExtensionObjectCodec;
use PhpOpcua\Client\Encoding\BinaryDecoder;
use PhpOpcua\Client\Encoding\BinaryEncoder;
class MyPointCodec implements ExtensionObjectCodec
{
public function decode(BinaryDecoder $decoder): object|array
{
return [
'x' => $decoder->readDouble(),
'y' => $decoder->readDouble(),
'z' => $decoder->readDouble(),
];
}
public function encode(BinaryEncoder $encoder, mixed $value): void
{
$encoder->writeDouble($value['x']);
$encoder->writeDouble($value['y']);
$encoder->writeDouble($value['z']);
}
}The decoder is positioned at the start of the ExtensionObject body. Read fields in the exact order the type's binary encoding defines. encode() does the reverse.
Available Decoder/Encoder Methods
| Method | OPC UA Type |
|---|---|
readBoolean() / writeBoolean() |
Boolean |
readByte() / writeByte() |
Byte |
readSByte() / writeSByte() |
SByte |
readUInt16() / writeUInt16() |
UInt16 |
readInt16() / writeInt16() |
Int16 |
readUInt32() / writeUInt32() |
UInt32 |
readInt32() / writeInt32() |
Int32 |
readInt64() / writeInt64() |
Int64 |
readUInt64() / writeUInt64() |
UInt64 |
readFloat() / writeFloat() |
Float |
readDouble() / writeDouble() |
Double |
readString() / writeString() |
String |
readByteString() / writeByteString() |
ByteString |
readDateTime() / writeDateTime() |
DateTime |
readGuid() / writeGuid() |
Guid |
readNodeId() / writeNodeId() |
NodeId |
readQualifiedName() / writeQualifiedName() |
QualifiedName |
readLocalizedText() / writeLocalizedText() |
LocalizedText |
readVariant() / writeVariant() |
Variant |
readExtensionObject() / writeExtensionObject() |
Nested ExtensionObject |
Registering a Codec
Create an ExtensionObjectRepository, register your codecs, and pass it to the ClientBuilder:
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Repository\ExtensionObjectRepository;
use PhpOpcua\Client\Types\NodeId;
$repo = new ExtensionObjectRepository();
// By class name (instantiated on first use)
$repo->register(NodeId::numeric(2, 5001), MyPointCodec::class);
// By instance (useful when the codec needs configuration)
$repo->register(NodeId::numeric(2, 5001), new MyPointCodec());
$client = ClientBuilder::create($repo)
->connect('opc.tcp://localhost:4840');Note: Each
Clienthas its own isolated repository. Codecs registered on one client do not affect another. If you don't pass a repository, the builder creates an empty one internally.
You can also register codecs on the builder before connecting:
$builder = ClientBuilder::create();
$builder->getExtensionObjectRepository()->register(
NodeId::numeric(2, 5001),
MyPointCodec::class
);
$client = $builder->connect('opc.tcp://localhost:4840');Using It
Once registered, the codec fires automatically whenever the library encounters an ExtensionObject with that typeId:
$repo = new ExtensionObjectRepository();
$repo->register(NodeId::numeric(2, 5001), MyPointCodec::class);
$client = ClientBuilder::create($repo)
->connect('opc.tcp://localhost:4840');
$result = $client->read($pointNodeId);
$point = $result->getValue();
// ['x' => 1.0, 'y' => 2.0, 'z' => 3.0]No extra steps. Read a node, get decoded data.
Repository API
$repo = new ExtensionObjectRepository();
$repo->register($typeId, MyCodec::class); // Register a codec
$repo->has($typeId); // bool
$repo->get($typeId); // ?ExtensionObjectCodec
$repo->unregister($typeId); // Remove one
$repo->clear(); // Remove allFinding the TypeId
Read the node without a codec first and inspect the raw ExtensionObject:
$result = $client->read($nodeId);
$raw = $result->getValue(); // ExtensionObject DTO
echo $raw->typeId; // e.g. "ns=2;i=5001"
echo $raw->encoding; // 1 = binary, 2 = XML
echo strlen($raw->body); // body size in bytesUse that typeId when calling $repo->register().
Tip: The
typeIdis the binary encoding NodeId, not the data type's own NodeId. These are different. The binary encoding NodeId is the one that appears in the wire format.
Automatic Discovery
Instead of writing codecs by hand, call $client->discoverDataTypes() after connecting. The client browses the server's DataType hierarchy, reads the DataTypeDefinition attribute (available on OPC UA 1.04+ servers), and registers a DynamicCodec for every custom structure it finds.
Before — manual codec:
class MyPointCodec implements ExtensionObjectCodec
{
public function decode(BinaryDecoder $decoder): array
{
return [
'x' => $decoder->readDouble(),
'y' => $decoder->readDouble(),
'z' => $decoder->readDouble(),
];
}
public function encode(BinaryEncoder $encoder, mixed $value): void
{
$encoder->writeDouble($value['x']);
$encoder->writeDouble($value['y']);
$encoder->writeDouble($value['z']);
}
}
$repo = new ExtensionObjectRepository();
$repo->register(NodeId::numeric(2, 5001), MyPointCodec::class);
$client = ClientBuilder::create($repo)
->connect('opc.tcp://localhost:4840');After — automatic discovery:
$client = ClientBuilder::create()
->connect('opc.tcp://localhost:4840');
$client->discoverDataTypes();
$point = $client->read($pointNodeId)->getValue();
// ['x' => 1.5, 'y' => 2.5, 'z' => 3.5] — decoded automaticallyNo codec class, no registration. The library reads the structure definition from the server and builds a decoder at runtime.
Namespace Filtering
Pass a namespaceIndex to limit discovery to a specific namespace. This avoids scanning the entire type hierarchy when you only care about your application's types:
$client->discoverDataTypes(namespaceIndex: 2);Manual Codecs Take Priority
If you registered a codec manually before calling discoverDataTypes(), the manual codec is preserved. Auto-discovery never overwrites existing registrations.
Note: Auto-discovery requires the server to expose
DataTypeDefinitionattributes (OPC UA 1.04+). Older servers that lack these attributes need manual codecs.
Tip: Call
discoverDataTypes()once afterconnect(). It adds a round-trip to the server but saves you from writing and maintaining codec classes for every custom type.
Events:
DataTypesDiscoveredis dispatched after discovery completes, carrying the namespace index and the number of types found. See Events.
Limitations
- Binary only. Codecs work for binary-encoded ExtensionObjects (encoding
0x01). XML-encoded ones (encoding0x02) come back as raw XML strings. - No built-in codecs. The library does not ship decoders for standard OPC UA ExtensionObject types like
ServerStatusDataTypeorEUInformation. You write the codecs you need.
Design Note: Why BuiltinTypes Are Not Codecs
The codec system is for ExtensionObject -- composite structures whose binary format is defined by servers or OPC UA companion specs.
BuiltinType values (Int32, String, Double, etc.) are protocol-level primitives. Their encoding is fixed by the OPC UA spec and hardcoded in BinaryEncoder / BinaryDecoder. Making them pluggable would add indirection with zero benefit since their format never changes.
Two distinct layers:
- BuiltinType -- the protocol itself (fixed, spec-defined)
- ExtensionObjectCodec -- application-level structures on top of the protocol (variable, server-defined, extensible)