Extending a registrar
Register your own codec alongside the generated ones — for a vendor-specific structure no companion spec defines. The generated files stay @generated; the custom code lives in your application.
The 51 companion specs cover most servers, but real deployments
often add vendor-specific structures the spec doesn't define.
MyVendorCustomStatus, an ACME-extended Argument shape, an
internal diagnostic record — these need their own
ExtensionObjectCodec registered.
The pattern this page covers: register a custom codec alongside
the generated ones, without modifying anything under src/.
The generated files stay @generated; the custom code lives in
your application.
The shape
Three things to write in your application code:
- The DTO — readonly PHP class for the structure.
- The codec —
ExtensionObjectCodecthat decodes / encodes the binary body. - A registration call — your own
loadGeneratedTypes()chain or a custom registrar implementingGeneratedTypeRegistrar.
Two registration strategies depending on volume:
| Strategy | When |
|---|---|
| Direct registration on the repository | One or two custom codecs |
Custom GeneratedTypeRegistrar |
Several codecs, your own dependency policy |
Strategy 1 — direct registration
For one or two extras, register directly on the ExtensionObjectRepository before connecting:
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Types\NodeId;
use PhpOpcua\Nodeset\Machinery\MachineryRegistrar;
$builder = ClientBuilder::create()
->loadGeneratedTypes(new MachineryRegistrar());
// Register the custom codec on the same per-client repository
$builder->getExtensionObjectRepository()->register(
NodeId::parse('ns=10;i=5001'), // your vendor DataType NodeId
new App\Opcua\VendorCustomStatusCodec(),
);
$client = $builder->connect('opc.tcp://vendor-plc.local:4840');
Generated codecs land via loadGeneratedTypes(); the custom codec
lands via the explicit register() call. The repository doesn't
care where a codec came from — both run on the same reads.
Strategy 2 — custom GeneratedTypeRegistrar
For more than a couple of codecs, or to centralise the wiring, write your own registrar:
namespace App\Opcua;
use PhpOpcua\Client\Repository\ExtensionObjectRepository;
use PhpOpcua\Client\Repository\GeneratedTypeRegistrar;
use PhpOpcua\Client\Types\NodeId;
use PhpOpcua\Nodeset\Machinery\MachineryRegistrar;
final class CustomVendorRegistrar implements GeneratedTypeRegistrar
{
public function __construct(public bool $only = false) {}
public function registerCodecs(ExtensionObjectRepository $repository): void
{
$repository->register(NodeId::parse('ns=10;i=5001'), new VendorCustomStatusCodec());
$repository->register(NodeId::parse('ns=10;i=5002'), new VendorDiagnosticRecordCodec());
$repository->register(NodeId::parse('ns=10;i=5003'), new VendorAlarmRecordCodec());
}
public function getEnumMappings(): array
{
return [
'ns=10;i=4010' => VendorOperatingStateEnum::class,
'ns=10;i=4011' => VendorAlarmCategoryEnum::class,
];
}
public function dependencyRegistrars(): array
{
return [
new MachineryRegistrar(), // your vendor spec extends Machinery
];
}
}
The custom registrar implements the same interface as the
generated ones. Loading it goes through the same loadGeneratedTypes()
path — your dependencies cascade like any other:
$client = ClientBuilder::create()
->loadGeneratedTypes(new App\Opcua\CustomVendorRegistrar())
->connect('opc.tcp://vendor-plc.local:4840');
This is the recommended path for any non-trivial vendor extension — it keeps the wiring in one place and makes the registrar testable.
Writing the DTO
The DTO is a readonly PHP class with one constructor argument per field. Follow the same conventions as the generated ones:
namespace App\Opcua;
use PhpOpcua\Client\Types\LocalizedText;
use PhpOpcua\Client\Types\NodeId;
final readonly class VendorCustomStatus
{
public function __construct(
public string $DeviceId,
public LocalizedText $Status,
public ?VendorOperatingStateEnum $Mode, // optional → nullable
public int $Severity,
/** @var float[] */
public array $RawValues,
) {}
}
Field types:
- Built-in OPC UA types → the PHP equivalent (
string,int,float,bool,LocalizedText,NodeId,DateTimeImmutable, …) - Custom enum → your
BackedEnumclass - Optional fields → nullable (
?T) with defaultnull - Arrays →
array, with PHPDoc@var T[]
Writing the codec
The codec reads/writes the binary body in the same field order the server uses. Build from the binary spec:
namespace App\Opcua;
use PhpOpcua\Client\Encoding\BinaryDecoder;
use PhpOpcua\Client\Encoding\BinaryEncoder;
use PhpOpcua\Client\Encoding\ExtensionObjectCodec;
final class VendorCustomStatusCodec implements ExtensionObjectCodec
{
public function decode(BinaryDecoder $decoder): VendorCustomStatus
{
// For structures with optional fields, the spec defines an
// encoding mask. Read it first.
$optionalMask = $decoder->readUInt32();
return new VendorCustomStatus(
DeviceId: $decoder->readString(),
Status: $decoder->readLocalizedText(),
Mode: ($optionalMask & 0b1)
? VendorOperatingStateEnum::from($decoder->readInt32())
: null,
Severity: $decoder->readInt32(),
RawValues: $this->decodeFloatArray($decoder),
);
}
public function encode(BinaryEncoder $encoder, mixed $value): void
{
assert($value instanceof VendorCustomStatus);
$optionalMask = $value->Mode !== null ? 0b1 : 0;
$encoder->writeUInt32($optionalMask);
$encoder->writeString($value->DeviceId);
$encoder->writeLocalizedText($value->Status);
if ($value->Mode !== null) {
$encoder->writeInt32($value->Mode->value);
}
$encoder->writeInt32($value->Severity);
$this->encodeFloatArray($encoder, $value->RawValues);
}
private function decodeFloatArray(BinaryDecoder $decoder): array
{
$length = $decoder->readInt32();
if ($length < 0) { // -1 = null array per the OPC UA spec
return [];
}
$out = [];
for ($i = 0; $i < $length; $i++) {
$out[] = $decoder->readDouble();
}
return $out;
}
private function encodeFloatArray(BinaryEncoder $encoder, array $values): void
{
$encoder->writeInt32(count($values));
foreach ($values as $v) {
$encoder->writeDouble($v);
}
}
}
The encoder/decoder API is opcua-client's — see the
BinaryEncoder reference
for every read/write method.
Key points:
- Field order matches the OPC UA spec. Off-by-one in field order makes the codec hand back garbage values silently. Test with a known wire payload.
- Optional fields use a mask. OPC UA structures with optional
fields prefix the body with a
UInt32mask, one bit per optional field in declaration order. - Array fields are length-prefixed
Int32.-1means null array (different from empty); the codec above flattens null to an empty array as a simplification — keep null distinct if your application needs that. - Enums encode as
Int32. Use$enum->valueon encode,EnumClass::from($int)on decode (ortryFrom()for tolerance to out-of-spec values).
Using the custom DTO
Once registered, read() on a vendor node returns the typed DTO:
use App\Opcua\VendorCustomStatus;
$dv = $client->read('ns=10;s=Devices/PLC1/Status');
if ($dv->getValue() instanceof VendorCustomStatus) {
/** @var VendorCustomStatus $status */
$status = $dv->getValue();
echo "{$status->DeviceId}: {$status->Status->text}, severity {$status->Severity}\n";
}
Same pattern as the generated DTOs — see Usage · Reading structured data.
Testing the codec
A round-trip test against the codec alone catches most bugs:
use App\Opcua\VendorCustomStatusCodec;
use App\Opcua\VendorCustomStatus;
use PhpOpcua\Client\Encoding\BinaryEncoder;
use PhpOpcua\Client\Encoding\BinaryDecoder;
it('round-trips a VendorCustomStatus', function () {
$codec = new VendorCustomStatusCodec();
$original = new VendorCustomStatus(
DeviceId: 'PLC-1',
Status: new LocalizedText('en', 'OK'),
Mode: VendorOperatingStateEnum::RUNNING,
Severity: 0,
RawValues: [1.0, 2.0, 3.0],
);
$encoder = new BinaryEncoder();
$codec->encode($encoder, $original);
$bytes = $encoder->getBuffer();
$decoder = new BinaryDecoder($bytes);
$decoded = $codec->decode($decoder);
expect($decoded)->toEqual($original); // value equality
});
Run that test in CI — your custom codec is application code, no generator behind it. The discipline is yours.
What this does not let you do
- Modify the generated output. Files under
src/are@generatedand will be overwritten on the nextgenerate.phprun. The custom DTO and codec must live in your application code, not in this package. - Hook into the generator. If many custom structures share a pattern your spec could express, the right fix is to upstream a NodeSet2.xml change to the OPC Foundation — your custom codec becomes a generated codec a few months later.
- Override the binary format. The codec controls how a single DataType encodes; it cannot change the OPC UA framing, signing, or encryption. Those belong to the transport / secure channel.
For codecs that need to live alongside generated ones long-term, the pattern is stable — write the DTO and codec once, register them in your application's bootstrap, never touch the package's files.