Why Default Table Behaviour Isn't Enough
Filament's Table component covers 80% of CRUD needs out of the box. The remaining 20% — tables with expensive joins, cross-relation search, and multi-step filter UIs — requires deliberate use of APIs that the docs mention but rarely demonstrate together. This article walks through three concrete patterns you can drop into a production resource today.
1. Deferred Table Loading
When a resource's base query involves several joins or subqueries, the initial page load blocks on that query. Filament v3 ships a deferLoading() method on the table that renders a skeleton immediately and fires the real query after hydration.
public static function table(Table $table): Table
{
return $table
->deferLoading()
->columns([
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('account.balance')
->money('usd')
->sortable(),
]);
}
The skeleton uses the column count and a configurable row count (->deferLoading(rows: 8)). Pair this with ->poll('30s') only when you genuinely need live data — polling and deferred loading together mean every poll cycle re-shows the skeleton, which is jarring.
2. Live Search Across Relations
The built-in ->searchable() column modifier adds a LIKE clause on the column's database path. For relation columns (account.name) Filament generates a whereHas automatically — but only for a single level. For deeper or polymorphic relations you need ->searchable(query: ...) .
Tables\Columns\TextColumn::make('primary_contact_name')
->label('Primary Contact')
->searchable(
query: function (Builder $query, string $search): Builder {
return $query->whereHas(
'contacts',
fn (Builder $q) => $q
->where('contacts.is_primary', true)
->where(function (Builder $inner) use ($search) {
$inner->where('contacts.first_name', 'like', "%{$search}%")
->orWhere('contacts.last_name', 'like', "%{$search}%");
})
);
}
),
The closure receives the full Eloquent builder so you can add any constraint. Keep the closure pure — avoid loading models inside it or you'll create N+1 issues during search debounce cycles.
3. Custom Filter Forms with Dependent Selects
Filament filters accept a form() method that returns a schema of form components. This is where most tutorials stop. The trick is wiring reactive state between components so that selecting a Region narrows the Country options.
use Filament\Tables\Filters\Filter;
use Filament\Forms\Components\Select;
use Filament\Forms\Get;
Filter::make('location')
->form([
Select::make('region_id')
->label('Region')
->options(Region::pluck('name', 'id'))
->live()
->afterStateUpdated(fn (callable $set) => $set('country_id', null)),
Select::make('country_id')
->label('Country')
->options(
fn (Get $get) => Country::where('region_id', $get('region_id'))
->pluck('name', 'id')
)
->disabled(fn (Get $get): bool => blank($get('region_id'))),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['region_id'], fn ($q, $v) => $q->where('region_id', $v))
->when($data['country_id'], fn ($q, $v) => $q->where('country_id', $v));
}),
->live() on the first select triggers a Livewire round-trip that re-evaluates the second select's options closure. The afterStateUpdated reset prevents stale country IDs surviving a region change. The ->query() closure only applies constraints when the values are non-null, so a partially filled filter still works.
Combining All Three
These patterns compose cleanly. A table with deferLoading() + a live-search column + a dependent filter form will:
- Render instantly with a skeleton.
- Fire one initial query after hydration.
- Re-query only on explicit search/filter interaction.
The result is a resource that feels fast even when the underlying query is complex.
Takeaways
- Use
->deferLoading()on any table whose base query exceeds ~50 ms; avoid pairing it with->poll(). - Override
->searchable(query: ...)whenever the defaultwhereHaspath is insufficient or polymorphic. - Build dependent filter selects with
->live(),afterStateUpdatedresets, andGet $getclosures — no custom Livewire component needed. - Keep filter
->query()closures conditional with->when()so partial filter state doesn't over-constrain results. - Profile the generated SQL with
DB::listenduring development; Filament's query builder can produce surprising joins when sorting on relation columns.