kisame76/filament-tree-table
Expandable parent/child tree rows for Filament v4 and v5 tables. Show top-level parents, expand with a chevron to reveal inline child rows while search and filters stay correct. Includes expand-all/collapse-all actions and optional pagination by root.
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.
defaultSort() plus full ->sortable(query: ...) / relationship-column support inside the treepaginateByRoot())level/depth column required — depth is derived from parent_idcomposer 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"
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.
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.
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 theExpandableRows::make()->applyTo(...)call and theimplements HasExpandableRows+use InteractsWithExpandableRowsto that same class.
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.
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.
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);
}
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.->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).->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.grid(false) removes the per-level indentation (flat rows); the hierarchy
is then shown only by accentBar/depthTint. grid and cornerArrow are independent.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; }.HasExpandableRows (e.g. a widget sharing the
same table() definition) render completely flat — every wired behaviour self-disables.MIT.
How can I help you explore Laravel packages today?