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

Browsing

Walking the OPC UA address space. Flat browse, recursive browse, filtering — and pragmatic Symfony patterns for tag discovery on schedule.

OPC UA models the device as a graph of typed nodes connected by references. Browsing is how you discover what's available.

Flat browse

browse() has a wider signature than the one-liner:

browse(
    NodeId|string $nodeId,
    BrowseDirection $direction = BrowseDirection::Forward,
    bool $includeSubtypes = true,
    int $nodeClassMask = 0,        // 0 = all
    bool $useCache = true,
): array;  // ReferenceDescription[]
php basic browse
$nodes = $this->client->browse('ns=0;i=85');  // Objects folder, defaults
foreach ($nodes as $node) {
    echo "{$node->browseName}{$node->nodeId}\n";
}

Each entry is a ReferenceDescription:

Field Meaning
nodeId OPC UA NodeId of the target
browseName Programmatic name
displayName Human-readable label
nodeClass Object / Variable / Method / View / ...
referenceType The reference linking source → target
typeDefinition Type of the target

Recursive browse

php recursive
$tree = $this->client->browseRecursive(
    'ns=4;s=Tags',
    maxDepth: 5,                   // named arg — 3rd positional
);

foreach ($tree as $entry) {
    echo str_repeat('  ', $entry->depth) . $entry->browseName . "\n";
}

browseRecursive signature: browseRecursive(NodeId|string $nodeId, BrowseDirection $direction = Forward, ?int $maxDepth = null, ?NodeId $referenceTypeId = null, bool $includeSubtypes = true, array $nodeClasses = []): arraymaxDepth is the 3rd positional argument, so use it as a named arg when you skip $direction. Default depth (null) honours the bundle's browse_max_depth config (default 10).

Filtered browse

Pass the $nodeClassMask as an int bitmask built from NodeClass enum values. There is no fluent ->referenceType() or ->nodeClass() builder.

php filtered
use PhpOpcua\Client\Types\BrowseDirection;
use PhpOpcua\Client\Types\NodeClass;

$variables = $this->client->browse(
    'ns=2;s=Folder',
    BrowseDirection::Forward,
    true,                              // includeSubtypes
    NodeClass::Variable->value,        // only variables
);

Common reference types:

Reference type Meaning
Organizes Folder ↔ children
HasComponent Composition (object has parts)
HasProperty Variable property attached to a node
HasTypeDefinition Instance ↔ its type
HasSubtype Type hierarchy

For user-facing tag browsing, Organizes + Variable gives the cleanest list.

Discovery on schedule — fill a Doctrine table

Browse once a day, persist to plc_tags:

php src/Command/DiscoverTagsCommand.php
namespace App\Command;

use App\Entity\PlcTag;
use Doctrine\ORM\EntityManagerInterface;
use PhpOpcua\Client\Types\NodeClass;
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:plc:discover')]
final class DiscoverTagsCommand extends Command
{
    public function __construct(
        private OpcuaManager $opcua,
        private EntityManagerInterface $em,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('connection', InputArgument::OPTIONAL, '', 'default')
            ->addArgument('root',       InputArgument::OPTIONAL, '', 'ns=4;s=Tags');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $tree = $this->opcua->connect($input->getArgument('connection'))
            ->browseRecursive($input->getArgument('root'), maxDepth: 10);

        $output->writeln('Found ' . count($tree) . ' nodes');

        foreach ($tree as $entry) {
            if ($entry->nodeClass !== NodeClass::Variable) continue;

            $tag = $this->em->getRepository(PlcTag::class)
                ->findOneBy(['nodeId' => $entry->nodeId])
                ?? new PlcTag();

            $tag->setNodeId($entry->nodeId);
            $tag->setBrowseName($entry->browseName);
            $tag->setDisplayName($entry->displayName);
            $tag->setParentNodeId($entry->parentNodeId);
            $tag->setLastSeenAt(new \DateTimeImmutable());

            $this->em->persist($tag);
        }

        $this->em->flush();
        return Command::SUCCESS;
    }
}

Run on a schedule via Symfony Scheduler:

php src/Scheduler/PlcSchedule.php
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Component\Console\Messenger\RunCommandMessage;

#[AsSchedule('plc')]
final class PlcSchedule implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return (new Schedule())
            ->add(RecurringMessage::cron('0 3 * * *', new RunCommandMessage('app:plc:discover')));
    }
}

Discovery runs at 03:00 daily.

Listing only writable nodes

A UI need: show the operator which tags are writable.

php writable filter
use PhpOpcua\Client\Types\AttributeId;
use PhpOpcua\Client\Types\BrowseDirection;
use PhpOpcua\Client\Types\NodeClass;

$variables = $this->client->browse(
    'ns=2;s=Folder',
    BrowseDirection::Forward,
    true,
    NodeClass::Variable->value,
);

$writable = [];
foreach ($variables as $node) {
    $access = $this->client->read($node->nodeId, AttributeId::AccessLevel);

    if (($access->getValue() & 0b10) !== 0) {   // CurrentWrite bit
        $writable[] = $node;
    }
}

AccessLevel bits:

Bit Name
0 CurrentRead
1 CurrentWrite
2 HistoryRead
3 HistoryWrite
4 SemanticChange
5 StatusWrite
6 TimestampWrite

Reverse browse

Walk upward from a node to its parents — useful for breadcrumbs:

php inverse browse
use PhpOpcua\Client\Types\BrowseDirection;

$parents = $this->client->browse(
    'ns=2;s=Speed',
    BrowseDirection::Inverse,
);

Translate browse paths

Server-side resolution of a browse path string to a NodeId:

php translate
$results = $this->client->translateBrowsePaths([
    [
        'startingNode' => 'ns=2;s=Folder',
        'relativePath' => '/Subfolder/Speed',
    ],
]);

// $results is an array of BrowsePathResult — each carries
// $statusCode and $targets (TargetId[]).
$nodeId = (string) $results[0]->targets[0]->targetId;

The method is translateBrowsePaths (plural, array argument). Passing null returns a BrowsePathsBuilder for ergonomic construction.

Performance

Scope Round-trips
Flat browse, ~10 children 1
Filtered browse 1
Recursive ~100 nodes 5-10 (chunked at MaxNodesPerBrowse)
Whole address space Don't do this from a request — schedule it

Don't browse on every request. Discovery is a periodic activity; results live in your Doctrine schema or Symfony cache.

Caching browse results

If you must browse live (operator-driven address-space exploration), cache aggressively:

php cached browse
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

public function children(string $nodeId): array
{
    return $this->cache->get(
        'opcua.browse.' . hash('xxh3', $nodeId),
        function (ItemInterface $item) use ($nodeId) {
            $item->expiresAfter(3600);   // 1 hour
            return $this->client->browse($nodeId);
        },
    );
}

PLC tag structure changes on the order of weeks — cache for hours, not seconds.

Browsing via companion-spec types

If opcua-client-nodeset is installed (see Recipes · Using companion specs), type-aware browsing becomes available — strongly-typed accessors on MachineToolType, PackMLType, etc.

Documentation