Tree view component for Lattice — hierarchy rendering 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.
Extracted from the Lattice core package to grow on its own; drag & drop reordering is next on the roadmap.
composer require lattice-php/treeThat is the whole integration: the package ships its React renderer as source and Lattice's
lattice() Vite plugin compiles it into your app's bundle via virtual:lattice/plugins. The
PHP classes are picked up by Lattice's discovery and TypeScript generation automatically.
use Lattice\Tree\Tree;
use Lattice\Tree\TreeNode;
Tree::make('categories')->nodes([
TreeNode::make('electronics', 'Electronics')
->icon('cpu')
->children([
TreeNode::make('electronics-laptops', 'Laptops'),
TreeNode::make('electronics-phones', 'Phones')->href('/products/phones'),
]),
])->defaultExpanded(['electronics']);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.
For content the conveniences do not cover, ->schema() replaces the composed body outright:
use Lattice\Lattice\Ui\Components\Avatar;
use Lattice\Lattice\Ui\Components\Badge;
use Lattice\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'),
]);->schema() is an escape hatch, not an addition — it replaces the default body entirely. label is still
required and keeps driving typeahead and aria announcements even when the schema does not render it. On
Enter/Space, a node without an href activates the first button found in its body, so a custom schema should
put its primary action's button first — or expect it to receive Enter.
Or back it with an Eloquent adjacency list (a self-referencing parent_id column):
use Lattice\Tree\EloquentTreeSource;
Tree::make('categories')->source(
EloquentTreeSource::make(Category::class)
->scope(fn ($query) => $query->where('active', true)),
);Any other backing store implements the two-method Lattice\Tree\TreeSource contract.
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 fetchedThe 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.
The component's strings ship with inline English defaults. With
bambamboole/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).
composer install && npm install
composer check # pint --test, phpstan, pest (Unit + Feature)
npm run typecheck && npm test
composer test:browser # rebuilds the workbench bundle, then runs the Playwright suite
composer serve # workbench demo app: /tree (eager) and /tree-lazy (lazy endpoint)