Skip to content

Tree

The tree package renders hierarchies from inline nodes, callbacks, or Eloquent adjacency-list sources — with full keyboard navigation (roving tabindex, typeahead), per-node icons, badges, links, and actions, and lazy child loading over a signed endpoint. Registered Lattice actions handle selection and optimistic drag-and-drop moving without coupling the package to application persistence.

Terminal window
composer require lattice-php/tree

That is the whole integration: the package ships its React renderer as source, and the lattice() Vite plugin compiles it into your app’s bundle via virtual:lattice/plugins (see Component packages). The PHP classes are picked up by Lattice’s discovery and TypeScript generation automatically. No-build apps use the precompiled module the package also ships: run php artisan lattice:assets after installation.

Tree::make('catalog')->nodes([
TreeNode::make('products', 'Products')
->badge('24', ColorName::Info)
->children([
TreeNode::make('hardware', 'Hardware')->children([
TreeNode::make('laptops', 'Laptops'),
TreeNode::make('phones', 'Phones'),
]),
TreeNode::make('software', 'Software'),
]),
TreeNode::make('services', 'Services')->badge('8'),
])->defaultExpanded(['products', 'hardware']);

Nodes compile their conveniences (->icon(), ->badge(), ->href(), ->action()/->actions()) into a canonical body schema of core components — an icon, a text-or-link label, a badge, and an end-floated action stack. ->badge($label, $color) accepts any Lattice color.

->class() adds classes to the node’s <li> element — the wrapper around both the row and its children group — so styling a parent together with its subtree (a frame around a section and its lines, say) needs no :has() selectors against the tree’s internal markup. The array shorthand accepts it as a class key.

For content the conveniences do not cover, ->schema() replaces the composed body outright:

use Lattice\Ui\Components\Avatar;
use Lattice\Ui\Components\Badge;
use Lattice\Ui\Components\Text;
TreeNode::make('acme-corp', 'Acme Corp')
->schema([
Avatar::make('/avatars/acme.png'),
Text::make('Acme Corp'),
Badge::make('Pro')->color('purple'),
]);

Interactive form controls (input, textarea, select, contenteditable regions, and their labels) are first-class inside ->schema(): clicks, keystrokes, and drag gestures that originate in a control stay with the control instead of driving the tree. Clicking into an inline input does not select the node or steal its focus, typing does not trigger typeahead or Ctrl+Shift+Arrow moves, and selecting text inside it does not start a node drag. Controls also keep their natural tab stop — only buttons and links in a node body are removed from the page tab order.

Back a tree with an Eloquent adjacency list (a self-referencing parent_id column):

use Lattice\Tree\EloquentTreeSource;
Tree::make('categories')->source(
EloquentTreeSource::make(Category::class)
->orderBy('sort_order')
->map(fn (Category $category, TreeNode $node) => $node
->badge((string) $category->products_count)
->disabled(! $category->is_active)
->href(route('categories.show', $category))),
);

->orderBy() orders roots and every sibling group by that column, followed by the label and ID for deterministic ties. Without it, label and ID remain the default ordering. The single ->map() callback receives the model and its base TreeNode, applies equally to eager and lazy results, and may use any builder method — including ->class() and ->schema().

Any other backing store implements the two-method Lattice\Tree\TreeSource contract.

Attach registered Lattice actions to receive generic interaction payloads:

Tree::use(CategoryTree::class)
->activeId(request()->string('category')->toString() ?: null)
->selectAction(SelectCategory::class) // { nodeId }
->moveAction(MoveCategory::class); // { nodeId, parentId, position }

Clicking a row selects it; expander, link, and node-action clicks keep their own behavior. The active row updates optimistically and follows later activeId props, so a URL parameter can remain the authoritative selection.

moveAction() enables pointer moving between parents and the root. The zero-based position is the node’s final sibling position. The client prevents disabled and cyclic drops, rolls back rejected requests, and offers Ctrl+Shift+Arrow keys: Up/Down reorder, Right indents, and Left outdents.

Every moveAction handler ends up re-implementing the same splice-and-resequence algorithm. AdjacencyListMovePlanner owns it as a pure function: give it the current placements of one scope — id, parent id, position per node — plus the wire payload, and it returns only the placements that must change, with contiguous zero-based positions in both the source and the destination sibling group. It returns null when the move is structurally impossible: unknown node, unknown destination parent, self-parent, or a destination inside the moved node’s own subtree. A move that changes nothing returns an empty plan. The planner never writes anything — persistence, locking, and domain rules (max depth, locked records, tenancy) stay with your application:

