kisame76/filament-tree-table

Expandable parent/child tree rows for Filament v4 & v5 tables — inline sub-rows, search/filter aware, expand & collapse all.

Maintainers

Package info

github.com/Kisame76/filament-tree-table

pkg:composer/kisame76/filament-tree-table

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.0 2026-06-18 10:57 UTC

This package is auto-updated.

Last update: 2026-06-18 11:03:08 UTC


README

Filament Tree Table

Filament Tree Table

Filament Latest Version on Packagist Tests Total Downloads

Expandable parent/child tree rows for Filament v4 & v5 tables. Show only top-level parents, expand them with a chevron, and render the sub-rows inline as real table rows — search and filter stay correct, and you get expand-all / collapse-all buttons.

  • ✅ Inline expandable rows that keep all your existing columns
  • ✅ Search / filter aware — flatten to a flat match list, or keep the tree and reveal each match with its ancestor path (auto-expanded, non-matching ancestors dimmed as context)
  • ✅ Custom sibling ordering — defaultSort() plus full ->sortable(query: ...) / relationship-column support inside the tree
  • ✅ Clear sub-rows without forcing an icon convention: a corner-arrow glyph on children and/or a coloured accent bar (+ optional per-depth tint) — mix or switch, fully themeable
  • ✅ Expand-all / collapse-all header actions
  • ✅ Optional pagination by root — keep each family on one page instead of splitting parents and children across page boundaries (paginateByRoot())
  • ✅ Database-agnostic ordering (Postgres / MySQL / SQLite)
  • ✅ No stored level/depth column required — depth is derived from parent_id

Requirements

  • PHP 8.2+
  • Filament v4 or v5

Installation

composer require kisame76/filament-tree-table

The CSS auto-registers with Filament. After install (and on deploy) run:

php artisan filament:assets

Optionally publish the config:

php artisan vendor:publish --tag="filament-tree-table-config"

Usage

Your model needs a self-referencing parent_id column and a children() HasMany relationship (both names are configurable).

It takes two pieces — applying the tree where the table is defined, and opting the page in.

1. Apply the tree in your table definition

In Filament's resource structure the table lives in Tables/<Name>Table::configure() (or directly in the resource's table() method). Wrap it with ExpandableRows:

use Filament\Tables\Table;
use Kisame76\FilamentTreeTable\ExpandableRows;

class CategoriesTable
{
    public static function configure(Table $table): Table
    {
        return ExpandableRows::make()
            ->parentKey('parent_id')           // default
            ->childrenRelationship('children') // default
            ->applyTo(
                $table->columns([
                    // ... your normal columns
                ])
            );
    }
}

applyTo() prepends the chevron toggle column, wires the tree query, applies the per-row styling, and adds the expand/collapse-all header actions.

2. Opt the page in

The List page is the Livewire component that holds the expand state, so add the interface + trait there. It has no table() of its own — that stays in the table class:

use Filament\Resources\Pages\ListRecords;
use Kisame76\FilamentTreeTable\Concerns\InteractsWithExpandableRows;
use Kisame76\FilamentTreeTable\Contracts\HasExpandableRows;

class ListCategories extends ListRecords implements HasExpandableRows
{
    use InteractsWithExpandableRows;

    protected static string $resource = CategoryResource::class;
}

Relation managers and table widgets define table() on the component itself, so there is only one class: add the ExpandableRows::make()->applyTo(...) call and the implements HasExpandableRows + use InteractsWithExpandableRows to that same class.

Configuration

Every cue is an independent toggle — combine or switch freely:

