Filament resource list with status tabs and filters
A Filament 4 list page with query-backed status tabs showing live counts, plus select and toggle table filters on the same resource.
FilamentTablesfilamenttabsfilterslaravel
Code
<?php
namespace App\Filament\Resources\Invoices;
use App\Filament\Resources\Invoices\Pages\ListInvoices;
use App\Models\Invoice;
use Filament\Actions\EditAction;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class InvoiceResource extends Resource
{
protected static ?string $model = Invoice::class;
public static function table(Table $table): Table
{
return $table
->defaultSort('due_at')
->columns([
TextColumn::make('number')->searchable()->sortable(),
TextColumn::make('client.name')->searchable()->toggleable(),
TextColumn::make('total')->money('usd', divideBy: 100)->sortable(),
TextColumn::make('status')->badge()->color(fn (string $state): string => match ($state) {
'paid' => 'success',
'sent' => 'info',
'overdue' => 'danger',
default => 'gray',
}),
TextColumn::make('due_at')->date()->sortable(),
])
->filters([
SelectFilter::make('client_id')
->label('Client')
->relationship('client', 'name')
->searchable()
->preload(),
Filter::make('large')
->label('Over 1,000')
->toggle()
->query(fn (Builder $query): Builder => $query->where('total', '>', 100_000)),
])
->deferFilters(false) // apply filters instantly, no "Apply" button
->recordActions([EditAction::make()]);
}
public static function getPages(): array
{
return ['index' => ListInvoices::route('/')];
}
}
<?php
namespace App\Filament\Resources\Invoices\Pages;
use App\Filament\Resources\Invoices\InvoiceResource;
use App\Models\Invoice;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
class ListInvoices extends ListRecords
{
protected static string $resource = InvoiceResource::class;
public function getTabs(): array
{
// One grouped query feeds every badge instead of a count per tab.
$counts = Invoice::query()
->selectRaw('status, count(*) as total')
->groupBy('status')
->pluck('total', 'status');
return [
'all' => Tab::make('All')->badge($counts->sum()),
'draft' => Tab::make('Drafts')
->badge($counts['draft'] ?? 0)
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'draft')),
'unpaid' => Tab::make('Unpaid')
->badge($counts['sent'] ?? 0)
->badgeColor('warning')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'sent')),
'overdue' => Tab::make('Overdue')
->badge($counts['overdue'] ?? 0)
->badgeColor('danger')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'overdue')),
'paid' => Tab::make('Paid')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'paid')),
];
}
public function getDefaultActiveTab(): string|int|null
{
return 'unpaid';
}
}
Needs: filament/filament@^4.0
How it works
What it solves
Billing or order lists usually need two ways to slice the same table: a quick status switch at the top and finer filters for everything else. This pattern wires both into one Filament 4 resource, so staff can jump to "Overdue" in one click and still narrow by client or amount.
How it works
getTabs() on the list page returns Tab objects. Each tab adds a modifyQueryUsing constraint to the base table query, and the active tab is kept in the URL. Badge numbers come from a single grouped query that is plucked into a status-to-count map, so rendering five tabs costs one extra query, not five.
The resource's table() adds a relationship SelectFilter for the client and a toggle Filter with a closure query for large totals. Filters stack on top of the active tab, so "Overdue" plus "Over 1,000" works with no extra code. deferFilters(false) makes filters apply as soon as they change.
Adapting it
Swap the model, status values and column set. If the status is an enum, use $counts[Status::Paid->value]. Add a tab with a different constraint (for example due_at this week) and the badge for it can use its own count().
Pitfalls
- Badge counts ignore the table's other filters and search, they describe the whole status, not the filtered view.
- If you scope the resource through
getEloquentQuery()(tenant, soft deletes), apply the same scope to the count query or the badges will not match the rows. getDefaultActiveTab()must return a key that exists ingetTabs().
How to use it
- Install Filament 4 and create a resource:
php artisan make:filament-resource Invoice. - Replace the generated resource
table()and the list page with the two files, adjusting namespaces to your app. - Make sure the
invoicestable hasnumber,client_id,total(integer cents),statusanddue_at. - Open the invoices list in the panel: the tabs sit above the table and the filters in the toolbar.
- To add a tab, return another
Tab::make(...)with amodifyQueryUsingclosure.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.