Tree Exporters
Read-only formatters that render a node (or whole forest) as Mermaid, Graphviz DOT, ASCII tree, or nested JSON. Useful for:
- Debugging.
dd($root->toAsciiTree())when a tree assertion fails. - Docs. Paste a
toMermaid()snippet into a markdown file and GitHub renders the diagram. - Frontend handoff.
toJsonTree()returns the shapejsTree,react-arborist, andvue-treeaccept directly.
Every exporter is a pure function of state already on the model — no mutation, no schema changes. Each instance method runs one lft-ordered query for the subtree, then folds in PHP.
1. ASCII tree
use Vusys\NestedSet\Export\AsciiOptions;
echo $electronics->toAsciiTree();
// Electronics
// ├── Laptops
// └── Phones
// ├── iPhone
// └── Android
echo $electronics->toAsciiTree(new AsciiOptions(unicode: false));
// Electronics
// |-- Laptops
// `-- Phones
// |-- iPhone
// `-- Android
AsciiOptions knobs:
| Field | Type | Default | What it does |
|---|---|---|---|
unicode |
bool |
true |
Box-drawing glyphs (├ └ │) vs plain ASCII (|-- \-- | `). |
label |
Closure(Model): string|int|float|null |
fn ($n) => $n->name |
Render each row. Falls back to the primary key if the closure throws or returns null. |
showDepth |
bool |
false |
Append (depth=N) to each label. |
maxDepth |
?int |
null |
Truncate beyond depth N (counted from the export root). Composes with filter — the stricter depth wins. |
withTrashed |
bool |
false |
Include soft-deleted descendants. |
filter |
?WalkFilter |
null |
Predicate-based pruning. See Walking Subtrees → Pruning with WalkFilter. |
2. Mermaid
use Vusys\NestedSet\Export\MermaidOptions;
echo $electronics->toMermaid();
// graph TD
// n1["Electronics"]
// n2["Laptops"]
// n3["Phones"]
// n4["iPhone"]
// n5["Android"]
// n1 --> n2
// n1 --> n3
// n3 --> n4
// n3 --> n5
Paste the output into a markdown ```mermaid block and GitHub renders it. Direction defaults to TD (top-down); pass LR, BT, or RL to flip.
Aggregate columns can ride along on each label:
$electronics->toMermaid(new MermaidOptions(
showAggregates: ['products_total', 'products_count'],
));
// graph TD
// n1["Electronics<br/>products_total: 23<br/>products_count: 17"]
// ...
MermaidOptions knobs:
| Field | Type | Default | What it does |
|---|---|---|---|
direction |
'TD'|'LR'|'BT'|'RL' |
'TD' |
Graph direction. |
label |
Closure(Model): ... |
fn ($n) => $n->name |
Per-node label. |
showId |
bool |
false |
Append (id=N) to each label. |
showAggregates |
list<string> |
[] |
Aggregate columns appended as extra label lines. |
withTrashed |
bool |
false |
Include soft-deleted descendants. |
filter |
?WalkFilter |
null |
Predicate-based pruning. See Walking Subtrees → Pruning with WalkFilter. |
Special characters in labels are HTML-escaped (" → ", < → <, newlines → <br/>). UUID primary keys hash to a short alphanumeric prefix (n4a91f2c0) so the identifiers stay valid Mermaid syntax.
3. Graphviz / DOT
use Vusys\NestedSet\Export\DotOptions;
file_put_contents('tree.dot', $electronics->toDot());
// dot -Tpng tree.dot -o tree.png
Output:
digraph tree {
rankdir=TB;
node [shape=box];
"n1" [label="Electronics"];
"n2" [label="Laptops"];
"n3" [label="Phones"];
"n1" -> "n2";
"n1" -> "n3";
}
DotOptions mirrors MermaidOptions (direction defaults to TB, and the same filter: ?WalkFilter field is supported). Quotes and backslashes in labels are DOT-escaped.
4. JSON
use Vusys\NestedSet\Export\JsonOptions;
return response()->json($electronics->toJsonTree());
Output shape:
{
"id": 1,
"label": "Electronics",
"children": [
{ "id": 2, "label": "Laptops", "children": [] },
{
"id": 3,
"label": "Phones",
"children": [
{ "id": 4, "label": "iPhone", "children": [] },
{ "id": 5, "label": "Android", "children": [] }
]
}
]
}
The method is named toJsonTree() (not toJson()) because Eloquent already defines Model::toJson(int $options) for serialising one row.
JsonOptions knobs:
| Field | Type | Default | What it does |
|---|---|---|---|
label |
Closure(Model): ... |
fn ($n) => $n->name |
Per-node label. |
extras |
list<string> |
[] |
Raw column names to copy onto each payload (e.g. ['lft', 'rgt', 'depth']). |
childrenKey |
string |
'children' |
Rename the children array key ('items', 'nodes', …). |
withTrashed |
bool |
false |
Include soft-deleted descendants. |
filter |
?WalkFilter |
null |
Predicate-based pruning. See Walking Subtrees → Pruning with WalkFilter. |
5. JSON import — fromJsonTree
fromJsonTree() is the inverse of toJsonTree() / toJsonTreeForest(). It accepts the exporter's shape (or a JSON string of the same shape) and inserts the payload as new rows under a parent — or as new roots when no parent is given:
use Vusys\NestedSet\Import\JsonImportOptions;
// Decoded array form.
Category::fromJsonTree([
'name' => 'Electronics',
'children' => [
['name' => 'Laptops'],
['name' => 'Phones', 'children' => [
['name' => 'iPhone'],
['name' => 'Android'],
]],
],
]);
// JSON string form.
Category::fromJsonTree($request->getContent());
// Append under an existing node.
Category::fromJsonTree($payload, parent: $electronics);
The payload may be a single node dict (one tree) or a list of dicts (a forest); both shapes round-trip with toJsonTree() / toJsonTreeForest(). Structural columns (lft / rgt / depth / parent_id) are stripped before insert and regenerated by the package. Primary keys are regenerated unless includeKeys: true is set on the import options. Returns the inserted nodes as a collection in DFS pre-order.
JsonImportOptions knobs:
| Field | Type | Default | What it does |
|---|---|---|---|
childrenKey |
string |
'children' |
Name of the children array key in the payload. Must match the export's childrenKey. |
strict |
bool |
true |
Reject unknown columns (not on the model and not in ignoreColumns). Set to false to silently drop extras like id, lft, exported extras you don't want to import. |
transform |
Closure(array, int): array | null |
null |
Rewrite each row's attributes before insert. Receives raw payload + destination depth (0 = clone root). Useful for namespacing slugs, defaulting columns, or stripping metadata. |
includeKeys |
bool |
false |
When true, preserve id values from the payload instead of regenerating them. Collisions throw JsonImportKeyCollisionException. |
ignoreColumns |
list<string> |
['lft', 'rgt', 'depth', 'parent_id'] |
Columns silently dropped from each row before insert. |
Invalid payload shape (missing children array, non-string keys, non-array root) throws InvalidJsonTreeException. Strict mode also throws on unknown columns. The whole import runs in one transaction — partial inserts never escape on failure.
For models declaring #[NestedSetScope], pass a $parent in the target scope; importing as a root in a scoped model isn't supported (the scope columns must come from somewhere). For unscoped models, omit $parent to import every top-level dict as a new root.
6. Whole-tree exports
The instance methods above render $node + its descendants. To export every root in the table, use the *Forest static methods:
Category::toMermaidForest();
Category::toDotForest();
Category::toAsciiTreeForest();
Category::toJsonTreeForest();
Warning
toJsonTreeForest() returns a single root dict when exactly one root exists, and a list of root dicts when there's more than one — the shape depends on the data. Callers that pass the result straight to a frontend that expects a stable contract will break the first time a second root appears. Normalise with array_values((array) $result) if you want a list always, or assert count($roots) === 1 upstream. The instance toJsonTree() always returns a single dict (one node, one tree); the shape problem only applies to the forest variant.
7. Scoped (multi-tree) models
For models that declare #[NestedSetScope(...)], the *Forest variants walk every tree across every scope. To render just one tree, pass the scope value. All four exporters have a *Scope variant:
MenuItem::toMermaidScope(7);
MenuItem::toDotScope(7);
MenuItem::toAsciiTreeScope(7, new AsciiOptions(unicode: false));
MenuItem::toJsonTreeScope(7);
Multi-column scopes accept an array:
Comment::toMermaidScope(['tenant_id' => 42, 'post_id' => 99]);
Calling *Scope on an unscoped model throws LogicException — use the *Forest variant instead.
8. Edge cases
8.1 Soft deletes
Excluded by default. Pass withTrashed: true on any options object to include them; combine with a custom label closure if you want to mark them visually ([deleted] {$name}).
8.2 Unplaced node
Where lft = rgt = 0. The descendants query returns nothing, so the export is just the root row.
8.3 Cyclic parent_id
Detected during the fold; throws Vusys\NestedSet\Exceptions\CorruptTreeException rather than infinite-looping. This only triggers on corrupt data — see Tree Repair.
8.4 Label fallback
If the label closure throws or returns null / '' / a non-stringable object, the renderer falls back to the primary key.
8.5 Large trees
The exporters are debug / UI tools, not analytics pipelines — they load the whole subtree into memory. Use maxDepth for ASCII rendering, or paginate by anchor for forests larger than ~10k nodes.