Typed DTOs
Each structured DataType in a companion spec produces a readonly PHP class. With the registrar loaded, reads that return the corresponding ExtensionObject decode into typed instances automatically.
Every structured DataType in a companion spec — the kind that wraps
multiple typed fields and travels on the wire as a binary
ExtensionObject — produces a readonly PHP class under
Types/. The matching Codecs/<Type>Codec.php decodes the bytes
into an instance.
Together with the registrar, this turns OPC UA structures into typed PHP objects with property-level access.
The generated DTO
namespace PhpOpcua\Nodeset\AMB\Types;
readonly class NameNodeIdDataType
{
public function __construct(
public LocalizedText $Name,
public NodeId $NodeId,
) {
}
}
Properties:
readonly— once constructed, immutable. Property assignment throws.- Public typed properties — direct access, no getters. The
spec's field name is preserved exactly (
PascalCasefor OPC UA, no transformation). - Constructor promotion — every field is a constructor argument in declaration order. The codec calls the constructor with positional arguments.
The class is intentionally minimal. No factories, no validation, no behaviour — these are wire-format value objects.
Type mapping rules
The generator maps the spec's <Field DataType="..."> to PHP
types as follows:
OPC UA DataType |
PHP type |
|---|---|
Boolean |
bool |
SByte, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64 |
int |
Float, Double |
float |
String, XmlElement |
string |
ByteString |
string (raw bytes) |
DateTime |
\DateTimeImmutable |
Guid |
string (canonical hex) |
NodeId, ExpandedNodeId |
PhpOpcua\Client\Types\NodeId |
QualifiedName |
PhpOpcua\Client\Types\QualifiedName |
LocalizedText |
PhpOpcua\Client\Types\LocalizedText |
Variant |
PhpOpcua\Client\Types\Variant |
DataValue |
PhpOpcua\Client\Types\DataValue |
| Custom enumeration DataType | The generated BackedEnum class |
| Custom structure DataType | The generated DTO class |
Modifiers:
Optionalfield (<Field ... Optional="true">) → the type is nullable (?T), default valuenull.- Array field (
ValueRank="1") → the field type isarray, documented in PHPDoc. Variant-typed field (DataType="i=24") → the field type isVariant— the codec leaves the underlying value type untouched.
How a DTO arrives in your code
The full happy path:
-
01
The server returns a `Variant<ExtensionObject>`.
The variant's
valueis anExtensionObjectwhosetypeIdis the DataType NodeId of, say,NameNodeIdDataType. Thebodyis the binary-encoded structure payload. -
02
The client looks up the codec.
The
ExtensionObjectRepositorywas populated by the registrar viaregisterCodecs(). It finds a codec for thattypeId. -
03
The codec decodes the body.
NameNodeIdDataTypeCodec::decode(BinaryDecoder $decoder)reads the wire bytes and constructs aNameNodeIdDataTypeinstance. -
04
`DataValue::getValue()` returns the DTO.
getValue()recognises the decodedExtensionObjectand unwraps to the codec's return value. Your code receives theNameNodeIdDataType, not the wrapper.
End result:
use PhpOpcua\Nodeset\AMB\AMBNodeIds;
use PhpOpcua\Nodeset\AMB\Types\NameNodeIdDataType;
$dv = $client->read(AMBNodeIds::SomeNameNodeIdNode);
$value = $dv->getValue();
if ($value instanceof NameNodeIdDataType) {
echo $value->Name->text . "\n"; // LocalizedText
echo (string) $value->NodeId; // NodeId, cast to its canonical string form
}
Without the registrar, $value would be an ExtensionObject with
body set to the raw bytes — you would have to decode them
yourself.
What happens when a field is nullable
OPC UA's optional fields use a bitmask in the structure body. The codec reads the bitmask first, then conditionally reads each optional field:
public function decode(BinaryDecoder $decoder): SomeOptionalStruct
{
$optionalMask = $decoder->readUInt32(); // bit 0 = first optional, etc.
return new SomeOptionalStruct(
Mandatory: $decoder->readDouble(),
Optional: ($optionalMask & 0b1) ? $decoder->readString() : null,
);
}
The DTO constructor argument is ?string $Optional = null, so the
caller can also construct it without supplying the optional value:
$value = new SomeOptionalStruct(Mandatory: 42.0);
// $value->Optional === null
Nested structures
A field whose DataType is another structure is typed with the generated DTO of that structure. The codec recursively delegates to the nested codec.
readonly class OuterDataType
{
public function __construct(
public string $Name,
public InnerDataType $Inner,
) {
}
}
The registrar must register both codecs — the generator
handles this automatically. Loading the registrar of the
outermost spec is sufficient (dependent specs' codecs come
along via dependencyRegistrars()).
Writing a DTO back
Once decoded into a DTO, you can build a new instance, pass it to
write(), and the codec handles the encoding:
use PhpOpcua\Client\Types\LocalizedText;
use PhpOpcua\Client\Types\NodeId;
use PhpOpcua\Nodeset\AMB\Types\NameNodeIdDataType;
$newValue = new NameNodeIdDataType(
Name: new LocalizedText('en', 'Cooling Pump 1'),
NodeId: NodeId::numeric(2, 1042),
);
$client->write(AMBNodeIds::SomeNameNodeIdNode, $newValue);
Auto-detect picks BuiltinType::ExtensionObject, looks up the
codec for the DTO's DataType, encodes the body. The result is a
Variant<ExtensionObject> on the wire.
Equality
DTOs are PHP objects — == does structural equality, === is
identity. Two DTOs with identical field values compare equal with
== but not with ===:
$a = new NameNodeIdDataType(new LocalizedText(null, 'A'), NodeId::numeric(2, 1));
$b = new NameNodeIdDataType(new LocalizedText(null, 'A'), NodeId::numeric(2, 1));
$a == $b; // true (value equality)
$a === $b; // false (different object)
For cache keys, hash a normalised string form
(serialize($value) works in-process; for cross-process
serialisation use the codec's encode + base64).
What the DTOs do not include
- Validation. A DTO accepts whatever the constructor type-hints
allow. Out-of-spec values reach the wire as the codec encodes
them; the server may reject with
BadInvalidArgument. - Schema metadata. The DTO does not carry its DataType NodeId
at runtime. You match by class (
$value instanceof NameNodeIdDataType), not by NodeId. - Trait / interface markers. DTOs do not implement any common
interface — each stands alone. Use
instanceoffor type discrimination.
For applications that need richer modelling on top, wrap the generated DTOs in your own domain classes.
Where to look next
- Codecs and registrars — how the codec wiring binds DTOs to NodeIds.
- Usage · Reading structured data — patterns for working with returned DTOs.