gtcesar / filament-expandable-table
Expandable master-detail rows with nested Filament tables
Package info
github.com/gtcesar/filament-expandable-table
pkg:composer/gtcesar/filament-expandable-table
Requires
- php: ^8.3
- filament/filament: ^5.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- orchestra/testbench: ^9.0
- pestphp/pest: ^2.0
- pestphp/pest-plugin-livewire: ^2.0
This package is auto-updated.
Last update: 2026-05-09 19:55:01 UTC
README
filament-expandable-table
Expandable master-detail rows with nested Filament tables
πΊπΈ English Β |Β π§π· PortuguΓͺs
πΊπΈ English
What does this plugin do?
It adds expandable rows to Filament 5 tables. When the user clicks the expand button on a row, a full Filament table appears directly below it β no modal, no page redirect.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β # β Customer β Total β Status β [+] β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 1 β John Smith β $120.00 β Paid β [+] β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β³ Items for Order #1 β
β ββββββββββββββββββββ¬βββββββ¬βββββββββββββββββββββββββ β
β β Product β Qty β Unit Price β β
β ββββββββββββββββββββΌβββββββΌβββββββββββββββββββββββββ€ β
β β Blue T-Shirt (M) β 2 β $ 30.00 β β
β β Slim Jeans β 1 β $ 60.00 β β
β ββββββββββββββββββββ΄βββββββ΄βββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 2 β Mary Johnson β $200.00 β Pending β [+] β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The expand/collapse toggle is instant (no server round-trip). Sub-table data is loaded on demand by Livewire.
The sub-table is a full Filament table: columns, filters, actions, bulk actions, sorting, search, and pagination all work out of the box.
Requirements
You need a Laravel project with Filament 5 already installed and working.
| Dependency | Min version |
|---|---|
| PHP | 8.3+ |
| Laravel | 11 or 12 |
| Filament | 5.x |
| Livewire | 4.x (bundled with Filament 5) |
Installation
Run these two commands from your project root:
composer require gtcesar/filament-expandable-table php artisan filament:assets
The first installs the package. The second publishes the CSS and JS assets the plugin needs for styling and animations.
No extra configuration needed. Laravel auto-discovers the service provider. You do not need to touch
AppServiceProvider,PanelProvider, or any config file.
Step-by-step setup
We'll build an Orders table where each row expands to show its Order Items.
Step 1 β Create the sub-table class
Create a PHP file anywhere in your project (we suggest app/Filament/Tables/). This class describes the sub-table: which columns to show, which filters to apply, and how to fetch the related records.
<?php // app/Filament/Tables/OrderItemsTable.php namespace App\Filament\Tables; use App\Models\OrderItem; use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\EditAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Model; use Tibras\ExpandableTable\Contracts\HasExpandedTable; class OrderItemsTable implements HasExpandedTable { public function table(Table $table, Model $record): Table { // $record is the parent row (the Order). // Use $record->id to fetch only the items belonging to that order. return $table ->query( OrderItem::query()->where('order_id', $record->id) ) ->columns([ TextColumn::make('product_name') ->label('Product') ->searchable() ->sortable(), TextColumn::make('quantity') ->label('Qty') ->sortable(), TextColumn::make('unit_price') ->label('Unit Price') ->money('USD') ->sortable(), TextColumn::make('status') ->label('Status') ->badge(), ]) ->filters([ SelectFilter::make('status') ->label('Status') ->options([ 'pending' => 'Pending', 'shipped' => 'Shipped', 'delivered' => 'Delivered', ]), ]) ->actions([ EditAction::make(), DeleteAction::make(), ]) ->paginated([5, 10, 25]) ->defaultSort('product_name'); } }
What does
implements HasExpandedTablemean? It's a PHP contract that guarantees your class has thetable()method the plugin needs to call. Think of it as a promise: the plugin knows it can ask any class that implementsHasExpandedTableto build a table.
Step 2 β Add the expand button to the parent table
In your ListRecords page, add ExpandAction to the table actions array:
<?php // app/Filament/Resources/OrderResource/Pages/ListOrders.php namespace App\Filament\Resources\OrderResource\Pages; use App\Filament\Resources\OrderResource; use App\Filament\Tables\OrderItemsTable; use Filament\Resources\Pages\ListRecords; use Tibras\ExpandableTable\Actions\ExpandAction; class ListOrders extends ListRecords { protected static string $resource = OrderResource::class; protected function getTableActions(): array { return [ ExpandAction::make() ->detailComponent(OrderItemsTable::class), // Other actions (EditAction, DeleteAction, etc.) can go here too. ]; } }
This adds a [+] button to each row. Clicking it instantly toggles the row open or closed via Alpine.js β no server call needed.
Step 3 β Render the sub-table in the view
This step "closes the loop": you tell Filament where to render the sub-table when a row is expanded.
Finding the right view file:
Publish Filament's views if you haven't already:
php artisan filament:publish --views
The file to edit is:
resources/views/vendor/filament-tables/index.blade.php
Inside that file, find the <tr> that renders each data row (look for a @foreach over the records). Add the following block right after that <tr>:
{{-- Expansion row β hidden until the [+] button is clicked --}} <tr x-show="$store.expandableTable.isExpanded('{{ $this->getId() }}.{{ $record->getKey() }}')"> <td colspan="99" class="p-0"> @livewire( 'expandable-table::expanded-table-detail', [ 'recordClass' => get_class($record), 'recordKey' => $record->getKey(), 'detailComponent' => \App\Filament\Tables\OrderItemsTable::class, ], key('expand-' . $record->getKey()) ) </td> </tr>
Why
colspan="99"? So the sub-table cell spans the full width of the parent table, regardless of how many columns it has.Why
key('expand-' . $record->getKey())? Livewire uses this key to keep each row's sub-table state isolated. Without it, sub-tables from different rows interfere with each other.
Optional: start a row expanded
To have certain rows open by default when the page loads (e.g., the most recent order), use ->startExpanded():
ExpandAction::make() ->detailComponent(OrderItemsTable::class) ->startExpanded(fn (Order $record): bool => $record->is_latest),
Or pass true to expand all rows by default:
->startExpanded() // equivalent to ->startExpanded(true)
API reference
ExpandAction
| Method | Description |
|---|---|
->detailComponent(string|Closure) |
The fully-qualified class name of the class implementing HasExpandedTable. Required. |
->startExpanded(bool|Closure) |
If true (or if the Closure returns true), the row starts expanded. Default: false. |
HasExpandedTable (interface)
interface HasExpandedTable { public function table(Table $table, Model $record): Table; }
| Parameter | Description |
|---|---|
$table |
The Filament Table instance β use all the standard fluent methods: ->query(), ->columns(), ->filters(), ->actions(), ->bulkActions(), ->defaultSort(), etc. |
$record |
The parent row's Eloquent model (e.g., the Order). Use it to scope the sub-table query. |
How it works
User clicks [+]
β
βΌ
Alpine.js toggles store (no server round-trip)
β
βΌ
<tr x-show="isExpanded(...)"> becomes visible via CSS
β
βΌ
Livewire mounts ExpandedTableDetail component
β
βΌ
ExpandedTableDetail calls OrderItemsTable::table()
β
βΌ
Full Filament table rendered inside the row
Troubleshooting
Sub-table doesn't appear after clicking the button
Check that:
key('expand-' . $record->getKey())is present in the@livewirecall.x-showis on the<tr>element itself, not on a wrapper inside it.
Sub-table has no styling (CSS missing)
Run php artisan filament:assets and verify expandable-table.css was published to public/vendor/filament/.
Button exists but doesn't react to clicks
The plugin's JavaScript may not be loaded. Run php artisan filament:assets again and hard-refresh your browser (Ctrl+Shift+R).
Page is slow with many rows
Livewire mounts one component per open row. Keep the parent table paginated to 25 records or fewer.
Can I use different sub-tables depending on the record?
Yes. Use a Closure in ->detailComponent():
ExpandAction::make() ->detailComponent(fn (Order $record): string => match ($record->type) { 'wholesale' => WholesaleItemsTable::class, default => OrderItemsTable::class, }),
License
MIT β Augusto CΓ©sar (gtcesar)
π§π· PortuguΓͺs
O que esse plugin faz?
Adiciona linhas expansΓveis Γ s tabelas do Filament 5. Ao clicar no botΓ£o de expansΓ£o, a linha abre uma tabela Filament completa diretamente abaixo dela β sem modal e sem recarregar a pΓ‘gina.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β # β Cliente β Total β Status β [+] β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 1 β JoΓ£o Silva β R$120,00 β Pago β [+] β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β³ Itens do Pedido #1 β
β ββββββββββββββββββββ¬βββββββ¬βββββββββββββββββββββββ β
β β Produto β Qtd β PreΓ§o Un. β β
β ββββββββββββββββββββΌβββββββΌβββββββββββββββββββββββ€ β
β β Camiseta Azul M β 2 β R$ 30,00 β β
β β CalΓ§a Slim β 1 β R$ 60,00 β β
β ββββββββββββββββββββ΄βββββββ΄βββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 2 β Maria Souza β R$200,00 β Pendente β [+] β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
A expansΓ£o Γ© instantΓ’nea (sem chamada ao servidor para abrir/fechar). Os dados da sub-tabela sΓ£o carregados sob demanda pelo Livewire.
A sub-tabela Γ© uma tabela Filament completa: colunas, filtros, aΓ§Γ΅es, bulk actions, ordenaΓ§Γ£o, busca e paginaΓ§Γ£o funcionam normalmente.
Requisitos
VocΓͺ precisa ter um projeto Laravel com o Filament 5 jΓ‘ instalado e funcionando.
| DependΓͺncia | VersΓ£o mΓnima |
|---|---|
| PHP | 8.3+ |
| Laravel | 11 ou 12 |
| Filament | 5.x |
| Livewire | 4.x (vem com o Filament 5) |
InstalaΓ§Γ£o
Execute os dois comandos abaixo no terminal, dentro da pasta do seu projeto:
composer require gtcesar/filament-expandable-table php artisan filament:assets
O primeiro instala o pacote. O segundo publica os arquivos de CSS e JS que o plugin usa para a animaΓ§Γ£o e o estilo das linhas expansΓveis.
Nenhuma configuraΓ§Γ£o adicional. O Laravel registra o plugin automaticamente. NΓ£o Γ© preciso editar
AppServiceProvider,PanelProvidernem nenhum outro arquivo de configuraΓ§Γ£o.
ConfiguraΓ§Γ£o passo a passo
Vamos usar o exemplo de uma tabela de Pedidos onde cada linha expande para mostrar seus Itens.
Passo 1 β Criar a classe que define a sub-tabela
Crie um arquivo PHP em qualquer lugar do seu projeto (sugerimos app/Filament/Tables/). Esse arquivo descreve a sub-tabela: quais colunas mostrar, quais filtros aplicar e como buscar os registros relacionados.
<?php // app/Filament/Tables/OrderItemsTable.php namespace App\Filament\Tables; use App\Models\OrderItem; use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\EditAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Model; use Tibras\ExpandableTable\Contracts\HasExpandedTable; class OrderItemsTable implements HasExpandedTable { public function table(Table $table, Model $record): Table { // $record Γ© a linha da tabela PAI (o Pedido). // Use $record->id para buscar apenas os itens daquele pedido. return $table ->query( OrderItem::query()->where('order_id', $record->id) ) ->columns([ TextColumn::make('product_name') ->label('Produto') ->searchable() ->sortable(), TextColumn::make('quantity') ->label('Qtd') ->sortable(), TextColumn::make('unit_price') ->label('PreΓ§o Un.') ->money('BRL') ->sortable(), TextColumn::make('status') ->label('Status') ->badge(), ]) ->filters([ SelectFilter::make('status') ->label('Status') ->options([ 'pending' => 'Pendente', 'shipped' => 'Enviado', 'delivered' => 'Entregue', ]), ]) ->actions([ EditAction::make(), DeleteAction::make(), ]) ->paginated([5, 10, 25]) ->defaultSort('product_name'); } }
O que Γ©
implements HasExpandedTable? Γ uma forma do PHP garantir que a sua classe tem o mΓ©todotable()que o plugin precisa chamar. Pense como um contrato: o plugin sabe que pode pedir a qualquer classe que implementeHasExpandedTablepara montar uma tabela.
Passo 2 β Adicionar o botΓ£o de expansΓ£o na tabela pai
No seu ListRecords (o arquivo da pΓ‘gina de listagem do recurso), adicione o ExpandAction ao array de aΓ§Γ΅es da tabela:
<?php // app/Filament/Resources/OrderResource/Pages/ListOrders.php namespace App\Filament\Resources\OrderResource\Pages; use App\Filament\Resources\OrderResource; use App\Filament\Tables\OrderItemsTable; use Filament\Resources\Pages\ListRecords; use Tibras\ExpandableTable\Actions\ExpandAction; class ListOrders extends ListRecords { protected static string $resource = OrderResource::class; protected function getTableActions(): array { return [ ExpandAction::make() ->detailComponent(OrderItemsTable::class), // Outras aΓ§Γ΅es (EditAction, DeleteAction, etc.) podem ficar aqui tambΓ©m. ]; } }
Isso adiciona um botΓ£o [+] em cada linha da tabela de pedidos. Ao clicar, ele abre ou fecha a linha instantaneamente via Alpine.js β sem chamada ao servidor.
Passo 3 β Exibir a sub-tabela na view
Este Γ© o passo que "fecha o circuito": vocΓͺ diz ao Filament onde renderizar a sub-tabela quando uma linha for expandida.
Como encontrar o arquivo de view correto:
Publique as views do Filament (se ainda nΓ£o fez isso):
php artisan filament:publish --views
O arquivo a editar estΓ‘ em:
resources/views/vendor/filament-tables/index.blade.php
Dentro desse arquivo, localize o <tr> que renderiza cada linha de dado (geralmente hΓ‘ um @foreach sobre os registros). Logo apΓ³s esse <tr>, adicione o bloco abaixo:
{{-- Linha de expansΓ£o β invisΓvel atΓ© o botΓ£o [+] ser clicado --}} <tr x-show="$store.expandableTable.isExpanded('{{ $this->getId() }}.{{ $record->getKey() }}')"> <td colspan="99" class="p-0"> @livewire( 'expandable-table::expanded-table-detail', [ 'recordClass' => get_class($record), 'recordKey' => $record->getKey(), 'detailComponent' => \App\Filament\Tables\OrderItemsTable::class, ], key('expand-' . $record->getKey()) ) </td> </tr>
Por que
colspan="99"? Para a cΓ©lula da sub-tabela ocupar toda a largura da tabela, independente de quantas colunas a tabela pai tiver.Por que
key('expand-' . $record->getKey())? O Livewire usa essa chave para manter o estado de cada sub-tabela isolado. Sem ela, as sub-tabelas de linhas diferentes interferem entre si.
OpΓ§Γ£o: expandir uma linha automaticamente
Para que certas linhas jΓ‘ abram expandidas quando a pΓ‘gina carrega (por exemplo, o pedido mais recente), use ->startExpanded():
ExpandAction::make() ->detailComponent(OrderItemsTable::class) ->startExpanded(fn (Order $record): bool => $record->is_latest),
Ou passe true para expandir todas as linhas por padrΓ£o:
->startExpanded() // equivalente a ->startExpanded(true)
ReferΓͺncia da API
ExpandAction
| MΓ©todo | O que faz |
|---|---|
->detailComponent(string|Closure) |
Informa qual classe PHP monta a sub-tabela. ObrigatΓ³rio. |
->startExpanded(bool|Closure) |
Se true (ou se a Closure retornar true), a linha comeΓ§a expandida. PadrΓ£o: false. |
HasExpandedTable (interface)
interface HasExpandedTable { public function table(Table $table, Model $record): Table; }
| ParΓ’metro | DescriΓ§Γ£o |
|---|---|
$table |
InstΓ’ncia da tabela Filament β use os mΓ©todos fluentes normais: ->query(), ->columns(), ->filters(), ->actions(), ->bulkActions(), ->defaultSort(), etc. |
$record |
O model da linha pai (ex.: o Pedido). Use para filtrar os registros da sub-tabela. |
Como funciona por baixo dos panos
UsuΓ‘rio clica no botΓ£o [+]
β
βΌ
Alpine.js atualiza o store local (sem chamada ao servidor)
β
βΌ
<tr x-show="isExpanded(...)"> torna-se visΓvel via CSS
β
βΌ
Livewire monta o componente ExpandedTableDetail
β
βΌ
ExpandedTableDetail chama OrderItemsTable::table()
β
βΌ
Tabela Filament completa renderizada dentro da linha
Perguntas frequentes e problemas comuns
A sub-tabela nΓ£o aparece quando clico no botΓ£o
Verifique duas coisas:
- O
key('expand-' . $record->getKey())estΓ‘ presente na diretiva@livewire. - O
x-showestΓ‘ exatamente no elemento<tr>(nΓ£o dentro de outro elemento).
O visual da sub-tabela estΓ‘ sem estilo (sem CSS)
Execute php artisan filament:assets e verifique que o arquivo expandable-table.css foi publicado em public/vendor/filament/.
O botΓ£o existe mas nΓ£o reage ao clique
O JavaScript do plugin pode nΓ£o estar carregado. Execute php artisan filament:assets novamente e limpe o cache do navegador (Ctrl+Shift+R).
A pΓ‘gina fica lenta com muitas linhas
O Livewire instancia um componente para cada linha aberta. Mantenha a paginaΓ§Γ£o da tabela pai em 25 registros ou menos para evitar sobrecarga.
Posso usar sub-tabelas diferentes dependendo do registro?
Sim. Use uma Closure em ->detailComponent():
ExpandAction::make() ->detailComponent(fn (Order $record): string => match ($record->type) { 'wholesale' => WholesaleItemsTable::class, default => OrderItemsTable::class, }),
LicenΓ§a
MIT β Augusto CΓ©sar (gtcesar)