Browsing recursively
browseRecursive() walks a subtree in memory. For wide address spaces, swap it for a streaming traversal that does not blow up on a 50 000-node tree.
browseRecursive() is the right call when you want the whole subtree
in memory at once — a configuration screen, a one-time inventory
dump, a small tree on a tame server. For anything larger, you need a
streaming approach: walk node-by-node, process as you go, never hold
the whole tree at once.
browseRecursive() returns the array of BrowseNode rooted at the
immediate children of the starting node, not a single wrapper node.
A walker has to iterate the array; passing the array directly to a
function that expects a single BrowseNode is the most common usage
bug.
The simple case
use PhpOpcua\Client\Types\BrowseNode;
use PhpOpcua\Client\Types\NodeClass;
$roots = $client->browseRecursive(
'ns=2;s=Devices',
maxDepth: 3,
nodeClasses: [NodeClass::Object, NodeClass::Variable],
);
foreach ($roots as $root) {
walk($root, depth: 0);
}
function walk(BrowseNode $node, int $depth): void
{
echo str_repeat(' ', $depth) . $node->reference->displayName->text . "\n";
foreach ($node->getChildren() as $child) {
walk($child, $depth + 1);
}
}
maxDepth: 3 is a memory cap, not a feature decision. The default
(set via setDefaultBrowseMaxDepth(), ships at 4) is fine for the
"I want to see what's in there" case; lower it when you only need the
top level.
nodeClasses takes an array of NodeClass enum cases, not an
integer bitmask. An empty array (the default) returns all classes.
browseRecursive() has cycle detection — references back to an
ancestor stop the recursion. Useful, because the OPC UA address space
graph is not a tree.
Streaming traversal
When the subtree might be large, switch to a generator:
use PhpOpcua\Client\Types\NodeId;
use PhpOpcua\Client\Types\NodeClass;
use PhpOpcua\Client\Types\ReferenceDescription;
/**
* Yield every Variable node under $rootNodeId, depth-first, with
* cycle detection. Memory is O(depth × children-per-level), not O(tree).
*/
function streamVariables($client, NodeId|string $rootNodeId): \Generator
{
$stack = [$client->resolveNodeId((string) $rootNodeId)];
$visited = [];
while ($stack !== []) {
$node = array_pop($stack);
$key = (string) $node;
if (isset($visited[$key])) {
continue;
}
$visited[$key] = true;
foreach ($client->browseAll($node, nodeClasses: [NodeClass::Variable]) as $ref) {
yield $ref;
}
foreach ($client->browseAll($node, nodeClasses: [NodeClass::Object]) as $ref) {
$stack[] = $ref->nodeId;
}
}
}
foreach (streamVariables($client, 'ns=2;s=Plant') as $variable) {
processOne($variable); // your code here
}
The pattern:
- One
browseAll()per node visited — the library handles continuation points internally. - The generator yields one
ReferenceDescriptionat a time. Caller controls memory. - Explicit visited set short-circuits cycles. Without it, you can loop indefinitely if the server publishes a cyclic graph (rare but legal).
- Two separate browse calls per node — one for variables (yield), one for objects (recurse). Cuts wire traffic when you only care about variables.
When the tree is really big
For multi-tenant plants — a server publishing hundreds of devices each with a thousand tags — even the streaming generator is too greedy. The right pattern is to bound the depth at each level and queue rather than recurse:
$queue = new SplQueue();
$queue->enqueue(['node' => 'ns=2;s=Plant', 'depth' => 0]);
$visited = [];
while (! $queue->isEmpty()) {
['node' => $node, 'depth' => $depth] = $queue->dequeue();
$key = (string) $node;
if ($depth > MAX_DEPTH || isset($visited[$key])) {
continue;
}
$visited[$key] = true;
foreach ($client->browseAll($node) as $ref) {
processOne($ref);
if ($ref->nodeClass === NodeClass::Object) {
$queue->enqueue(['node' => $ref->nodeId, 'depth' => $depth + 1]);
}
}
}
Breadth-first lets you stop at a fixed depth without losing nodes at that depth — useful for "show me the first 3 levels".
Cache interaction
Every browse() / browseAll() call hits the PSR-16 cache by
default. A recursive walk benefits enormously: the second walk over
the same subtree (no schema change in between) is mostly cache hits.
For a one-off inventory job:
- Leave caching on (
InMemoryCacheis the default). - If the job is in a short-lived script, the cache adds no value — the second walk does not happen — but it also adds negligible cost.
For a periodic discovery worker:
- Use a persistent cache (
FileCache, Redis) viasetCache(). The cost amortises across runs. - Invalidate on schema deploys with
$client->invalidateCache($root)or$client->flushCache().
Choosing a nodeClassMask
OPC UA browse returns Variables, Objects, Methods, Views, Types — by default, all of them, including the type definitions of every node visited. That balloons the result set.
Two common masks:
// Just folders and devices — for tree navigation
$mask = NodeClass::Object->value;
// Just data — for inventory of every tag in a plant
$mask = NodeClass::Variable->value;
// Both — when you want the structure and the leaves
$mask = NodeClass::Object->value | NodeClass::Variable->value;
Cutting Type and Method from the mask typically cuts result-set
size by 30-60% on industrial servers.
Cycle detection in browseRecursive
The built-in browseRecursive() tracks NodeIds it has already
visited and short-circuits. The streaming patterns above replicate
that — do not assume the server publishes a tree. Two real-world
shapes that loop:
- An object has a
HasNotifierreference back to its parent device. - A type definition references itself transitively through
HasSubtypeandHasComponent.
Without cycle detection, both walk forever.
Performance numbers (ballpark)
Real-world numbers from the library's integration suite (LAN attached UA-.NETStandard server):
| Subtree size | browseRecursive() |
Streaming generator |
|---|---|---|
| 100 nodes | ~50 ms | ~60 ms |
| 1 000 nodes | ~400 ms | ~500 ms |
| 10 000 nodes | ~5 s, ~40 MB RAM | ~6 s, <2 MB RAM |
| 100 000 nodes | OOM at default settings | ~60 s, <2 MB RAM |
The streaming pattern is roughly the same speed (the wire traffic dominates), but holds constant memory.
When not to walk
If the address space is well-known and stable, hardcode the NodeIds
in configuration. The library is fast enough that resolving them at
startup with resolveNodeId() is fine; recursive discovery is for
inventories and unknown servers.