saade/filament-adjacency-list
A Filament form component for managing adjacency lists (trees) with drag-and-drop nesting, reordering, and customizable labels, depth limits, item actions, and URLs. Works with arrays or Eloquent relationships.
1. Install and wire up the theme first. The single most common "it's broken" report is missing CSS:
composer require saade/filament-adjacency-list
If you use a custom Filament theme (required for panels per the Filament styling docs), add the view path to your theme CSS — otherwise the drag handle, indentation guides and action buttons render unstyled and drag-and-drop appears not to work:
@source '../../../../vendor/saade/filament-adjacency-list/resources/views/**/*.blade.php';
2. Pin the right major for your Filament version. This package's major version tracks Filament's, not the package's own API stability: the 3.x branch is for Filament 3, and the 4.x line covers Filament 4 and Filament 5 (v4.0.1 added Laravel 12 and Filament v5 support; v4.0.2 added Laravel 13). Check the tag you install against your filament/filament constraint before anything else — mismatched majors are the second most common failure mode.
3. First use case: a JSON-backed menu/category tree. Start non-relational; it's the fastest feedback loop and proves the UI works in your theme:
use Saade\FilamentAdjacencyList\Forms\Components\AdjacencyList;
AdjacencyList::make('subjects')
->form([
Forms\Components\TextInput::make('label')->required(),
]);
Cast the column to array and you're done — the field stores a plain nested array with label and children keys. Once that renders, move on to ->relationship(...) for real models.
Where to look first: the README's Relationships section (it's the whole ballgame for anything non-trivial), then resources/views/ for the Blade partials if you need to override markup, and the action classes behind addAction/editAction/etc. when you need to change behaviour rather than styling. The CHANGELOG for the 4.2.0 release is unusually informative — it reads like a list of the exact edge cases you'll hit.
Best for single-record config: nav menus, sidebar builders, page layouts, taxonomy presets.
AdjacencyList::make('menu')
->labelKey('title')
->childrenKey('nodes')
->maxDepth(3)
->modal(false) // inline add instead of a modal
->form([
TextInput::make('title')->required(),
TextInput::make('url'),
]);
labelKey/childrenKey let you match whatever shape your frontend already consumes, which makes this package a drop-in replacement for hand-rolled nested Repeaters.
This is the day-to-day pattern for Category, Department, Page, Comment trees. The model side is not optional boilerplate — the recursion and ordering must live in the relationship itself:
// Department
public function subjects(): HasMany
{
return $this->hasMany(Subject::class)
->whereNull('parent_id')
->with('children') // required: lazy recursion renders one level only
->orderBy('sort'); // required: drag order won't persist without it
}
// Subject
public function children(): HasMany
{
return $this->hasMany(Subject::class, 'parent_id')
->with('children')
->orderBy('sort');
}
AdjacencyList::make('subjects')
->relationship('subjects')
->labelKey('name')
->childrenKey('children') // must match the relationship method name
->orderColumn('sort')
->form([
TextInput::make('name')->required(),
]);
Scope the query inline when you need soft filters:
->relationship('subjects', fn (Builder $query) => $query->where('enabled', 1))
Use this when you already have recursive CTE queries elsewhere and want one source of truth. Two viable setups:
// Option 1: change the path separator (only in greenfield models)
public function getPathSeparator() { return '.children.'; }
// Option 2 (safer): leave the default alone and register a custom path
public function getCustomPaths()
{
return [[ 'name' => 'tree_path', 'column' => 'id', 'separator' => '.children.' ]];
}
AdjacencyList::make('subdepartments')
->relationship('descendants') // or descendantsAndSelf
->customPath('tree_path');
Prefer Option 2 in any model that already uses HasRecursiveRelationships for reporting — the README explicitly warns that changing the global separator breaks other call sites. This is the pattern that makes the package viable for read-heavy admin apps without a second authoring path.
AdjacencyListWidget gives you a standalone tree for any model with a recursive child relationship — including many-to-many graph relationships via HasGraphRelationships.
class DepartmentTreeWidget extends AdjacencyListWidget
{
protected static string $relationshipName = 'descendantsAndSelf';
public function getModel(): Model | string | null
{
return Department::query()->where('is_root', true)->first();
}
protected function adjacencyList(AdjacencyList $adjacencyList): AdjacencyList
{
return $adjacencyList->label('Structure')->editable()->labelKey('nombre');
}
}
On Edit/View pages the record is injected automatically; override getModel() only when the tree's owner is a different record than the page's. A useful division of labour: form component for authoring, widget for reviewing (with ->editable(false) on the widget if you want it purely informational).
Combine a read-mostly tree with a click target:
AdjacencyList::make('subjects')
->editable(false)
->itemAction('view')
->itemUrl(fn (array $item) => SubjectResource::getUrl('edit', ['record' => $item['data']['id']]))
->itemLabel(fn (array $item): ?string => Subject::find($item['data']['id'], ['name'])?->name);
Keep itemAction/itemUrl for the "click a node, go somewhere" flow and reserve the modal form for editing content. Mixing both on the same field is where users get confused.
The component exposes the full matrix and, per the 4.2.0 changes, respects policies and ignores drags on disabled fields:
->addable(Gate::allows('create', Subject::class))
->editable(true)
->deletable(true)
->reorderable(true)
->moveable(true)
->indentable(true)
Set these from policy checks rather than hardcoded booleans and the UI stays honest across roles.
->form([...]) is rendered inside each node's modal; a 10-field schema on a 200-node tree is a slow page.->orderColumn('sort') + orderBy('sort') over custom save hooks. Let the package own ordering; manual reindexing logic in your afterSave will fight it.->addAction(fn (Action $action): Action => $action->icon('heroicon-o-plus')->color('primary')). The README imports Filament\Forms\Actions\Action; verify that namespace against your installed Filament major, since action namespaces moved between majors. Same applies to ->addChildAction(...), ->editAction(...), ->deleteAction(...), ->reorderAction(...).label + children (or your custom keys) and assert on the saved column or on parent_id/sort values after call('save') in a Livewire test. Assert structure, not the DOM.The tree is empty until the owner record exists. Per 4.2.0, items can't be added until the owning record is saved. On a Create page the field is effectively read-only. Practical workarounds: (a) create the record with a redirect to Edit, (b) put the tree on a dedicated "manage structure" page, or (c) accept a minimal create form and build the tree afterwards.
childrenKey is documented as non-relational-only, but it still matters in relational mode. The 4.2.0 fix "move, indent and outdent items correctly … with a custom children key" tells you the value feeds internal move logic regardless. Keep it equal to the relationship method name and you sidestep the whole class of bug.
Forgetting ->with('children') gives you a one-level tree that looks like a rendering bug. It's not — it's N+1 avoidance failing safe. Same for ->orderBy('sort'): drags will appear to work and then silently revert on reload.
itemLabel runs per node. The README's own example does a ::find() per item. On a 500-node tree that's 500 queries. Either use the already-loaded attribute ($item['label']) or preload a lookup map before rendering. Combined with persistCollapsed() (fixed in 4.2.0), large trees are survivable — but measure before shipping a 1,000-node category tree.
maxDepth counts the moved item's own descendants. The 4.2.0 fix means a drag that would breach the limit at the subtree's deepest point is rejected, not just at the root. Good behaviour, but it surprises people who expect to indent freely. Surface the limit in the UI copy or users will file bugs.
Graph / many-to-many relationships are the sharpest edge. Saving graph relationships without detaching siblings was fixed in 4.2.0. If you're pinned below that, don't ship HasGraphRelationships trees.
"Never change the parent or order of the record whose tree is edited" is deliberate. If your UI implies you can drag the root node itself, you can't — design around a single tree root per record rather than a forest.
Don't upgrade majors casually. Package major = Filament major. A composer update that bumps the field's major will also move your Filament major, and the Action namespaces and form API move with it. Pin with "saade/filament-adjacency-list": "^4.2" and treat major bumps as planned migrations.
Debugging the state. In a form context, dump($getState()) inside the schema closure shows you the exact nested array being persisted. Do this before writing any custom save logic — the relationship mode and JSON mode produce different shapes, and guessing wrong wastes an afternoon.
Extension points worth knowing:
AdjacencyList and override methods for a house style across all trees.->modal(false) for fast inline entry on shallow trees (≤2 levels); keep the modal for anything deeper.addAction, deleteAction, …) are the right hook for confirmation dialogs and icon/colour consistency with the rest of your panel — cheaper than overriding Blade views.customPath('tree_path') is the escape hatch for Staudenmeir users who can't touch the global separator.Adoption caveats for the assessment itself: single maintainer, ~100 stars, zero dependents in the wild, and an MIT licence — great for a category/menu editor, but treat it as a component you may need to fork. The 4.1.0 perf work came from a Filament core maintainer, which is a reassuring signal about code quality, but the bus factor is one. If trees are core to your product, budget time to vendor the view partials into your theme (which the @source step already half-encourages) so minor-version upgrades don't surprise you visually.
How can I help you explore Laravel packages today?