Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Select Tree Laravel Package

codewithdennis/filament-select-tree

Add a dynamic select tree field to Laravel and Filament apps. Build interactive hierarchical selection dropdowns from BelongsTo or BelongsToMany relationships, or custom queries. Customize parent and child queries, and toggle strict null-parent root nodes.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the Filament 4-compatible release and publish the package assets:

composer require codewithdennis/filament-select-tree:4.x
php artisan filament:assets

If the project is still on Filament 3, use the 3.x package branch instead. Run the asset command as part of deployment, then clear cached views/components if the field renders unstyled.

The quickest first use case is a category tree on a resource form. You need a self-referencing model table, for example categories with id, name, and nullable parent_id, plus the relationship on the model.

use CodeWithDennis\FilamentSelectTree\SelectTree;

SelectTree::make('categories')
    ->relationship('categories', 'name', 'parent_id')
    ->searchable();

For a BelongsToMany relationship the field state is an array. For a BelongsTo relationship use the singular relationship name and the foreign key as the field name:

SelectTree::make('category_id')
    ->relationship('category', 'name', 'parent_id');

Look first at the README “Relationships” and “Methods” sections; the recent 4.2 releases also matter because they add and fix strictNullParentRootNodes(), which affects how filtered trees behave.

Implementation Patterns

Use relationship-backed fields wherever possible. They give you persistence and the correct single/multiple state handling for free. A BelongsToMany field is multi-select by default; a BelongsTo field is single-select.

SelectTree::make('categories')
    ->relationship('categories', 'name', 'parent_id')
    ->enableBranchNode()
    ->withCount()
    ->searchable();

Use a plain query when the tree is not an Eloquent relationship. Explicitly set multiple(false) when the field should store one value:

SelectTree::make('category_id')
    ->query(fn () => Category::query(), 'name', 'parent_id')
    ->multiple(false)
    ->placeholder('Select a category');

Scope parent and child queries separately. This is the normal pattern for active/visible trees, tenant scoping, or excluding archived branches:

SelectTree::make('categories')
    ->relationship(
        relationship: 'categories',
        titleAttribute: 'name',
        parentAttribute: 'parent_id',
        modifyQueryUsing: fn ($query) => $query->where('is_active', true),
        modifyChildQueryUsing: fn ($query) => $query->where('is_active', true),
    );

If you filter the parent query, children whose parents were filtered out are promoted to root nodes by default. If that is wrong for your use case, add:

->strictNullParentRootNodes()

Use the tree as a table filter. For filter forms, branch selection and dependent behavior are usually more useful than leaf-only selection:

use Filament\Tables\Filters\Filter;
use Illuminate\Database\Eloquent\Builder;
use CodeWithDennis\FilamentSelectTree\SelectTree;

Filter::make('tree')
    ->form([
        SelectTree::make('categories')
            ->relationship('categories', 'name', 'parent_id')
            ->independent(false)
            ->enableBranchNode()
            ->multiple(true),
    ])
    ->query(function (Builder $query, array $data) {
        $categories = (array) ($data['categories'] ?? []);

        return $query->when($categories, function (Builder $query) use ($categories) {
            return $query->whereHas(
                'categories',
                fn (Builder $query) => $query->whereIn('id', $categories)
            );
        });
    })
    ->indicateUsing(function (array $data): ?string {
        $categories = (array) ($data['categories'] ?? []);

        if ($categories === []) {
            return null;
        }

        return 'Categories: ' . Category::whereIn('id', $categories)->pluck('name')->implode(', ');
    });

Use prepend() or append() for sentinel filter options. This is the standard “Uncategorized” pattern:

SelectTree::make('categories')
    ->relationship('categories', 'name', 'parent_id')
    ->enableBranchNode()
    ->multiple(true)
    ->prepend([
        'name' => 'Uncategorized Records',
        'value' => '-1',
        'parent' => null,
        'disabled' => false,
        'hidden' => false,
        'children' => [],
    ]);

Then handle -1 explicitly in the filter query, alongside the normal whereHas.

Use getTreeUsing() when the structure is not a simple Eloquent parent-child query. This is useful for external APIs, precomputed arrays, or custom sorting. The tree must follow the expected shape:

SelectTree::make('category_id')
    ->getTreeUsing([
        [
            'name' => 'Parent Category',
            'value' => '1',
            'children' => [
                [
                    'name' => 'Child Category',
                    'value' => '2',
                    'children' => [],
                ],
            ],
        ],
    ])
    ->multiple(false);

