| Install | |
|---|---|
composer require pjedesigns/filament-nested-set-table |
|
| Latest Version: | 1.1.0 |
| PHP: | ^8.2 |
A Filament table component for displaying and managing nested set data structures with drag-and-drop reordering support. Works with Filament v4/v5. Built for use with kalnoy/nestedset.
kalnoy/nestedset packageInstall the package via composer:
composer require pjedesigns/filament-nested-set-table
Publish the config file (optional):
php artisan vendor:publish --tag="filament-nested-set-table-config"
This package provides two ways to display and manage nested set data:
| Feature | HasTree (Table) | OrderPage (Dedicated) |
|---|---|---|
| Use case | Full CRUD with tree | Focused reordering |
| Loading | Lazy (on expand) | All at once |
| Expand/Collapse | Server call | Pure JavaScript |
| Columns/Actions | Full Filament support | Label only |
| Best for | Data management | Quick reordering |
Best for: Full CRUD functionality with tree visualization in standard Filament tables.
Ensure your model uses the NodeTrait from kalnoy/nestedset and optionally add the InteractsWithTree trait:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Kalnoy\Nestedset\NodeTrait;
use Pjedesigns\FilamentNestedSetTable\Concerns\InteractsWithTree;
class Category extends Model
{
use NodeTrait;
use InteractsWithTree;
protected $fillable = ['title'];
// Optional: customize the label column
public function getTreeLabelColumn(): string
{
return 'title';
}
// Optional: provide an icon for tree nodes
public function getTreeIcon(): ?string
{
return 'heroicon-o-folder';
}
// Optional: control if node can be dragged
public function canBeDragged(): bool
{
return true;
}
// Optional: control if node can have children
public function canHaveChildren(): bool
{
return true;
}
}
<?php
namespace App\Filament\Resources\CategoryResource\Pages;
use App\Filament\Resources\CategoryResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Pjedesigns\FilamentNestedSetTable\Concerns\HasTree;
class ListCategories extends ListRecords
{
use HasTree;
protected static string $resource = CategoryResource::class;
// Optional: Configure eager loading for relationships
protected function getTreeWith(): array
{
return ['media', 'author'];
}
// Optional: Add expand/collapse all actions
protected function getHeaderActions(): array
{
return [
Action::make('expandAll')
->label(__('Expand All'))
->icon('heroicon-o-chevron-double-down')
->color('gray')
->action(fn () => $this->expandAllNodes()),
Action::make('collapseAll')
->label(__('Collapse All'))
->icon('heroicon-o-chevron-double-up')
->color('gray')
->action(fn () => $this->collapseAllNodes()),
// ... other actions
];
}
}
<?php
namespace App\Filament\Resources;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Pjedesigns\FilamentNestedSetTable\Tables\Columns\TreeColumn;
class CategoryResource extends Resource
{
public static function table(Table $table): Table
{
return $table
->recordUrl(null) // Recommended: disable row click for drag-and-drop
->defaultSort('_lft', 'asc')
->columns([
TreeColumn::make('title')
->searchable()
->sortable()
->dragHandle() // Show drag handle
->expandToggle() // Show expand/collapse toggle
->indentSize(24), // Pixels per depth level
// Add any other columns as normal
TextColumn::make('slug'),
]);
}
}
Note: The HasTree trait automatically handles query modifications (withDepth, withCount('children'), lazy loading). Do not add modifyQueryUsing for these - use getTreeWith() for eager loading relationships instead.
Best for: Focused, fast reordering experience with minimal server calls.
The OrderPage is a dedicated Filament page optimized for tree reordering:
The Order Page provides a streamlined UI with:
Create a page class that extends OrderPage and links it to your resource:
<?php
namespace App\Filament\Resources\CategoryResource\Pages;
use App\Filament\Resources\CategoryResource;
use Pjedesigns\FilamentNestedSetTable\Pages\OrderPage;
class OrderCategories extends OrderPage
{
// Link to your resource - model is automatically resolved
protected static string $resource = CategoryResource::class;
// Optional: override the page title (default: "Order {PluralModelLabel}")
// protected static ?string $title = 'Order Categories';
// Optional: customize the label column (default: 'title')
public function getLabelColumn(): string
{
return 'name';
}
// Optional: set max depth (default: from config, 0 = unlimited)
// When set to 1, only reordering is allowed (no nesting)
public function getMaxDepth(): int
{
return 5;
}
// Optional: customize indent size (default: from config)
public function getIndentSize(): int
{
return 24;
}
// Optional: eager load relationships
public function getEagerLoading(): array
{
return ['media'];
}
// Optional: filter by scope (for scoped nested sets)
public function getScopeFilter(): array
{
return ['navigation_id' => 1];
}
// Optional: disable drag and drop
public function isDragEnabled(): bool
{
return true;
}
}
Add it to your Resource's pages array:
// In your Resource class
public static function getPages(): array
{
return [
'index' => Pages\ListCategories::route('/'),
'create' => Pages\CreateCategory::route('/create'),
'edit' => Pages\EditCategory::route('/{record}/edit'),
'order' => Pages\OrderCategories::route('/order'), // Add ordering page
];
}
// In your ListRecords page
protected function getHeaderActions(): array
{
return [
Action::make('order')
->label('Reorder')
->icon('heroicon-o-bars-arrow-down')
->url(OrderCategories::getUrl()),
// ... other actions
];
}
Add a reorder method to your model's policy:
<?php
namespace App\Policies;
use App\Models\Category;
use App\Models\User;
class CategoryPolicy
{
public function reorder(User $user, Category $category): bool
{
return $user->can('update', $category);
}
}
If no reorder method exists, the package falls back to checking update permission.
// config/filament-nested-set-table.php
return [
// Default indentation per depth level (pixels)
'indent_size' => 24,
// Enable drag-and-drop by default
'drag_enabled' => true,
// Maximum tree depth (0 = unlimited)
'max_depth' => 0,
// Remember expanded/collapsed state in session
'remember_expanded_state' => true,
// Expand all nodes by default on first visit
'default_expanded' => false,
// Undo duration in seconds
'undo_duration' => 10,
// Enable broadcasting for real-time updates
'broadcast_enabled' => false,
// Touch delay to prevent accidental drags (ms)
'touch_delay' => 150,
];
TreeColumn::make('title')
->indentSize(24) // Set indent size per level
->dragHandle(true) // Show/hide drag handle
->expandToggle(true) // Show/hide expand toggle
->draggable(true) // Enable/disable dragging
->icon('heroicon-o-folder') // Set an icon
->badge() // Display as badge (inherited from TextColumn)
->searchable() // Make searchable (inherited)
->sortable(); // Make sortable (inherited)
The HasTree trait provides several useful methods:
// Expand/Collapse
$this->expandAllNodes(); // Expand all nodes
$this->collapseAllNodes(); // Collapse all nodes
$this->toggleNode($nodeId); // Toggle a specific node
$this->isNodeExpanded($nodeId); // Check if node is expanded
// State management
$this->resetTreeState(); // Reset to default state
$this->clearExpandedState(); // Clear session state
// Configuration
$this->getTreeWith(); // Override to specify eager loading
$this->getMaxDepth(); // Override to set custom max depth
You can override the maximum tree depth for a specific ListRecords page by overriding the getMaxDepth() method:
class ListCategories extends ListRecords
{
use HasTree;
protected static string $resource = CategoryResource::class;
// Override max depth for this specific page (0 = unlimited)
public function getMaxDepth(): int
{
return 5; // Limit to 5 levels for this page only
}
}
This overrides the global config value (filament-nested-set-table.max_depth) for this specific page.
When working with nested set data, deleting, force-deleting, or restoring a node will also affect its descendants. The kalnoy/nestedset package automatically cascades these operations to child nodes.
This package provides tree-aware action classes that display the descendant count in the confirmation modal, so users know exactly how many items will be affected.
| Action | Description |
|---|---|
TreeDeleteAction |
Shows count of child items that will also be soft-deleted |
TreeForceDeleteAction |
Shows count of child items (including trashed) that will be permanently deleted |
TreeRestoreAction |
Shows count of trashed child items that will also be restored |
use Pjedesigns\FilamentNestedSetTable\Actions\TreeDeleteAction;
use Pjedesigns\FilamentNestedSetTable\Actions\TreeForceDeleteAction;
use Pjedesigns\FilamentNestedSetTable\Actions\TreeRestoreAction;
public static function table(Table $table): Table
{
return $table
->columns([...])
->actions([
TreeDeleteAction::make(),
TreeRestoreAction::make(),
TreeForceDeleteAction::make(),
]);
}
The tree actions extend Filament's base actions (DeleteAction, ForceDeleteAction, RestoreAction) and only add the modalDescription showing the descendant count. The actual delete/restore logic uses the default Filament behavior.
If your application has custom delete logic (e.g., using service classes, custom validation, or additional cleanup), you should extend the tree actions and override the using() method:
<?php
namespace App\Filament\Actions;
use Illuminate\Database\Eloquent\Model;
use Pjedesigns\FilamentNestedSetTable\Actions\TreeDeleteAction as BaseTreeDeleteAction;
class TreeDeleteAction extends BaseTreeDeleteAction
{
protected function setUp(): void
{
parent::setUp();
// Customize modal heading
$this->modalHeading(fn (): string => __('Delete :title', ['title' => $this->getRecordTitle()]));
// Customize success notification
$this->successNotificationTitle(fn (?Model $record): string =>
__(':title deleted successfully', ['title' => $record?->title ?? 'Record'])
);
// Custom delete logic
$this->using(function (Model $record): Model {
// Your custom delete logic here
// For example, using a service class:
$record->getService()->delete($record);
return $record;
});
}
}
When restoring a soft-deleted node, its parent may have been permanently deleted. The kalnoy/nestedset package will restore the node, but it may be left orphaned (with a parent_id pointing to a non-existent record).
Consider handling this in your custom restore action:
$this->using(function (Model $record): Model {
// Check if parent was permanently deleted
if ($record->parent_id && ! $record->parent) {
// Make this node a root node
$record->saveAsRoot();
}
$record->restore();
return $record;
});
The actions use the following translation keys from filament-nested-set-table::actions:
return [
'delete_confirm' => 'Are you sure you want to delete this item?',
'delete_confirm_with_children' => 'Are you sure you want to delete this item? This will also delete :count child item.|Are you sure you want to delete this item? This will also delete :count child items.',
'force_delete_confirm' => 'Are you sure you want to permanently delete this item? This action cannot be undone.',
'force_delete_confirm_with_children' => 'Are you sure you want to permanently delete this item? This will also permanently delete :count child item. This action cannot be undone.|Are you sure you want to permanently delete this item? This will also permanently delete :count child items. This action cannot be undone.',
'restore_confirm' => 'Are you sure you want to restore this item?',
'restore_confirm_with_children' => 'Are you sure you want to restore this item? This will also restore :count child item.|Are you sure you want to restore this item? This will also restore :count child items.',
];
Publish translations to customize:
php artisan vendor:publish --tag="filament-nested-set-table-translations"
Add this trait to your model for additional customization:
use Pjedesigns\FilamentNestedSetTable\Concerns\InteractsWithTree;
class Category extends Model
{
use NodeTrait;
use InteractsWithTree;
// Get the label for tree display
public function getTreeLabel(): string
{
return $this->getAttribute($this->getTreeLabelColumn());
}
// Column used for tree label (default: 'title')
public function getTreeLabelColumn(): string
{
return 'title';
}
// Icon for this node (default: 'heroicon-o-folder')
public function getTreeIcon(): ?string
{
return 'heroicon-o-folder';
}
// Can this node have children? (default: true)
public function canHaveChildren(): bool
{
return true;
}
// Can this node be dragged? (default: true)
public function canBeDragged(): bool
{
return true;
}
// Max depth for this tree (default: from config)
public function getMaxTreeDepth(): int
{
return config('filament-nested-set-table.max_depth', 0);
}
}
To eager load relationships with tree queries, override the getTreeWith() method (HasTree) or getEagerLoading() method (OrderPage):
// HasTree (ListRecords page)
protected function getTreeWith(): array
{
return ['media', 'author', 'tags'];
}
// OrderPage
public function getEagerLoading(): array
{
return ['media', 'author', 'tags'];
}
This ensures relationships are loaded efficiently when fetching tree nodes, preventing N+1 query issues.
For models with scoped nested sets (e.g., navigation items scoped by navigation_id):
class NavigationItem extends Model
{
use NodeTrait;
use InteractsWithTree;
protected function getScopeAttributes(): array
{
return ['navigation_id'];
}
}
The package will automatically prevent moving nodes between different scopes.
For OrderPage, you can filter by scope:
public function getScopeFilter(): array
{
return ['navigation_id' => $this->navigationId];
}
Both the OrderPage and HasTree trait support a one-click alphabetical reorder feature. When enabled, a "Save Alphabetically" button appears in the header that reorders all nodes alphabetically within each parent group (preserving the tree hierarchy).
use Pjedesigns\FilamentNestedSetTable\Pages\OrderPage;
class OrderCategories extends OrderPage
{
protected static string $resource = CategoryResource::class;
// Enable the alphabetical ordering button
public function shouldShowAlphabeticalButton(): bool
{
return true;
}
// Optional: customize which fields to sort by (default: ['title'])
public function getAlphabeticalOrderField(): array
{
return ['title'];
}
}
use Pjedesigns\FilamentNestedSetTable\Concerns\HasTree;
class ListCategories extends ListRecords
{
use HasTree;
protected static string $resource = CategoryResource::class;
// Enable the alphabetical ordering button
public function shouldShowAlphabeticalButton(): bool
{
return true;
}
// Optional: customize which fields to sort by (default: ['title'])
public function getAlphabeticalOrderField(): array
{
return ['title'];
}
protected function getHeaderActions(): array
{
return [
// Add the alphabetical button as a header action
Action::make('saveAlphabetically')
->label(__('filament-nested-set-table::messages.save_alphabetically'))
->icon('heroicon-o-bars-arrow-down')
->color('info')
->requiresConfirmation()
->action(fn () => $this->saveAlphabetically()),
// ... other actions
];
}
}
You can sort by multiple fields. The sort compares each field in order, moving to the next field only when values are equal:
class OrderPeople extends OrderPage
{
protected static string $resource = PersonResource::class;
public function shouldShowAlphabeticalButton(): bool
{
return true;
}
// Sort by last name first, then first name
public function getAlphabeticalOrderField(): array
{
return [
'first_name',
'last_name',
];
}
}
parent_id (preserving the tree structure)strnatcasecmp)insertAfterNode() methodfixTree() to ensure _lft/_rgt values are consistentThe OrderPage fully supports Filament's nested resources. When using a child resource (a resource that has a $parentResource property), the package automatically handles parent record resolution.
If you have a NavigationResource with a nested NavigationItemResource:
// NavigationItemResource.php
class NavigationItemResource extends Resource
{
protected static ?string $model = NavigationItem::class;
protected static ?string $parentResource = NavigationResource::class;
public static function getPages(): array
{
return [
'create' => CreateNavigationItem::route('/create'),
'edit' => EditNavigationItem::route('/{record}/edit'),
'order' => OrderNavigationItems::route('/order'),
];
}
}
Your OrderPage implementation is simple - just use getParentRecord() to access the parent:
// OrderNavigationItems.php
use Pjedesigns\FilamentNestedSetTable\Pages\OrderPage;
class OrderNavigationItems extends OrderPage
{
protected static string $resource = NavigationItemResource::class;
public function getScopeFilter(): array
{
return ['navigation_id' => $this->getParentRecord()?->getKey()];
}
}
The OrderPage uses Filament's InteractsWithParentRecord trait, which:
getParentRecord() method to access the parent model instancegetParentResource() static method to get the parent resource classIn your parent resource's ManageRelatedRecords page:
// ManageNavigationItems.php (in NavigationResource)
class ManageNavigationItems extends ManageRelatedRecords
{
protected static string $resource = NavigationResource::class;
protected static string $relationship = 'navigationItems';
protected static ?string $relatedResource = NavigationItemResource::class;
public function table(Table $table): Table
{
return $table
->headerActions([
Action::make('order')
->label('Order Items')
->icon('heroicon-o-bars-arrow-down')
->url(NavigationItemResource::getUrl('order', [
'navigation' => $this->record->id
])),
]);
}
}
The OrderPage automatically handles the "Back to List" button for nested resources. It will navigate back to the appropriate parent page (typically the ManageRelatedRecords page or the parent's edit page).
The package dispatches the following events:
| Event | Description | Properties |
|---|---|---|
NodeMoved |
Node successfully moved | $node, $result, $previousParentId, $previousPosition |
NodeMoveFailed |
Move operation failed | $node, $error, $attemptedParentId, $attemptedPosition |
TreeFixed |
Tree structure repaired | $modelClass, $nodesFixed, $scopeAttributes |
// In EventServiceProvider or a listener
use Pjedesigns\FilamentNestedSetTable\Events\NodeMoved;
Event::listen(NodeMoved::class, function (NodeMoved $event) {
// Log the move
activity()
->performedOn($event->node)
->log('Node moved');
});
The package includes English translations. Publish them to customize:
php artisan vendor:publish --tag="filament-nested-set-table-translations"
Available translation keys:
return [
'move_success' => 'Item moved successfully.',
'move_failed' => 'Failed to move item.',
'undo_success' => 'Move undone successfully.',
'item_moved' => 'Item moved',
'unauthorized' => 'You are not authorized to move this item.',
'circular_reference' => 'Cannot move an item under its own descendant.',
'max_depth_exceeded' => 'Cannot move here: would exceed maximum depth of :max levels.',
'expand_all' => 'Expand All',
'collapse_all' => 'Collapse All',
'fix_tree' => 'Fix Tree',
'undo' => 'Undo',
'back_to_list' => 'Back to :resource',
'tree_structure' => 'Order Tree',
'tree_description' => 'Drag and drop items to reorder. Drop on an item to make it a child.',
'tree_description_flat' => 'Drag and drop items to reorder.',
'save_alphabetically' => 'Save Alphabetically',
'alphabetical_confirm' => 'This will reorder all items alphabetically within each level. Are you sure?',
'alphabetical_success' => 'Items reordered alphabetically.',
'alphabetical_failed' => 'Failed to reorder items alphabetically.',
// ... and more
];
composer test
Please 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.