Skip to content

Outputs

An output consumes the node stream and builds the result. Calling one runs the whole pipeline. Outputs are implemented as sinks; Tree has a method for each built-in one, and into($sink) takes any Sink.

Tree method Sink Result Memory
iterate(), foreach ($tree …) — Generator<int depth, mixed node> O(1) beyond the pipeline
toNestedArray() NestedArraySink list of roots with children O(output)
toFlatList() FlatListSink list<array{depth: int, node: mixed}> O(output)
toAdjacencyList() AdjacencyListSink pre-order list with rewritten parent ids O(output)
toNestedSet() NestedSetSink pre-order list with recomputed lft / rgt / level O(output)
count() CountSink int O(1)
into($sink) any Sink whatever the sink returns —
cursor() — the raw Cursor, for code that drives it itself —

None of them modify your payloads. Array rows are copied when a key is added; objects are wrapped or passed to your node factory.

Nested arrays

toNestedArray() returns a list of roots, each with its children.

  • Array payloads get a children key: ['id' => 1, …, 'children' => [...]].
  • Object payloads are wrapped in a TreeNode (entities are never modified).
  • A nodeFactory decides the shape itself.
use Ghispi\TreeTransform\Tree;

require __DIR__ . '/../vendor/autoload.php';

$rows = [
    ['id' => 1, 'title' => 'Clothing', 'lft' => 1, 'rgt' => 6],
    ['id' => 2, 'title' => 'Men',      'lft' => 2, 'rgt' => 3],
    ['id' => 3, 'title' => 'Women',    'lft' => 4, 'rgt' => 5],
];
$tree = Tree::fromNestedSet($rows)->map(fn(array $row) => ['id' => $row['id'], 'title' => $row['title']]);

echo "Default: every node gets a 'children' key\n";
echo json_encode($tree->toNestedArray()), "\n\n";

echo "omitEmptyChildren: true\n";
echo json_encode($tree->toNestedArray(omitEmptyChildren: true)), "\n\n";

echo "childrenField: '__children' (same shape as Gedmo buildTree())\n";
echo json_encode($tree->toNestedArray(childrenField: '__children')), "\n\n";

echo "nodeFactory: build any shape\n";
echo json_encode($tree->toNestedArray(nodeFactory: fn(array $node, array $children, int $depth) => [
    'text' => $node['title'],
    'level' => $depth,
    'nodes' => $children,
])), "\n";

Output:

Default: every node gets a 'children' key
[{"id":1,"title":"Clothing","children":[{"id":2,"title":"Men","children":[]},{"id":3,"title":"Women","children":[]}]}]

omitEmptyChildren: true
[{"id":1,"title":"Clothing","children":[{"id":2,"title":"Men"},{"id":3,"title":"Women"}]}]

childrenField: '__children' (same shape as Gedmo buildTree())
[{"id":1,"title":"Clothing","__children":[{"id":2,"title":"Men","__children":[]},{"id":3,"title":"Women","__children":[]}]}]

nodeFactory: build any shape
[{"text":"Clothing","level":0,"nodes":[{"text":"Men","level":1,"nodes":[]},{"text":"Women","level":1,"nodes":[]}]}]

Source: examples/outputs-nested-array.php

Option Type Default Effect
childrenField string 'children' Key for the children of array payloads. '__children' matches Gedmo's buildTree()
omitEmptyChildren bool false Leave the children key out of leaves
nodeFactory ?Closure(mixed $node, list<mixed> $children, int $depth): mixed null Builds each node. $children holds already-built children

The result is built bottom-up without recursion or PHP references. Memory: the size of the result.

Objects and TreeNode

Objects cannot get a children key, and the library does not modify them. Each one is wrapped in a readonly TreeNode with public node (the original object) and children (a list of TreeNode).

use Ghispi\TreeTransform\Sink\TreeNode;
use Ghispi\TreeTransform\Tree;

require __DIR__ . '/../vendor/autoload.php';

final readonly class Page
{
    public function __construct(
        public string $title,
        public string $path,
    ) {}
}

$pages = [
    new Page('About', '/about'),
    new Page('Team', '/about/team'),
    new Page('Jobs', '/about/jobs'),
    new Page('Blog', '/blog'),
];

// Objects are never modified: each one is wrapped in a TreeNode with its children.
$roots = Tree::fromMaterializedPath($pages, new Ghispi\TreeTransform\Source\MaterializedPathFields(separator: '/'))
    ->toNestedArray();

foreach ($roots as $root) {
    assert($root instanceof TreeNode);
    echo $root->node->title, ' (', count($root), ' children, leaf: ', var_export($root->isLeaf(), true), ")\n";
    foreach ($root as $child) { // iterates the direct children
        assert($child instanceof TreeNode);
        echo '  ', $child->node->title, "\n";
    }
}

