Filament v3 Table Tricks: Deferred Loading, Live Search, and Custom Filter Forms
#filament #laravel #livewire #tables

Filament v3 Table Tricks: Deferred Loading, Live Search, and Custom Filter Forms

4 min read Mohamed Said Mohamed Said

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:

  1. Render instantly with a skeleton.
  2. Fire one initial query after hydration.
  3. 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 default whereHas path is insufficient or polymorphic.
  • Build dependent filter selects with ->live(), afterStateUpdated resets, and Get $get closures — 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::listen during development; Filament's query builder can produce surprising joins when sorting on relation columns.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does `deferLoading()` affect SEO or server-side rendering?
Filament tables are rendered inside Livewire components, so they are not crawled by search engines regardless. `deferLoading()` has no SEO impact — it only changes when the Livewire component fires its initial data fetch after the page HTML is delivered.
Q02 Can I use the custom `searchable(query:)` closure alongside Filament's global search?
The `query:` closure on a column only affects the table's per-column search bar, not the global search. Global search uses the `getGlobalSearchResultsUsing` method on the resource class, which you configure separately.
Q03 How do I reset all dependent filter fields when the user clears the filter form?
Implement `->resetFiltersFormUsing()` on the table, or rely on Filament's built-in 'Reset filters' action which calls `$this->resetTableFiltersForm()` and clears all filter state, triggering a fresh query.

Continue reading

More Articles

View all