solution-forest / filament-nestable-tree
A nestable drag-and-drop tree component for Filament v4 and v5. Supports Eloquent models (including kalnoy/nestedset), static record arrays, per-node actions, multi-tree pages, cross-tree drag-and-drop, lazy loading, and async child loading.
Package info
github.com/solutionforest/filament-nestable-tree
pkg:composer/solution-forest/filament-nestable-tree
Requires
- php: ^8.2
- filament/filament: ^4.0|^5.0
- kalnoy/nestedset: ^6.0|^7.0
- spatie/laravel-package-tools: ^1.15.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/boost: ^2.4
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-arch: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- pestphp/pest-plugin-livewire: ^3.0|^4.0
- rector/rector: ^2.0
- spatie/laravel-ray: ^1.26
README
A nestable drag-and-drop tree component for Filament v4 and v5. Supports Eloquent models (including kalnoy/nestedset), static record arrays, per-node actions, multi-tree pages, cross-tree drag-and-drop, lazy loading, and async child loading.
Example usage — see the fixture pages in this repository.
Which Package Should I Use?
- Need a simple tree solution with quick setup? Use filament-tree.
- Need to handle heavy-load menus or large, complex trees? Use this package (
filament-nestable-tree).
Installation
composer require solution-forest/filament-nestable-tree
Important
If you are using Filament Panels with a custom theme, add the plugin's views to your theme CSS file so Tailwind can scan them:
@source '../../../../vendor/solution-forest/filament-nestable-tree/resources/**/*.blade.php';
If you have not yet set up a custom theme, follow the Filament theming guide first.
Quick Start
1 — Standalone Tree Page
Create a Filament page that shows a tree:
php artisan make:filament-tree-page CategoryTreePage
use SolutionForest\FilamentNestableTree\Filament\Pages\TreePage; use SolutionForest\FilamentNestableTree\Tree; class CategoryTreePage extends TreePage { protected static ?string $navigationLabel = 'Categories'; public function tree(Tree $tree): Tree { return $tree->model(Category::class)->labelField('title'); } }
2 — Resource Tree Page (replaces ListRecords)
php artisan make:filament-tree-resource-page ManageCategoryTree
use SolutionForest\FilamentNestableTree\Filament\Resources\Pages\TreePage; use SolutionForest\FilamentNestableTree\Tree; class ManageCategoryTree extends TreePage { public static string $resource = CategoryResource::class; public function tree(Tree $tree): Tree { return parent::tree($tree) // includes default EditAction + DeleteAction ->model(Category::class) ->labelField('title'); } }
Register the page in your resource's getPages():
public static function getPages(): array { return [ 'index' => ManageCategoryTree::route('/'), ]; }
3 — Tree Widget
php artisan make:filament-tree-widget CategoryTreeWidget
use SolutionForest\FilamentNestableTree\Filament\Widgets\Tree as TreeWidget; use SolutionForest\FilamentNestableTree\Tree; class CategoryTreeWidget extends TreeWidget { public function tree(Tree $tree): Tree { return $tree->model(Category::class)->labelField('title'); } }
4 — Embed in Any Livewire Component (InteractsWithTree)
Add the InteractsWithTree trait to any Livewire component (including custom pages, widgets, or plain Livewire components) to embed a tree without extending a base class:
use Livewire\Component; use SolutionForest\FilamentNestableTree\Concerns\InteractsWithTree; use SolutionForest\FilamentNestableTree\Tree; class MyCustomPage extends Component { use InteractsWithTree; public function tree(Tree $tree): Tree { return $tree->model(Category::class)->labelField('title'); } }
Then render the tree in your Blade view:
@include('filament-nestable-tree::livewire.components.tree', [ 'wireNodesProperty' => 'treeNodes', 'treeKeyName' => null, 'treeConfig' => $this->getCachedTree(), 'isSearchable' => $this->getCachedTree()->isSearchable(), 'allowDragDrop' => $this->getCachedTree()->isDraggable(), 'allowCrossCategory' => $this->getCachedTree()->isCrossCategoryAllowed(), 'toolbarActions' => $this->getCachedTree()->getToolbarActions(), 'lazy' => $this->getCachedTree()->isLazy(), 'hasNodeActions' => ! empty($this->getCachedTree()->getNodeActions()), ])
Livewire automatically calls
mountInteractsWithTree()after your component'smount()to populate the tree nodes — no manual setup required.
Tree Configuration Reference
All options are fluent methods on the Tree instance returned from tree() or trees().
| Method | Default | Description |
|---|---|---|
->model(Category::class) |
null |
Eloquent model to load the tree from |
->records([...]) |
[] |
Static nested/flat array (alternative to model) |
->labelField('name') |
'name' |
Attribute used as the display label |
->recordKeyField('id') |
'id' |
Attribute used as the unique identifier |
->parentKeyField('parent_id') |
'parent_id' |
Attribute used as the parent reference |
->childrenField('children') |
'children' |
Attribute that holds nested children |
->maxDepth(3) |
-1 (unlimited) |
Maximum nesting depth for drag-and-drop |
->maxVisibleDepth(5) |
4 |
Maximum rendered depth in the flat list view |
->searchable() |
false |
Show the search input and highlight matching labels |
->draggable(false) |
true |
Enable or disable drag-and-drop reordering |
->allowCrossCategory() |
false |
Allow nodes to move between root-level branches |
->lazy() |
false |
Defer node loading until after first render |
->asyncChildren(fn) |
null |
Load children on-demand when a node is expanded |
->saveOrderUsing(fn) |
null |
Closure to persist reorder; receives the nested nodes array |
->getRecordUsing(fn) |
null |
Custom closure to resolve a node record by its ID |
->nodeActions([...]) |
[] |
Per-node action buttons (edit, delete, custom) |
->appendToolbarActions([...]) |
— | Add buttons to the toolbar (append to defaults) |
Saving Order After Drag & Drop
Option 1 — Automatic (kalnoy/nestedset)
If your model uses the kalnoy/nestedset NodeTrait, the tree calls rebuildTree() automatically when the Save button is clicked — no extra configuration required:
use Kalnoy\Nestedset\NodeTrait; class Category extends Model { use NodeTrait; }
public function tree(Tree $tree): Tree { return $tree->model(Category::class)->labelField('title'); }
Option 2 — Custom callback
public function tree(Tree $tree): Tree { return $tree ->model(Category::class) ->saveOrderUsing(function (array $nodes): void { // $nodes is the full nested array from Alpine foreach ($nodes as $index => $node) { Category::where('id', $node['id'])->update(['sort_order' => $index]); } }); }
If neither option is configured, a MissingSaveOrderCallbackException is thrown at runtime when save is triggered.
Save button in toolbar
The default toolbar includes a Save button that is hidden until a drag-drop reorder occurs. You can also add your own conditional save action:
use Filament\Actions\Action; public function tree(Tree $tree): Tree { return $tree ->appendToolbarActions([ Action::make('save_order') ->label('Save') ->icon('heroicon-o-check') ->extraAttributes(['x-show' => 'hasUnsavedOrder', 'x-cloak' => true]) ->action('saveOrder'), ]); }
Node Actions
Add per-node action buttons:
use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; public function tree(Tree $tree): Tree { return $tree ->model(Category::class) ->nodeActions([ EditAction::make() ->iconButton() ->icon('heroicon-o-pencil') ->size('sm'), DeleteAction::make() ->iconButton() ->icon('heroicon-o-trash') ->size('sm') ->color('danger'), ]); }
Custom node actions
use Filament\Actions\Action; use Filament\Forms\Components\TextInput; use Filament\Schemas\Schema; ->nodeActions(fn (Tree $tree) => [ Action::make('rename') ->iconButton() ->icon('heroicon-o-pencil') ->schema([TextInput::make('title')->required()]) ->fillForm(fn ($record): array => is_array($record) ? $record : $record->toArray()) ->action(function (array $data, $record, array $arguments) use ($tree): void { // $record is the Eloquent model or the array node // $arguments['nodeId'] is the node's primary key $record->update(['title' => $data['title']]); }) ->after(fn ($livewire) => $livewire->dispatch('tree-refresh')), ])
getRecordUsing — custom record resolution
By default, when a node action fires the plugin resolves the record from the database (for model-based trees) or the flat records() array (for static trees). Override this for custom lookups:
->getRecordUsing(function (int|string $id, Tree $tree, $livewire): mixed { return Category::withTrashed()->find($id); })
Toolbar Actions
Pass Action or ActionGroup instances via ->appendToolbarActions():
use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\CreateAction; public function tree(Tree $tree): Tree { return $tree ->model(Category::class) ->appendToolbarActions([ CreateAction::make('create_node') ->model(Category::class) ->schema(fn (Schema $schema) => $this->form($schema)) ->after(fn ($livewire) => $livewire->dispatch('tree-refresh')) ->extraAttributes(['style' => 'margin-left: auto;']), ActionGroup::make([ Action::make('import')->label('Import'), Action::make('export')->label('Export'), ])->label('More'), ]); }
Multiple Trees on One Page
Override trees() instead of tree() to render multiple independent trees:
use SolutionForest\FilamentNestableTree\Filament\Pages\TreePage; use SolutionForest\FilamentNestableTree\Tree; class MultiTreePage extends TreePage { public function trees(): array { return [ 'categories' => Tree::make()->model(Category::class)->searchable()->labelField('title'), 'tags' => Tree::make()->model(Tag::class)->searchable()->labelField('name'), ]; } }
Cross-tree drag & drop
To allow nodes to be dragged from one named tree to another, enable ->allowCrossCategory() and handle the tree-cross-move event:
class MultiTreePage extends TreePage { public function trees(): array { $electronicsId = Category::where('title', 'Electronics')->value('id'); $clothingId = Category::where('title', 'Clothing')->value('id'); $createAction = fn (string $category) => CreateAction::make('create_' . $category) ->iconButton() ->icon('heroicon-o-plus') ->after(fn ($livewire) => $livewire->dispatch('tree-refresh')) ->schema([ TextInput::make('name')->required(), ]) ->model(Tag::class) ->action(function (array $data, ?string $model) use ($category, $electronicsId, $clothingId): void { $categoryId = match ($category) { 'technology' => $electronicsId, 'science' => $clothingId, default => null, }; $model ??= Tag::class; $model::create([ 'name' => $data['name'], 'category_id' => $categoryId, ]); }); return [ 'technology' => Tree::make() ->records(fn () => Tag::where('category_id', $electronicsId) ->defaultOrder()->get()->toTree()->toArray()) ->labelField('name') ->allowCrossCategory() ->saveOrderUsing(fn (array $nodes) => Tag::rebuildTree($nodes)) ->appendToolbarActions([$createAction('technology')]), 'science' => Tree::make() ->records(fn () => Tag::where('category_id', $clothingId) ->defaultOrder()->get()->toTree()->toArray()) ->labelField('name') ->allowCrossCategory() ->saveOrderUsing(fn (array $nodes) => Tag::rebuildTree($nodes)) ->appendToolbarActions([$createAction('science')]), ]; } /** * Called automatically when a node is dragged from one tree to another. */ public function handleCrossTreeMove( string $fromTreeKey, string $toTreeKey, int|string $nodeId, mixed $destinationParentId = null, ): void { $tag = Tag::find($nodeId); if (! $tag) { return; } $newCategoryTitle = $this->treeCategories[$toTreeKey] ?? null; $newCategoryId = $newCategoryTitle ? Category::where('title', $newCategoryTitle)->value('id') : null; if ($destinationParentId) { $parent = Tag::find($destinationParentId); if ($parent) { $tag->appendToNode($parent)->save(); } } else { $tag->saveAsRoot(); } if ($newCategoryId) { $tag->update(['category_id' => $newCategoryId]); } $this->dispatch('tree-refresh'); } }
Static records partitioned by a field
Use ->records() closures to split a single flat array across multiple trees by a partition field (e.g. category_id). Each tree sees only its own nodes; cross-tree drags update the partition field; saving one tree leaves the other tree's nodes untouched.
class CategoryPartitionedTreePage extends TreePage { /** Flat node store — replace with database reads in production. */ public static array $nodes = []; private const TREE_CATEGORY_MAP = ['tree1' => 1, 'tree2' => 2]; protected $listeners = ['tree-cross-move' => 'handleCrossTreeMove']; public function trees(): array { return [ 'tree1' => Tree::make() ->labelField('title') ->allowCrossCategory() ->records(fn () => $this->asTree( collect(static::$nodes)->where('category_id', 1)->values()->all() )) ->saveOrderUsing($this->saveOrderForCategory(1)), 'tree2' => Tree::make() ->labelField('title') ->allowCrossCategory() ->records(fn () => $this->asTree( collect(static::$nodes)->where('category_id', 2)->values()->all() )) ->saveOrderUsing($this->saveOrderForCategory(2)), ]; } /** * Flatten + tag each saved node with its category, then merge back with * nodes that belong to other categories so nothing gets lost on save. */ private function saveOrderForCategory(int $categoryId): Closure { return function (array $nodes) use ($categoryId): void { $saved = collect($this->asFlatten($nodes)) ->map(fn ($n) => array_merge($n, ['category_id' => $categoryId])) ->all(); $others = collect(static::$nodes) ->filter(fn ($n) => ($n['category_id'] ?? null) != $categoryId) ->values() ->all(); static::$nodes = array_merge($others, $saved); }; } /** * Update the partition field (category_id) and parent_id when a node is * dragged between trees. Silently ignored for unknown tree keys. */ public function handleCrossTreeMove( string $fromTreeKey, string $toTreeKey, int|string $nodeId, mixed $destinationParentId = null, ): void { $destCategory = self::TREE_CATEGORY_MAP[$toTreeKey] ?? null; if ($destCategory === null) { return; } static::$nodes = collect(static::$nodes) ->map(function ($node) use ($nodeId, $destCategory, $destinationParentId) { if ((string) $node['id'] === (string) $nodeId) { $node['category_id'] = $destCategory; $node['parent_id'] = $destinationParentId; } return $node; }) ->all(); $this->dispatch('tree-refresh'); } // ── Helpers ──────────────────────────────────────────────────────────────── /** Flat parent_id array → nested children array. */ private function asTree(array $flat): array { $map = []; foreach ($flat as $item) { $map[$item['id']] = $item + ['children' => []]; } $tree = []; foreach ($map as $id => &$node) { if ($node['parent_id'] === null || ! isset($map[$node['parent_id']])) { $tree[] = &$node; } else { $map[$node['parent_id']]['children'][] = &$node; } } return $tree; } /** Nested children array → flat array (strips children key). */ private function asFlatten(array $tree): array { $flat = []; foreach ($tree as $item) { $children = $item['children'] ?? []; unset($item['children']); $flat[] = $item; if (! empty($children)) { $flat = array_merge($flat, $this->asFlatten($children)); } } return $flat; } }
Key points
->records()accepts aClosure— it is re-evaluated on every Livewire hydration so each tree always reflects the latest state of$nodes.saveOrderForCategory()merges the newly-ordered nodes back with nodes from other categories so a save on tree1 never discards tree2's data.TREE_CATEGORY_MAPis the single source of truth that links tree keys to partition values; add entries here when adding more trees.- For a database-backed version replace the
static::$nodesarray with Eloquent queries — the structure oftrees(),handleCrossTreeMove, andsaveOrderForCategorystays identical.
Async / Lazy Loading
->lazy() — defer initial load
Renders the component shell immediately and loads nodes in a second Livewire request. Useful for large trees:
Tree::make()->model(Category::class)->lazy()
->asyncChildren() — expand-on-demand
Load children only when a node is expanded for the first time. The closure receives the parent node's ID:
Tree::make() ->model(Category::class) ->asyncChildren(function (int|string $parentId): array { return Category::where('parent_id', $parentId)->get()->toArray(); })
When async children are enabled, the root-level nodes are loaded normally on mount. Child nodes are fetched via a Livewire call when the user expands a parent for the first time, and cached client-side for subsequent toggles.
Plain Eloquent Model (no NodeTrait)
You can use the package with any plain Eloquent model that has a parent_id column.
No kalnoy/nestedset NodeTrait is required.
Minimal schema
Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('name'); $table->unsignedInteger('order')->default(0); $table->foreignId('parent_id')->nullable()->constrained('posts')->nullOnDelete(); $table->timestamps(); });
Option A — model with a children() relationship
Define a self-referencing HasMany on the model:
class Post extends Model { public function children(): HasMany { return $this->hasMany(Post::class, 'parent_id')->orderBy('order')->with('children'); } }
Then pass the model to the tree. The package performs a recursive eager load via the relationship on initial mount:
public function tree(Tree $tree): Tree { return $tree ->model(Post::class) ->labelField('name') ->parentKeyField('parent_id') ->saveOrderUsing(function (array $nodes): void { $this->saveOrder($nodes); }); } private function saveOrder(array $nodes, ?int $parentId = null, int $start = 0): void { foreach ($nodes as $index => $node) { Post::where('id', $node['id'])->update([ 'parent_id' => $parentId, 'order' => $start + $index, ]); if (! empty($node['children'])) { $this->saveOrder($node['children'], (int) $node['id'], 0); } } }
Option B — manual tree build with records()
Use ->records() when you want full control over how the nested array is built
(e.g., no relationship on the model):
public function tree(Tree $tree): Tree { return $tree ->labelField('name') ->records(fn () => $this->buildTree(Post::orderBy('order')->get())) ->saveOrderUsing(function (array $nodes): void { $this->saveOrder($nodes); }); } private function buildTree(Collection $items, mixed $parentId = null): array { return $items ->where('parent_id', $parentId) ->map(fn (Post $item) => array_merge($item->toArray(), [ 'children' => $this->buildTree($items, $item->id), ])) ->values() ->toArray(); }
Async children with a plain model
Combine ->asyncChildren() with ->model(). On mount, only root nodes are returned.
Children are loaded by the callback when the user expands a node:
->model(Post::class) ->asyncChildren(function (int|string $parentId): array { return Post::where('parent_id', $parentId)->orderBy('order')->get()->toArray(); })
Artisan Generators
# Standalone Filament page php artisan make:filament-tree-page CategoryTreePage # Resource page (replaces ListRecords) php artisan make:filament-tree-resource-page ManageCategoryTree # Widget php artisan make:filament-tree-widget CategoryTreeWidget
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.



