opcua-client-nodeset · master
Docs · Concepts

Codecs and registrars

Codecs decode bytes to DTOs and back. Registrars wire codecs and enum mappings into the client at boot. The contract is GeneratedTypeRegistrar — one method, three things to register.

A registrar is the wiring class. Each companion spec ships at least one. It implements PhpOpcua\Client\Repository\GeneratedTypeRegistrar (the contract defined by opcua-client) and is the single object the application hands to ClientBuilder::loadGeneratedTypes() to turn on the spec's typed surface.

This page covers what a registrar does, what a codec does, and how the two interact with the client at runtime.

The GeneratedTypeRegistrar contract

php GeneratedTypeRegistrar
namespace PhpOpcua\Client\Repository;

interface GeneratedTypeRegistrar
{
    public function registerCodecs(ExtensionObjectRepository $repository): void;

    /** @return array<string, class-string<\BackedEnum>> */
    public function getEnumMappings(): array;

    /** @return GeneratedTypeRegistrar[] */
    public function dependencyRegistrars(): array;
}

Three moving parts:

Slot Purpose
registerCodecs() Push each codec onto the client's ExtensionObjectRepository keyed by the DataType NodeId
getEnumMappings() Return the DataType-NodeId-to-PHP-enum-class table
dependencyRegistrars() Return the registrars this spec depends on, for recursive load

The shipped concrete classes also expose a public bool $only = false constructor flag (via promoted properties) — when true the loader skips that registrar's dependencies. The flag is implementation detail, not part of the interface; custom registrars are free to omit it.

The interface lives in opcua-client's PhpOpcua\Client\Repository\ namespace — it is the cross-package contract. See Reference · Registrar API.

What a generated registrar looks like

php generated registrar
namespace PhpOpcua\Nodeset\AMB;

class AMBRegistrar implements GeneratedTypeRegistrar
{
    public function __construct(public bool $only = false) {}

    public function registerCodecs(ExtensionObjectRepository $repository): void
    {
        $repository->register(
            NodeId::parse(AMBNodeIds::NameNodeIdDataType_3),
            new Codecs\NameNodeIdDataTypeCodec(),
        );
        $repository->register(
            NodeId::parse(AMBNodeIds::RootCauseDataType_3),
            new Codecs\RootCauseDataTypeCodec(),
        );
    }

    public function getEnumMappings(): array
    {
        return [
            AMBNodeIds::MaintenanceMethodEnum => Enums\MaintenanceMethodEnum::class,
        ];
    }

    public function dependencyRegistrars(): array
    {
        return [];
    }
}

Three things to note:

  • The NodeId keys come from the same <Spec>NodeIds class the rest of the application uses. The _3 suffix here is the generator's disambiguator — see NodeId constants.
  • Codecs are instantiated eagerly — one new <Codec>() per registered structure. The cost is tiny (codecs are stateless), and the repository holds the instance for the client's lifetime.
  • dependencyRegistrars() returns instances, not class names. The generator instantiates dependency registrars when this method runs, so each call gets a fresh dependency tree.

What a codec looks like

php generated codec
namespace PhpOpcua\Nodeset\AMB\Codecs;

class NameNodeIdDataTypeCodec implements ExtensionObjectCodec
{
    public function decode(BinaryDecoder $decoder): NameNodeIdDataType
    {
        return new NameNodeIdDataType(
            $decoder->readLocalizedText(),
            $decoder->readNodeId(),
        );
    }

    public function encode(BinaryEncoder $encoder, mixed $value): void
    {
        $encoder->writeLocalizedText($value->Name);
        $encoder->writeNodeId($value->NodeId);
    }
}

The contract is PhpOpcua\Client\Encoding\ExtensionObjectCodec:

  • decode(BinaryDecoder) reads the wire bytes in field order and constructs the DTO with positional arguments.
  • encode(BinaryEncoder, mixed $value) does the inverse — reads the DTO's properties and writes them in order.

Codecs are stateless. The same instance can serve every read / write of the same DataType across the entire session.

What loadGeneratedTypes() actually does

ClientBuilder::loadGeneratedTypes() is the only entry point the application calls. It runs once, before connect():

text loading flow
$builder->loadGeneratedTypes(new AMBRegistrar())

  ├── Unless the registrar's $only flag is true, recursively call
  │   loadGeneratedTypes() on each dependencyRegistrars() entry

  ├── Call $registrar->registerCodecs($builder->getExtensionObjectRepository())
  │     ← codecs land in the per-client codec repository

  └── Merge $registrar->getEnumMappings() into the builder's
      enum registry
      ← consumed by Client when wrapping read responses

After this runs:

  • Every read() returning a Variant<ExtensionObject> whose typeId matches a registered codec returns a typed DTO via getValue().
  • Every read() returning a Variant<Int32> whose DataType matches a registered enum mapping returns the typed enum via getValue().

Multiple loadGeneratedTypes() calls accumulate — see Usage · Loading registrars.

Codecs and the per-client repository

The ExtensionObjectRepository is per Client instance, not global. Two clients in the same PHP process can carry different codec sets:

php examples/two-clients.php
$plcClient = ClientBuilder::create()
    ->loadGeneratedTypes(new MachineToolRegistrar())
    ->connect('opc.tcp://cnc.plant.local:4840');

$historianClient = ClientBuilder::create()
    ->loadGeneratedTypes(new MTConnectRegistrar())
    ->connect('opc.tcp://historian.plant.local:4840');

Two registries, no cross-contamination. The cost is the codec instances are duplicated in memory — negligible for the sizes involved.

Codecs that share types

A handful of specs duplicate well-known structures (e.g. several specs define their own KeyValuePair or RangeDataType). The generator emits each spec's codec independently, keyed by that spec's DataType NodeId. Loading two such registrars registers two codecs against two distinct NodeIds — no conflict.

If two specs ever defined codecs against the same NodeId (possible if the OPC Foundation ever consolidates), the later registerCodecs() call overwrites the earlier one. The order in which the application loads registrars determines the winner.

Empty registrars

Specs with no enums and no structures still ship a registrar — a no-op:

php empty registrar shape
class MachineryRegistrar implements GeneratedTypeRegistrar
{
    public function __construct(public bool $only = false) {}

    public function registerCodecs(ExtensionObjectRepository $repository): void
    {
        // Nothing to register
    }

    public function getEnumMappings(): array
    {
        return [];
    }

    public function dependencyRegistrars(): array
    {
        return [
            new \PhpOpcua\Nodeset\DI\DiRegistrar(),
            new \PhpOpcua\Nodeset\IA\IARegistrar(),
        ];
    }
}

These exist because the spec inherits from other specs that do register things. MachineryRegistrar loads DI and IA — which is the entire reason someone loads MachineryRegistrar at all.

Where to look next