gekich/nested-tree-filter
A Laravel package for filtering nested tree structures, making it easier to query hierarchical data like categories and menus while preserving parent-child relationships in filtered results.
The package is a Laravel Nova filter that renders a hierarchical (parent/child) tree in the filter panel, so index views can be narrowed to a node and — depending on config — its descendants. With 6 stars, no dependents, and a 2021 archive date, treat it as a reference implementation you fork, not infrastructure you depend on.
Minimal steps:
laravel/nova: ^3.0 in its composer.json. Check that file before anything else.composer require gekich/nested-tree-filter in a scratch branch, or clone the repo into packages/ with a path repository if you need to patch it.php artisan vendor:publish, then npm run dev inside the package directory — Nova 3 packages typically require this, and Nova 4 uses nova.mix).// app/Nova/Product.php
public function filters(Request $request)
{
return [
\Gekich\NestedTreeFilter\NestedTreeFilter::make('Category')
->model(\App\Models\Category::class),
];
}
parent_id, a children() / parent() relation, and a display column.Where to look first: the filter class's apply() method and the options/constructor — that's where the author's assumptions about column names, relation names, and depth live. The README is thin at this star count; the source is the documentation.
First use case: a Category tree (3–4 levels) driving a Product index. Selecting "Electronics" should return products in Electronics and Phones, Laptops, etc. That single flow exercises every part of the package: tree rendering, descendant resolution, and the apply() query.
Filter a belongsTo relation by subtree. The filter's value is a node ID; apply() must expand it to the node plus all descendants before constraining the query. If the package doesn't do this for you, override it:
public function apply(Request $request, $query, $value)
{
$ids = $this->descendantIds((int) $value); // includes $value itself
return $query->whereIn('category_id', $ids);
}
protected function descendantIds(int $root): array
{
$ids = [$root];
$frontier = [$root];
while ($frontier) {
$children = Category::whereIn('parent_id', $frontier)->pluck('id')->all();
$frontier = array_diff($children, $ids);
$ids = array_merge($ids, $frontier);
}
return $ids;
}
Prefer a bounded-depth recursion over unbounded. A while loop against a corrupted tree (a node whose ancestor is itself) runs forever. Add a depth cap or a visited set.
For large trees, swap the recursion for a recursive CTE (MySQL 8 / Postgres) or a materialized path / nested-set column. A 20k-node category tree resolved with one query per level is the usual production failure mode here.
Register the same filter with different scopes when a resource has multiple tree-shaped dimensions:
NestedTreeFilter::make('Category')->model(Category::class),
NestedTreeFilter::make('Region')->model(Region::class)->key('region_tree'),
Give each a distinct key() — Nova derives the query-string key from the filter's name, and two filters named after their class will collide.
Set a sensible default for dashboards where an unfiltered view is meaningless:
NestedTreeFilter::make('Category')
->model(Category::class)
->default($request->user()->default_category_id),
Combine with Nova's built-in filters freely — the tree filter is just another where/whereIn clause, so it composes with RangeFilter on dates or a BooleanFilter for status without ordering concerns.
Customize labels without touching the model by passing an accessor-produced attribute as the label column (e.g. an indented_name accessor that prepends — per depth). The tree UI rarely renders indentation itself; you supply it.
Eager-load the tree once. If the filter's options are computed on every request (common in these packages), cache the flattened list per model with a short TTL and clear it on save via an observer.
composer.json constraint bump, and recompiling the JS. If you only need the tree widget, a 120-line custom Nova filter built on SelectFilter plus a descendant query is often cheaper than resurrecting this.webpack.mix.js package build with laravel-nova's own tooling; a Nova 3 component will silently fail to register and you'll get a blank filter panel entry rather than an error.apply() may not recurse. Verify against source before trusting the README. If it only filters where('parent_id', $value) or where('id', $value), it filters one level, and your users will report "the filter is broken" when a parent category shows nothing.parent_id. Add it. Recursive descent without it degrades to a table scan per level.parent_id pointing at a deleted row, or a two-node cycle, will either infinite-loop the resolver or drop nodes from the tree silently. Guard both in apply() and add a Category::saving check that rejects self-parenting.?category_tree= value for a deleted node yields an empty index that looks like a bug. Validate the incoming ID and ignore unknown ones.npm run build, or you'll debug a stale Vue component for an hour.apply() directly rather than through Nova's HTTP layer — instantiate the filter, build a Builder, call apply with a Request carrying the filter key, and assert the SQL bindings contain the full descendant set. That's the whole contract worth testing.Extension points, in order of usefulness: subclass the filter and override apply() (keeps the tree UI, replaces the query logic); override the options/options() method to control what's listed (e.g. hide archived branches); replace the Vue component with your own if you need search-inside-tree or multi-select, which this package almost certainly lacks.
Bottom line for day-to-day use: the UI idea is right and the implementation is small enough to audit in an afternoon. Adopt the pattern, not the dependency — pin it in a private fork you control, or reimplement the ~100 lines against the Nova version you actually run.
How can I help you explore Laravel packages today?