You've got an orders table in Filament, and every standup someone asks how many orders are still pending. The answer is in the database, but finding it means opening a filter dropdown and picking a value — friction for a question people ask ten times a day. Tabs on a Filament v5 List page fix that: always-visible, mutually-exclusive query scopes with a live count baked into each one, wired up with a single getTabs() method.
Add the getTabs() method to the List page#
Tabs live on the resource's List page, not the resource class itself. Open the ListRecords page Filament generated alongside your resource and add a getTabs() method that returns an array of Tab objects. The array keys are identifiers — Filament uses them as the tab's URL slug and, if you don't pass a label, to generate one. Filament v5 generates resources into their own directory, so this class usually sits at app/Filament/Resources/Orders/Pages/ListOrders.php; adjust the namespace if your app uses the flat resource layout.
namespace App\Filament\Resources\Orders\Pages;
use App\Filament\Resources\Orders\OrderResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab; // v5 namespace — not Filament\Resources\Components\Tab
class ListOrders extends ListRecords
{
protected static string $resource = OrderResource::class;
public function getTabs(): array
{
return [
'all' => Tab::make('All orders'),
'pending' => Tab::make('Pending'),
'processing' => Tab::make('Processing'),
'shipped' => Tab::make('Shipped'),
'cancelled' => Tab::make('Cancelled'),
];
}
}
That renders five tabs, but every one of them shows every order — none of them scope anything yet. The Tab class moved to Filament\Schemas\Components\Tabs\Tab in the v4/v5 schema unification, so a copied v3 use statement is the first thing that breaks.
Scope each tab's query with modifyQueryUsing()#
A tab with no modifier shows the full table. To turn it into a filter, chain modifyQueryUsing() and narrow the Eloquent builder. The closure receives the table's base query, so whatever you add is layered on top of the resource's own query and any active filters — you're constraining, not replacing.
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
public function getTabs(): array
{
return [
'all' => Tab::make('All orders'),
'pending' => Tab::make('Pending')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'pending')),
'processing' => Tab::make('Processing')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'processing')),
'shipped' => Tab::make('Shipped')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'shipped')),
'cancelled' => Tab::make('Cancelled')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'cancelled')),
];
}
The all tab keeps no modifier, so it stays the unscoped view. If status is cast to a backed enum on the model, pass the enum case instead of the string: $query->where('status', OrderStatus::Pending).
Show a live record count on each tab#
The count is what earns tabs their place on the page — the team sees the backlog without clicking into it. Add ->badge() with a count query, and colour it with ->badgeColor() so a full "Pending" queue reads as a warning and "Cancelled" sits muted. Colours accept Filament's semantic names (primary, success, warning, danger, gray).
use App\Models\Order;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
'pending' => Tab::make('Pending')
->badge(Order::query()->where('status', 'pending')->count())
->badgeColor('warning')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'pending')),
A badge answers "how many are pending" right on the tab. If you also want that number as a headline metric across the top of the page — with a trend arrow and a sparkline — that's a different component; reach for a stats overview widget with trends and sparklines for the dashboard view and keep the badge for the per-tab count.
Set the default active tab#
By default Filament opens the first tab in the array — usually all. But an ops team that lives in this table wants to land on the work that needs doing, not on everything. Return the tab's array key from getDefaultActiveTab() and they open straight into the pending queue.
public function getDefaultActiveTab(): string | int | null
{
return 'pending';
}
The active tab is also persisted in the URL's query string, keyed by your array key (it shows up as an activeTab parameter). A refresh, a bookmark, or a link shared with a teammate all reopen the same tab — which is exactly why meaningful keys like pending beat generic ones like tab1. It's the same query-string-as-state idea you'd reach for when binding table filters to the URL query string in Livewire; Filament just wires it up for the tab automatically.
Add icons to make the statuses scannable#
Icons turn a row of similar-length words into shapes the eye catches before it reads. Pass a Heroicon name to ->icon(), and if you'd rather the icon trail the label, move it with ->iconPosition().
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Support\Enums\IconPosition;
'pending' => Tab::make('Pending')
->icon('heroicon-m-clock')
->iconPosition(IconPosition::Before) // Before is the default; After trails the label
->badge(Order::query()->where('status', 'pending')->count())
->badgeColor('warning')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'pending')),
Pick icons that map to the state at a glance: heroicon-m-clock for pending, heroicon-m-cog-6-tooth for processing, heroicon-m-truck for shipped, heroicon-m-x-circle for cancelled, and heroicon-m-rectangle-stack for the catch-all all tab. That's the whole feature: scoped queries, counts, a sensible default, and icons.
Sidestep the badge and record-resolution gotchas#
Two things bite once tabs are in production. First, every badge count is its own COUNT query that runs on each page load, so five status tabs means five extra queries before the table even renders. Second, Filament resolves the record a user clicks through the active tab's query — which breaks the moment an action changes that record out of the tab.
For the counts, if the queries are expensive, defer them with deferBadge() so the page paints first and the numbers stream in afterwards. There's one catch the docs flag in bold: badge() must receive a closure when you defer, not a raw value. Pass a plain ->badge(Order::query()->count()) and the query still runs immediately while the tab is built, defeating the whole point.
'pending' => Tab::make('Pending')
->badge(fn (): int => Order::query()->where('status', 'pending')->count())
->deferBadge(),
That's the same defer-then-load pattern you'd use for a chart widget that polls and defers its data. And keeping those counts cheap is the same discipline as switching on Eloquent strict mode to catch N+1 queries — every badge is a query you're choosing to run on each load, so make each one count.
The record-resolution trap is subtler. Say you're on the "Pending" tab and an action marks an order as shipped; a second action in the same modal then tries to resolve that order and fails, because the active tab's where('status', 'pending') no longer matches it. Opt the tab out of that scoping for resolution with excludeQueryWhenResolvingRecord().
'pending' => Tab::make('Pending')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'pending'))
->excludeQueryWhenResolvingRecord(),
Do not add that to tabs that enforce authorization. If a tab scopes to the current tenant or the signed-in user's own records, excluding that scope when resolving a record opens a hole where someone can act on a row they shouldn't see. Use it for status-style tabs, never for ownership ones.
Decide between tabs and filters before you ship#
Tabs are not a replacement for filters — they solve a different problem, and reaching for the wrong one makes the page worse. Use tabs for a small, fixed set of mutually-exclusive states the user switches between constantly: order status, subscription state, read versus unread. They're always visible, one is always active, and each carries a count. Use filters for optional, combinable criteria the user reaches for now and then: a date range, a "created by" select, a minimum-amount slider. A user can stack three filters at once; they can only ever be on one tab.
The moment you want two states active together, or you're staring at a dozen tab options, that's the signal to move the choice into a combinable filter form with custom table filters instead. A rough rule: five or fewer exclusive states belong in tabs; anything a user combines, or any long list, belongs in filters.
Ship the tabs first — getTabs() with a modifyQueryUsing() and a badge on each is maybe ten minutes of work and the single highest-impact change you can make to a busy List page. Layer filters on only when a tab stops being enough. And if this List page is one screen inside a larger panel, the same patterns scale up when you're building a Filament dashboard from zero to production.
FAQ#
How do I add tabs to a Filament list page?
Add a getTabs() method to your resource's List page (the ListRecords class) that returns an array of Filament\Schemas\Components\Tabs\Tab objects. Each array key identifies a tab, and each Tab::make() can scope the table's query with modifyQueryUsing(). Filament renders the tabs above the table automatically — you don't touch the resource or the table definition to get them.
How do I show a record count on a Filament tab?
Chain ->badge() on the tab and pass a count, such as ->badge(Order::query()->where('status', 'pending')->count()), and colour it with ->badgeColor('warning'). If the count query is expensive, wrap it in a closure and add ->deferBadge() so it loads after the page renders. Remember the badge value must be a closure when you defer it, otherwise the query runs immediately and deferral does nothing.
What's the difference between tabs and filters in Filament?
Tabs are always-visible and mutually exclusive: one is active at a time, each scopes the query one way, and each can show a count. Filters live behind a button, are optional, and combine — a user can apply several at once. Use tabs for a small fixed set of states like order status, and use filters for optional, stackable criteria like a date range or a search field.
How do I set the default tab in Filament?
Add a getDefaultActiveTab() method to the List page and return the array key of the tab you want selected on load, for example return 'pending';. Without it, Filament opens the first tab in your getTabs() array. The method's return type is string | int | null, so returning null falls back to that first tab.
Can I keep the selected tab in the URL?
Yes, and you don't have to do anything for it. Filament persists the active tab in the URL's query string using your tab's array key, appearing as an activeTab parameter. A page refresh, a bookmark, or a link shared with a teammate all reopen the same tab, which is why readable keys like pending are worth more than generic ones like tab1.