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.
- 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).
composer require solution-forest/filament-nestable-treeImportant
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.
Create a Filament page that shows a tree:
php artisan make:filament-tree-page CategoryTreePageuse 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');
}
}php artisan make:filament-tree-resource-page ManageCategoryTreeuse 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('/'),
];
}php artisan make:filament-tree-widget CategoryTreeWidgetuse 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');
}
}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.
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) |
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');
}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'),
]);
}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'),
]);
}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')),
])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);
})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'),
]);
}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'),
];
}
}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');
}
}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.
Renders the component shell immediately and loads nodes in a second Livewire request. Useful for large trees:
Tree::make()->model(Category::class)->lazy()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.
You can use the package with any plain Eloquent model that has a parent_id column.
No kalnoy/nestedset NodeTrait is required.
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();
});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);
}
}
}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();
}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();
})# 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 CategoryTreeWidgetcomposer testPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.



