pjedesigns / filament-nested-set-table
A Filament table component for displaying and managing nested set data structures with drag-and-drop reordering support.
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/pjedesigns/filament-nested-set-table
Requires
- php: ^8.4
- filament/filament: ^4.0
- kalnoy/nestedset: ^6.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.1
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
A Filament 4 table component for displaying and managing nested set data structures with drag-and-drop reordering support. Built for use with kalnoy/nestedset.
Features
- Two Display Modes: Standard Filament table with tree features OR dedicated ordering page
- Drag-and-Drop Reordering: Intuitive touch-friendly drag-and-drop with visual drop zones
- Nested Set Integration: Works seamlessly with
kalnoy/nestedsetpackage - Tree-Aware Actions: Delete, force-delete, and restore actions that show descendant counts
- Expand/Collapse: Toggle visibility of child nodes with session persistence
- Lazy Loading: Only loads visible nodes for better performance with large trees (HasTree)
- Eager Loading Support: Configure relationships to eager load with tree queries
- Smart Pagination: Pagination counts root nodes only, children are loaded on expand
- Scoped Trees: Supports scoped nested sets (e.g., navigation items by navigation_id)
- Authorization: Integrates with model policies for move permission checks
- Undo Support: Temporary undo button for accidental moves
- Dark Mode: Full Filament dark mode support
Requirements
- PHP 8.4+
- Laravel 12+
- Filament 4.0+
- kalnoy/nestedset 6.0+
Installation
Install 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"
Usage Options
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 |
Option 1: HasTree Trait (Table Integration)
Best for: Full CRUD functionality with tree visualization in standard Filament tables.
1. Prepare Your Model
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; } }
2. Add HasTree Trait to Your ListRecords Page
<?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 ]; } }
3. Configure Your Table with TreeColumn
<?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.
Option 2: OrderPage (Dedicated Ordering Page)
Best for: Focused, fast reordering experience with minimal server calls.
The OrderPage is a dedicated Filament page optimized for tree reordering:
- Loads all nodes at once (no lazy loading delays)
- Expand/collapse is pure JavaScript (no server calls)
- Server calls only when you drop a node
- Built-in Expand All / Collapse All buttons
- Built-in Fix Tree action
Order Page Features
The Order Page provides a streamlined UI with:
- Header buttons: Expand All, Collapse All, Fix Tree, Back to List
- Undo support: After moving a node, an Undo button appears in the success notification
- Conditional description: Shows appropriate help text based on max depth setting
- Visual drop zones: Blue indicators show where items will be placed
1. Create Your Order Page
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; } }
2. Register the Page
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 ]; }
3. Link to Order Page from List 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 ]; }
Authorization
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.
Configuration
// 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 Options
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)
HasTree Trait Methods
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
Overriding Max Depth Per Page
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.
Tree-Aware Actions
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.
Available Actions
| 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 |
Basic Usage
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(), ]); }
Custom Delete Logic
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; }); } }
Handling Orphaned Nodes on Restore
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; });
Translations
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"
InteractsWithTree Trait Methods
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); } }
Eager Loading Relationships
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.
Scoped Trees
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]; }
Nested Resources (Child Resources)
The 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.
Example: Navigation Items as a Nested Resource
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()]; } }
How It Works
The OrderPage uses Filament's InteractsWithParentRecord trait, which:
- Automatically resolves the parent record from route parameters
- Provides
getParentRecord()method to access the parent model instance - Provides
getParentResource()static method to get the parent resource class - Handles authorization by checking view/edit permissions on the parent
Linking to the Order Page from a Relation Page
In 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 ])), ]); } }
Back Navigation
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).
Events
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 |
Listening to Events
// 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'); });
Translations
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.', // ... and more ];
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.