Output:

About (2 children, leaf: false)
  Team
  Jobs
Blog (0 children, leaf: true)

Source: examples/outputs-objects.php

TreeNode is Countable (number of direct children) and iterable (the direct children), and has isLeaf(). For a different shape (a DTO, a view model), pass a nodeFactory, or map() objects to arrays first.

iterate

iterate() streams nodes in pre-order as depth => node. Nothing is collected, so it works for any tree size. A Tree is itself iterable and does the same.

use Ghispi\TreeTransform\Tree;

require __DIR__ . '/../vendor/autoload.php';

$rows = require __DIR__ . '/support/shop.php';

$tree = Tree::fromNestedSet($rows)->maxDepth(2);

// Keys are depths and repeat, so loop with foreach. iterator_to_array() would keep only one node per depth.
foreach ($tree->iterate() as $depth => $category) {
    echo str_repeat('    ', $depth), $category['title'], "\n";
}

// A Tree is iterable itself: foreach ($tree as $depth => $node) does the same.
echo "\nNodes: ", $tree->count(), "\n";

Output:

Shop
    Clothing
        Men
        Women
    Electronics
        Phones
        Laptops
    Garden
        Tools

Nodes: 9

Source: examples/outputs-iterate.php

Gotcha: keys are depths, so they repeat. iterator_to_array($tree->iterate()) keeps one node per depth. Use iterator_to_array($tree->iterate(), false) for a plain list of nodes, or toFlatList() to keep the depths.

To skip a subtree while iterating, drive the generator yourself and call send(true) instead of next(): it moves past the current node's descendants, and the source does not read them further (like skipSubtree() on a cursor).

use Ghispi\TreeTransform\Tree;

require __DIR__ . '/../vendor/autoload.php';

$rows = require __DIR__ . '/support/shop.php';

// A sitemap that does not descend into Electronics: send(true) instead of next() skips the current node's subtree.
// The source skips those rows without reading them further.
$nodes = Tree::fromNestedSet($rows)->iterate();
while ($nodes->valid()) {
    $depth = $nodes->key();
    $category = $nodes->current();
    echo str_repeat('    ', $depth), $category['title'], "\n";

    if ($category['title'] === 'Electronics') {
        $nodes->send(true);
    } else {
        $nodes->next();
    }
}

Output:

Shop
    Clothing
        Men
            Shirts
            Shoes
        Women
            Dresses
    Electronics
    Garden
        Tools

Source: examples/outputs-iterate-skip.php

Do not mix this with foreach: foreach calls next() on its own, so a send(true) inside it advances twice and drops the node after the skipped subtree. Start the generator (valid(), current() or key()) before the first send(), as the loop above does; on an unstarted generator PHP first runs to the first node and then skips it.

The output of iterate() can feed another pipeline: Tree::fromDepthList($tree->iterate()). See Sources → Depth lists.

Flat list

toFlatList() collects the same pre-order entries into an array of ['depth' => int, 'node' => mixed], e.g. for an indented <select> rendered by a template. Tree::fromFlatList() reads it back.

use Ghispi\TreeTransform\Tree;

require __DIR__ . '/../vendor/autoload.php';

$rows = require __DIR__ . '/support/shop.php';

$list = Tree::fromNestedSet($rows)
    ->maxDepth(1)
    ->map(fn(array $c) => $c['title'])
    ->toFlatList();

var_export($list);
echo "\n";

// Tree::fromFlatList() reads the same shape back.
echo implode(' / ', iterator_to_array(Tree::fromFlatList($list)->depthWindow(1)->iterate(), false)), "\n";

Output:

array (
  0 => 
  array (
    'depth' => 0,
    'node' => 'Shop',
  ),
  1 => 
  array (
    'depth' => 1,
    'node' => 'Clothing',
  ),
  2 => 
  array (
    'depth' => 1,
    'node' => 'Electronics',
  ),
  3 => 
  array (
    'depth' => 1,
    'node' => 'Garden',
  ),
)
Clothing / Electronics / Garden

Source: examples/outputs-flat-list.php

Adjacency list

toAdjacencyList() returns a pre-order list where each node's parent field points to its parent in the output. After Promote or depthWindow() that differs from the input.

use Ghispi\TreeTransform\Source\AdjacencyListFields;
use Ghispi\TreeTransform\Strategy\Promote;
use Ghispi\TreeTransform\Tree;

require __DIR__ . '/../vendor/autoload.php';

$rows = require __DIR__ . '/support/shop.php';

// Only categories with products, moved up to the nearest kept ancestor: parents are rewritten.
$list = Tree::fromNestedSet($rows)
    ->filter(fn(array $c) => $c['products'] > 0, new Promote())
    ->map(fn(array $c) => ['id' => $c['id'], 'title' => $c['title']])
    ->toAdjacencyList(new AdjacencyListFields('id', 'parent_id'), rootParent: 0);

