symfony-opcua · v4.3.x
Docs · Recipes

Using companion specs

Load OPC UA companion specifications via opcua-client-nodeset for type-aware browsing — MachineTool, PackML, DI, and friends.

OPC UA companion specifications define typed node hierarchies for specific industries: MachineTool, PackML, Robotics, DI (Device Information). The opcua-client-nodeset package gives type-aware access — and the Symfony bundle picks it up automatically.

Install

bash terminal
composer require php-opcua/opcua-client-nodeset

The package's discovery mechanism auto-registers nodesets from its bundled directory. No additional config.

Without companion specs — raw browse

php raw
$nodes = $this->client->browseRecursive('ns=4;s=MachineTool', maxDepth: 5);
// array of ReferenceDescription — generic

With companion specs — typed

php typed
use PhpOpcua\Client\Nodeset\MachineTool\MachineToolType;

$machine = $this->client->nodeset(MachineToolType::class, 'ns=4;s=MachineTool');

$alarms     = $machine->getAlarms();         // array of MachineToolAlarm
$production = $machine->getProduction();      // ProductionType
$equipment  = $machine->getEquipment();        // ToolListType

// No string-fiddling, no walking the address space

The PHP classes correspond to the spec's ObjectType hierarchy.

Available specs

Companion spec PHP namespace Use case
DI PhpOpcua\Client\Nodeset\Di\ Generic device information
MachineTool PhpOpcua\Client\Nodeset\MachineTool\ CNCs, lathes, mills
Robotics PhpOpcua\Client\Nodeset\Robotics\ Industrial robots
PackML PhpOpcua\Client\Nodeset\PackML\ Packaging machinery
Machinery PhpOpcua\Client\Nodeset\Machinery\ Generic industrial machinery

Plus several more — see the opcua-client-nodeset README.

End-to-end — MachineTool production monitor

php src/Entity/MachineToolReading.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class MachineToolReading
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    public ?int $id = null;

    #[ORM\Column(type: 'string', length: 255)]
    public string $machineId;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    public ?string $activeProgram = null;

    #[ORM\Column(type: 'string', length: 100, nullable: true)]
    public ?string $activeTool = null;

    #[ORM\Column(type: 'string', length: 50, nullable: true)]
    public ?string $operationMode = null;

    #[ORM\Column(type: 'integer', nullable: true)]
    public ?int $partCount = null;

    #[ORM\Column(type: 'datetime_immutable')]
    public \DateTimeImmutable $readAt;
}
php src/Command/PollMachineToolCommand.php
namespace App\Command;

use App\Entity\MachineToolReading;
use Doctrine\ORM\EntityManagerInterface;
use PhpOpcua\Client\Nodeset\MachineTool\MachineToolType;
use PhpOpcua\SymfonyOpcua\OpcuaManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputArgument, InputInterface};
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:machine:poll')]
final class PollMachineToolCommand extends Command
{
    public function __construct(
        private OpcuaManager $opcua,
        private EntityManagerInterface $em,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument('machine-id', InputArgument::REQUIRED, 'MachineTool root NodeId');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $id = $input->getArgument('machine-id');
        $machine = $this->opcua->connect()->nodeset(MachineToolType::class, $id);

        $production = $machine->getProduction();

        $reading = (new MachineToolReading())
            ->setMachineId($id)
            ->setActiveProgram($production->getActiveProgram()?->getName()->value)
            ->setActiveTool($production->getActiveTool()?->getName()->value)
            ->setOperationMode($production->getOperationMode()->value)
            ->setPartCount((int) $production->getPartCount()->value)
            ->setReadAt(new \DateTimeImmutable());

        $this->em->persist($reading);
        $this->em->flush();

        $output->writeln("Recorded {$reading->id}");
        return Command::SUCCESS;
    }
}

Schedule every minute via Symfony Scheduler.

Type discovery

bash terminal
php bin/console debug:autowiring nodeset

…or in tinker-style:

php reflection
$methods = get_class_methods(PhpOpcua\Client\Nodeset\MachineTool\MachineToolType::class);
print_r($methods);

The classes are generated with rich docblocks listing every typed property.

Type-narrowing on alarms

php typed alarms
use PhpOpcua\Client\Nodeset\MachineTool\AxisAlarm;
use PhpOpcua\Client\Nodeset\MachineTool\MachineToolAlarm;

foreach ($machine->getAlarms() as $alarm) {
    if ($alarm instanceof AxisAlarm) {
        echo "Axis {$alarm->getAxisId()->value}: {$alarm->getMessage()->value}\n";
    }
}

instanceof narrowing lets you handle subtypes specifically.

Subscribing to typed events

The subscription side uses the raw createSubscription() / createEventMonitoredItem() API; the listener decodes with opcua-client-nodeset helpers:

php typed event listener
namespace App\EventListener;

use App\Entity\AxisAlarm as AxisAlarmEntity;
use Doctrine\ORM\EntityManagerInterface;
use PhpOpcua\Client\Event\EventNotificationReceived;
use PhpOpcua\Client\Nodeset\MachineTool\AlarmDecoder;
use PhpOpcua\Client\Nodeset\MachineTool\AxisAlarm;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

final class HandleMachineToolAlarm
{
    public function __construct(private EntityManagerInterface $em) {}

    #[AsEventListener]
    public function __invoke(EventNotificationReceived $event): void
    {
        $f = $event->eventFields;
        if (empty($f['EventType'])) return;

        $alarm = AlarmDecoder::decode($event);

        if ($alarm instanceof AxisAlarm) {
            $entity = (new AxisAlarmEntity())
                ->setClientHandle($event->clientHandle)
                ->setAxisId($alarm->axisId)
                ->setMessage($alarm->message)
                ->setSeverity((int) ($f['Severity'] ?? 0));

            $this->em->persist($entity);
            $this->em->flush();
        }
    }
}

Custom companion specs

For internal / proprietary types:

php src/Opcua/Nodeset/Acme/AcmeReactorType.php
namespace App\Opcua\Nodeset\Acme;

use PhpOpcua\Client\Nodeset\BaseNodesetType;
use PhpOpcua\Client\Types\DataValue;

class AcmeReactorType extends BaseNodesetType
{
    public function getTemperature(): ?DataValue
    {
        return $this->readChild('Temperature');
    }

    public function getPressure(): ?DataValue
    {
        return $this->readChild('Pressure');
    }

    public function getState(): string
    {
        return (string) $this->readChild('State')->value;
    }
}

Use identically:

php usage
$reactor = $this->client->nodeset(
    \App\Opcua\Nodeset\Acme\AcmeReactorType::class,
    'ns=2;s=Reactor1',
);

echo $reactor->getTemperature()->value;

Trade-off

Approach Pros Cons
Raw browse / read Universal — works against any OPC UA server String-fiddling, no type safety
Companion specs Type-safe, IDE auto-complete, idiomatic Only works against spec-conformant servers

If your servers conform (Siemens, Beckhoff, Rockwell), specs are dramatically nicer. If bespoke, raw is fine.

Performance

A typed accessor reads the underlying node lazily. $machine->getProduction() is one round-trip; $production->getActiveProgram() is another. For multi-property reads, the typed API hides batching — internally uses executeMany().

For known property sets, snapshot:

php snapshot
$snapshot = $machine->snapshot([
    'production.active_program',
    'production.active_tool',
    'production.operation_mode',
    'production.part_count',
]);
// One round-trip; $snapshot is an array of resolved values
Documentation