This gives full display control, but it does not automatically save a relationship for you. Pair it with manual form state persistence if the field is not backed by a relationship.

Use storeResults() when option state depends on the fetched models. This lets disabledOptions() and hiddenOptions() receive the component and inspect results:

SelectTree::make('categories')
    ->relationship('categories', 'name', 'parent_id')
    ->storeResults()
    ->disabledOptions(function ($state, SelectTree $component) {
        return $component->getResults()
            ->where('is_locked', true)
            ->pluck('id')
            ->all();
    });

Use UI methods intentionally. Common combinations:

  • ->searchable() for larger trees.
  • ->alwaysOpen() or ->staticList() for embedded/always-visible pickers.
  • ->direction('top') when the field sits near the viewport bottom.
  • ->defaultOpenLevel(2) to open the first few levels.
  • ->showTags(false)->tagsCountText('elements selected') for compact multi-select display.
  • ->grouped(false) when you want individual leaf nodes displayed instead of the parent group once all leaves are selected.
  • ->isGroupedValue() when the stored value should be the parent node when all children are selected.
  • ->parentNullValue(-1) only if -1 is a deliberate sentinel in your data model.
  • ->withKey('code') when the parent relationship uses a non-primary key column pair such as code/parent_code.

Gotchas and Tips

Do not skip php artisan filament:assets. The field depends on published frontend assets. If it renders as an empty input or the dropdown does not open, check asset publishing and cached assets before debugging the PHP side.

Filtered parent queries promote orphaned children to root by default. This is deliberate, to avoid empty trees, but it can surprise you in tenant-scoped or permission-scoped trees. Use ->strictNullParentRootNodes() when children must only appear under surviving parent results.

Branch selection changes what your filter means. If enableBranchNode() is enabled and a user selects a parent category, your filter query must decide whether that means “records assigned exactly to this parent” or “records assigned to this parent or any descendant.” A plain whereIn('id', $categories) only matches the selected IDs; it does not expand descendants. Add recursive/descendant logic if the business meaning is the whole subtree.

State shape depends on relationship type. BelongsToMany gives arrays; BelongsTo gives a scalar. For non-relationship query() use, explicitly call ->multiple(false) if you want a single value. Mixing these up causes whereIn failures or array-to-string errors in filter queries.

independent(false) and expandSelected() are for dependent tree behavior. Do not treat them as general filter options. They are most useful when the tree should behave like a cascading hierarchy and expand selected values. expandSelected() only works when the field is dependent.

prepend()/append() values should use the same type as normal node values. If your tree uses integer IDs, use integers, not '-1' strings, unless you intentionally normalize in the filter callback. The array keys are name, value, parent, disabled, hidden, and children.

withKey() is not just a display change. It changes the key used to build parent-child links. If your table has id, code, and parent_code, make sure the relationship/pivot and filter logic also consistently use the same key. Test selection and persistence, not just rendering.

storeResults() increases memory and query cost. It fetches and retains models for option-state callbacks. Use it when you need disabledOptions/hiddenOptions logic based on model data; otherwise skip it.

staticList() and alwaysOpen() can affect layout. They make the list part of the page flow instead of an overlay. Check parent overflow, z-index, and mobile layouts; these methods are not free if the form is inside a constrained modal or panel.

Use a unique treeKey() when multiple trees appear on one page. Otherwise Livewire/component state can collide, especially in repeated form sections or relation managers.

grouped(false) and isGroupedValue() are different concerns. grouped(false) changes display: individual leaves are shown instead of the parent group. isGroupedValue() changes value semantics: when all children are selected, return the parent value. Choose deliberately; using both without understanding the filter query can produce unexpected stored values.

Soft deletes need explicit intent. ->withTrashed() only matters when the model and relationship are set up for trashed records. If selected trashed records are no longer valid, your validation and filter logic still need to account for that.

Large trees are not lazy-loaded by default. The tree is built from the query/array you provide. Index parent_id (or your equivalent parent column), scope queries where possible, and avoid rendering thousands of nodes in every form. For very large datasets, prefer a custom query or getTreeUsing() with pre-filtered data rather than loading the entire table.

Test form state and filter queries separately. The most common failures are not rendering failures; they are state-shape mismatches, missing asset publishing, and filter callbacks that do not expand branch selections the way the user expects.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table