foreach ($list as $row) {
    printf("%-3d %-8s parent_id: %d\n", $row['id'], $row['title'], $row['parent_id']);
}

// Objects cannot get a parent field set; build the row with a factory instead.
$pairs = Tree::fromNestedSet($rows)
    ->maxDepth(1)
    ->toAdjacencyList(nodeFactory: fn(array $c, int|string|null $parentId, int $depth) => "{$c['id']}->" . ($parentId ?? 'null'));
echo "\n", implode(', ', $pairs), "\n";

Output:

3   Men      parent_id: 0
4   Shirts   parent_id: 3
10  Cases    parent_id: 0
11  Laptops  parent_id: 0

1->null, 2->1, 8->1, 12->1

Source: examples/outputs-adjacency-list.php

Option Type Default Effect
fields AdjacencyListFields id, parent_id The id is read from id, the parent id written to parent (array payloads) — the same object fromAdjacencyList() takes
accessor Accessor AutoAccessor Reads the id
rootParent mixed null Parent value written for roots, e.g. 0
nodeFactory ?Closure(mixed $node, int\|string\|null $parentId, int $depth): mixed null Builds each output row. Required for object payloads

Object payloads without a nodeFactory throw InvalidArgumentException.

Nested set

toNestedSet() returns a pre-order list with recomputed left, right and level values — to store a filtered or restructured tree, or to hand it to code that expects nested sets.

use Ghispi\TreeTransform\Source\NestedSetFields;
use Ghispi\TreeTransform\Tree;

require __DIR__ . '/../vendor/autoload.php';

$rows = require __DIR__ . '/support/shop.php';

// Drop the Electronics branch and renumber: left/right/level are recomputed for the result.
$rows = Tree::fromNestedSet($rows)
    ->filter(fn(array $c) => $c['slug'] !== 'electronics')
    ->maxDepth(1)
    ->map(fn(array $c) => ['id' => $c['id'], 'title' => $c['title']])
    ->toNestedSet(new NestedSetFields(left: 'lft', right: 'rgt', level: 'lvl'), levelBase: 0);

foreach ($rows as $row) {
    printf("%-2d %-9s lft %2d  rgt %2d  lvl %d\n", $row['id'], $row['title'], $row['lft'], $row['rgt'], $row['lvl']);
}

// Several roots numbered from 1 each, with the id of each node's new root (Gedmo with a TreeRoot column).
$forest = Tree::fromNestedSet(require __DIR__ . '/support/shop.php')
    ->depthWindow(1, 1)
    ->map(fn(array $c) => ['id' => $c['id'], 'title' => $c['title']])
    ->toNestedSet(new NestedSetFields(root: 'root'));
echo "\n";
foreach ($forest as $row) {
    echo "{$row['title']}: {$row['lft']}..{$row['rgt']} root {$row['root']}\n";
}

Output:

1  Shop      lft  1  rgt  6  lvl 0
2  Clothing  lft  2  rgt  3  lvl 1
12 Garden    lft  4  rgt  5  lvl 1

Clothing: 1..2 root 2
Electronics: 1..2 root 8
Garden: 1..2 root 12

Source: examples/outputs-nested-set.php

Option Type Default Effect
fields NestedSetFields lft, rgt, level lvl, no root Fields written — the same object fromNestedSet() takes. level: null leaves the level out. With a root field every root's tree is numbered from 1 and each node gets the id of its root in the output (Gedmo with a TreeRoot column); NestedSetFields::gedmo() sets lft, rgt, root, lvl
levelBase int 0 Level of roots (Gedmo uses 0; some schemas 1)
nodeFactory ?Closure(mixed $node, int $left, int $right, int $depth, mixed $rootId): mixed null Builds each output row. Required for object payloads. $rootId is null without a root field
idField string 'id' Field the root id is read from (only with a root field)
accessor Accessor AutoAccessor Reads the root id

The root ids are those of the output roots: after depthWindow() or Promote, nodes move to new roots, and a round trip through NestedSetFields::gedmo() stays valid. Every root needs an int or string id in idField (two consecutive roots with the same id, or a root without one, throw InvalidArgumentException): set idField when your id column is not id. A column that groups several roots, such as a menu id with one numbering per menu, is not a root field for the sink — leave root out and keep the column with map().

The right value of a node is known only when its subtree ends, so the whole result is collected first. Memory: O(output).

count

count() returns the number of nodes in the result without collecting them.

$visibleCategories = Tree::fromNestedSet($rows)->filter($isVisible)->count();

Your own sink

into() accepts any Sink, for example one that renders HTML in a single pass: see Extending → Sink.