ExpandableRows::make()
    ->parentKey('parent_id')                        // default
    ->childrenRelationship('children')              // default
    ->recordKey(fn ($record) => $record->getKey())  // for non-default primary keys
    ->grid(true)                                    // per-level stepping (indentation) on/off
    ->cornerArrow(true)                             // corner-down-right glyph on children
    ->accentBar(true)                               // coloured bar on the child's left edge
    ->depthTint(true)                               // per-depth background tint (child rows lighter)
    ->recordClasses(fn ($record, int $depth) => []) // extend/override row classes
    ->expandAllAction(true)
    ->collapseAllAction(true)
    ->flattenOnSort(false)                          // false (default): sort hierarchically, keep tree; true: flat sorted list
    ->flattenOnFilter(true)                         // true (default): a filter flattens the tree; false: keep the tree + reveal matches with ancestors
    ->flattenOnSearch(true)                         // true (default): a search flattens the tree; false: keep the tree + reveal matches with ancestors
    ->paginateByRoot(false)                         // false (default): paginate by row; true: paginate by root so families never split across pages
    ->defaultSort('sort')                           // default sibling order: a column name...
    ->defaultSort(fn ($query, $direction) => $query->orderBy('sort', $direction)) // ...or a closure (order by a related/computed value)
    ->applyTo($table);

Project-wide defaults live in config/filament-tree-table.php.

Translations

The expand-all / collapse-all action labels are translatable. English (en) and German (de) ship with the package; publish the language files to add or override locales:

php artisan vendor:publish --tag="filament-tree-table-translations"

This writes lang/vendor/filament-tree-table/{locale}/tree-table.php, where each locale defines actions.expand_all and actions.collapse_all.

Theming

All visuals are driven by CSS variables — override them in your panel theme:

.ftt-row {
  --ftt-slot: 1.5rem; /* width of each marker column (indent step) */
  --ftt-accent-color: rgb(99 102 241 / 0.85);
  --ftt-tint-color: rgb(99 102 241);
}

How it works / caveats

  • Filtering / search: by default (flattenOnFilter(true) / flattenOnSearch(true)) an active filter or search drops the tree and shows a flat list of every match, so nothing stays hidden behind a collapsed parent. Set either to false to keep the tree — matches are then shown together with their ancestor path (auto-expanded), and the non-matching ancestors are dimmed via the .ftt-context class (style it in your theme). While a filter/search drives the expansion the chevrons are non-interactive and the expand/collapse-all actions hide, so the displayed state can't be toggled out of sync.
  • Pagination: by default the page counts visible tree rows, so page sizes shift as you expand and a branch can span a page boundary (a parent on one page, its children on the next). Enable ->paginateByRoot() to paginate by root instead: each page holds N roots (the per-page selection) plus all of their currently visible descendants, so a family is never split — the row count per page then varies, and "Showing X to Y of Z" and the page count refer to roots. It is a no-op while the view is flattened (sort/filter/search), under a non-default pagination mode, or with ->paginated(false). For very deep trees you can also disable pagination entirely with ->paginated(false).
  • Sorting: a column sort is delegated to the column itself, so ->sortable(query: ...) closures and relationship/computed columns order the tree exactly as they would a flat table. Use defaultSort() for the sibling order when no column sort is active. With flattenOnSort(false) (default) the sort stays hierarchical — each sibling group follows the column while keeping the tree grouped under its parent (Jira-style); set flattenOnSort(true) to drop the tree and show a flat sorted list.
  • Stepping: grid(false) removes the per-level indentation (flat rows); the hierarchy is then shown only by accentBar/depthTint. grid and cornerArrow are independent.
  • Column manager: the chevron toggle column is kept out of the column-manager panel (the toggleableColumns() / reorderableColumns() dropdown) and is always pinned as the first column, so a persisted column order can never hide it or push it to the back. It still carries a non-breaking-space label internally (->toggleColumnLabel('…') to change it) because Filament rejects blank labels on reorderable columns — the label is no longer shown anywhere. The pinning is done by InteractsWithExpandableRows, which overrides Filament's getDefaultTableColumnState() / updateTableColumns(). On a List page, relation manager, or table widget this just works (the base class supplies InteractsWithTable). The only exception is a bare custom Livewire component that uses InteractsWithTable and InteractsWithExpandableRows side by side — there, resolve the trait conflict explicitly: use InteractsWithTable, InteractsWithExpandableRows { InteractsWithExpandableRows::getDefaultTableColumnState insteadof InteractsWithTable; InteractsWithExpandableRows::updateTableColumns insteadof InteractsWithTable; InteractsWithExpandableRows::paginateTableQuery insteadof InteractsWithTable; }.
  • Components that do not implement HasExpandableRows (e.g. a widget sharing the same table() definition) render completely flat — every wired behaviour self-disables.

License

MIT.