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.
Installation
Section titled “Installation”composer require lattice-php/treeThat 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']);- activeId:null
- activePath:null
- defaultExpanded:["products","hardware"]
- endpoint:null
- lazy:false
- maxDepth:null
- moveAction:null
- nodes:[{"acceptsChildren":true,"children":[{"acceptsChildren":true,"children":[{"acceptsChildren":true,"children":[],"class":null,"disabled":false,"hasChildren":false,"href":null,"id":"laptops","label":"Laptops","schema":[{"props":{"align":null,"color":null,"copyable":false,"size":"md","text":"Laptops"},"type":"text"}]},{"acceptsChildren":true,"children":[],"class":null,"disabled":false,"hasChildren":false,"href":null,"id":"phones","label":"Phones","schema":[{"props":{"align":null,"color":null,"copyable":false,"size":"md","text":"Phones"},"type":"text"}]}],"class":null,"disabled":false,"hasChildren":false,"href":null,"id":"hardware","label":"Hardware","schema":[{"props":{"align":null,"color":null,"copyable":false,"size":"md","text":"Hardware"},"type":"text"}]},{"acceptsChildren":true,"children":[],"class":null,"disabled":false,"hasChildren":false,"href":null,"id":"software","label":"Software","schema":[{"props":{"align":null,"color":null,"copyable":false,"size":"md","text":"Software"},"type":"text"}]}],"class":null,"disabled":false,"hasChildren":false,"href":null,"id":"products","label":"Products","schema":[{"props":{"align":null,"color":null,"copyable":false,"size":"md","text":"Products"},"type":"text"},{"props":{"color":{"dark":null,"kind":"named","value":"info"},"label":"24"},"type":"badge"}]},{"acceptsChildren":true,"children":[],"class":null,"disabled":false,"hasChildren":false,"href":null,"id":"services","label":"Services","schema":[{"props":{"align":null,"color":null,"copyable":false,"size":"md","text":"Services"},"type":"text"},{"props":{"color":null,"label":"8"},"type":"badge"}]}]
- rememberState:false
- revision:null
- selectAction:null
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.
Custom node content
Section titled “Custom node content”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'), ]);Form controls in a node body
Section titled “Form controls in a node body”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.
Eloquent sources
Section titled “Eloquent sources”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.
Selection and moving
Section titled “Selection and moving”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.
Planning a move
Section titled “Planning a move”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.
Move restrictions
Section titled “Move restrictions”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.
Lazy loading
Section titled “Lazy loading”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 expandTree::use(CategoryTree::class)->lazy(2); // two levels eagerTree::use(CategoryTree::class)->lazy(0); // bare skeleton — even the roots are fetchedPassing ->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.
Reloading
Section titled “Reloading”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.
Translations
Section titled “Translations”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).
Bundle size
Section titled “Bundle size”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
| Dependency | Raw | Gzip | Share |
|---|---|---|---|
| @lattice-php/tree | 26.6 KB | 8.9 KB | 92.0% |
| Bundler runtime | 4.0 KB | 0.8 KB | 8.0% |
Emitted JavaScript files
| File | Raw | Gzip |
|---|---|---|
plugin.js | 30.7 KB | 9.7 KB |