use Lattice\Tree\Support\AdjacencyListMovePlanner;
use Lattice\Tree\Support\NodePlacement;
public function handle(Request $request): ActionResult
{
$payload = $request->validate([
'nodeId' => ['required', 'string'],
'parentId' => ['nullable', 'string'],
'position' => ['required', 'integer', 'min:0'],
]);
return DB::transaction(function () use ($payload): ActionResult {
$categories = Category::query()->lockForUpdate()->get();
$plan = AdjacencyListMovePlanner::plan(
$categories->map(fn (Category $category): NodePlacement => new NodePlacement(
$category->id, $category->parent_id, $category->sort_order,
)),
$payload['nodeId'],
$payload['parentId'] ?? null,
$payload['position'],
);
if ($plan === null) {
return ActionResult::failure('The node cannot be moved there.');
}
foreach ($plan as $placement) {
$attributes = ['parent_id' => $placement->parentId, 'sort_order' => $placement->position];
(string) $placement->id === $payload['nodeId']
? $categories->find($placement->id)?->update($attributes)
: Category::query()->whereKey($placement->id)->update($attributes);
}
return ActionResult::success($payload);
});
}

A requested position past the end of the destination group clamps to the end. Siblings with equal stored positions order by existing position, then id, so the plan is deterministic even over sparse or duplicated sort_order values. Ids compare by string identity — the wire payload’s string "5" matches an integer primary key 5 — while the emitted placements keep your rows’ original id types, so the plan works unchanged for auto-increment and UUID keys.

Without restrictions the client offers make-child everywhere and leaves hierarchy rules to the move action — every invalid drop round-trips to the server and rolls back. Declare the rules up front and the client stops offering those moves at all:

Tree::use(CategoryTree::class)
->moveAction(MoveCategory::class)
->maxDepth(2); // roots are depth 1: a node may nest under a root, nothing deeper
TreeNode::make('archive', 'Archive')->acceptsChildren(false); // never a drop parent

->maxDepth() caps the depth any node may sit at after a move. The moved node’s own subtree counts: dragging a parent with one loaded level of children under a root already exhausts maxDepth(2), and a collapsed hasChildren node whose children were never fetched counts as at least two levels. ->acceptsChildren(false) (also an acceptsChildren array-shorthand key) keeps a node from ever receiving make-child drops or keyboard indents; reordering siblings around it stays possible.

Blocked pointer drops show a blocked indicator instead of the drop ring, and blocked keyboard indents do nothing. Both are client-side affordances over the loaded part of the tree — the registered move action remains the authority, and moves it rejects still roll back.

Serializing a large hierarchy eagerly is wasteful. Register a tree definition and let expansion fetch one level per request instead:

use Lattice\Tree\AsTree;
use Lattice\Tree\EloquentTreeSource;
use Lattice\Tree\TreeDefinition;
use Lattice\Tree\TreeSource;
#[AsTree('categories')]
class CategoryTree extends TreeDefinition
{
public function source(): TreeSource
{
return EloquentTreeSource::make(Category::class);
}
}
Tree::use(CategoryTree::class)->lazy(); // roots eager, deeper levels fetched on expand
Tree::use(CategoryTree::class)->lazy(2); // two levels eager
Tree::use(CategoryTree::class)->lazy(0); // bare skeleton — even the roots are fetched

Passing ->activeId($id) to an Eloquent-backed tree resolves that node’s ancestors, then loads, expands, and focuses the node through lazy levels. Custom TreeSource implementations return the ancestor IDs from path(). After a mutation, change ->revision($key) to discard cached lazy children and refetch expanded branches while preserving active and focus state.

The definition is discovered like any Lattice definition (#[AsTree] + Lattice’s discovery paths), and the serialized tree carries a sealed reference — the same signing machinery Lattice tables use — that the package’s lattice/trees/{tree} endpoint verifies before resolving the definition again with the identical context. authorize() on the definition gates both the initial render and every fetch. The route’s middleware and path follow Lattice’s group conventions: config('lattice.trees.middleware', ['web', 'auth']) and config('lattice.trees.endpoint', 'lattice/trees/{tree}').

An EloquentTreeSource behind the endpoint automatically switches to per-level queries (WHERE parent_id = ? plus a scoped EXISTS probe for hasChildren) instead of loading the whole table. Inline ->nodes() / ->source() trees stay eager-only — without a registry key there is nothing to seal — so ->lazy() on them throws.

A definition-backed tree listens for the reload-component effect: an action returning ActionResult::success()->reloadComponent('categories') (the tree’s #[AsTree] id) refetches the roots from the endpoint and drops cached lazy children, so expanded branches refetch while expansion and focus stay put — no full page reload needed after a mutation. Inline ->nodes() / ->source() trees have no endpoint to refetch from and ignore the effect.

The component’s strings ship with inline English defaults. With laravel-i18next enabled, the plugin’s tree namespace is loaded automatically and serves the bundled en/de translations (override them like any Laravel package translation — see Internationalization).

The breakdown below measures the package’s built plugin — React and the framework runtime are external, so this is exactly what the tree package adds on top of an app that already ships Lattice. It is regenerated on every docs build.

9.7 KB gzipped · 30.7 KB raw JavaScript

DependencyRawGzipShare
@lattice-php/tree26.6 KB8.9 KB92.0%
Bundler runtime4.0 KB0.8 KB8.0%

Emitted JavaScript files

FileRawGzip
plugin.js30.7 KB9.7 KB

Open the full interactive treemap ↗ · Generated by Sonda 0.14.0 on Fri, 11 Sep 2026 17:20:04 GMT.