When Your Filament Table Slows Down: Getting Server-Side Pagination Right
Slow Filament tables usually aren't a pagination problem — they're a query problem. How getEloquentQuery, eager loading, and the right indexes decide whether sorting and filtering stay fast at scale.
05 Sept 2025, 00:59 UTC

A Filament admin panel that felt snappy with 500 seed records starts dragging once the real data arrives. The table page takes seconds to load, sorting a column freezes the UI, and the database shows full table scans. In almost every case like this, the fix isn't a new caching layer — it's understanding how Filament tables actually fetch rows, and making sure your query and your indexes cooperate with it.
The useful takeaway: Filament tables already paginate through Laravel's paginator, so the performance problem is rarely "too much data in the browser" and almost always "the SQL behind getTableQuery() is doing more work than it needs to."
How Filament tables fetch data
Every Filament table is backed by an Eloquent query you define, typically in a resource class:
public static function table(Table $table): Table
{
return $table
->columns([/* ... */])
->paginated([10, 25, 50]);
}Under the hood, Filament applies your filters and sort order to that query, then calls Laravel's paginator. Each page turn, sort click, or filter change triggers a Livewire request that re-runs the query with a new LIMIT/OFFSET (or cursor, depending on configuration). The browser never holds the whole dataset — only the current page.
That design means two things. First, you don't need to hand-write pagination logic; writing raw forPage() calls yourself would fight the framework. Second, every interaction is a fresh SQL query, so query quality is the whole game.
The query you write is the query you get
When you need to customize the base query — say, scoping orders to the current tenant — you override getEloquentQuery() on the resource (or pass a modifyQueryUsing callback on a relation manager):
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where('tenant_id', auth()->user()->tenant_id)
->with(['customer:id,name']); // eager load to avoid N+1
}Two details here matter more than they look. The with() call prevents an N+1 query per row when a column displays a relationship — a common reason tables feel slow even at small page sizes. And the tenant scope must be in the base query, not a filter, so it applies to the count query too.
Speaking of the count query: Laravel's paginator runs SELECT COUNT(*) alongside the page query to render page numbers. On very large tables with heavy WHERE clauses, that count can cost as much as the data fetch. If you don't need exact page numbers, a simple paginator (previous/next only) skips it — Filament exposes pagination options on the table, so check which paginator your version uses before assuming.
Indexes decide whether sorting is free or fatal
Sorting in a Filament table becomes an ORDER BY on the column you marked ->sortable(). Without an index on that column, the database sorts the full result set on every click. The same applies to filter columns.
A concrete example: an orders table with 200,000 rows, sorted by placed_at and filtered by status. A composite index matches the query shape:
Schema::table('orders', function (Blueprint $table) {
$table->index(['tenant_id', 'status', 'placed_at']);
});Column order in the index follows the query: equality conditions first (tenant_id, status), then the sort column. You can confirm the database uses it by running the generated SQL through EXPLAIN. To see the generated SQL, temporarily add ->toRawSql() in a tinker session:
# In `php artisan tinker`, run against your dev database
App\Models\Order::where('tenant_id', 1)
->where('status', 'pending')
->orderBy('placed_at', 'desc')
->toRawSql();Paste the output into EXPLAIN in your database client. You're looking for an index scan rather than a sequential scan and an external sort. Never run EXPLAIN conclusions from production data volumes against a tiny dev dataset — the planner behaves differently at scale.
Search is the other trap
Filament's table search box generates WHERE column LIKE '%term%' for each searchable column. A leading wildcard defeats normal B-tree indexes, so on large tables, searching several text columns can be slower than everything else combined. Options, in increasing effort: limit ->searchable() to genuinely useful columns, use database full-text indexes with a custom query scope, or move search to a dedicated engine. Often just trimming searchable columns from six to two is enough.
Trade-offs worth knowing
Server-side pagination means every interaction costs a network round trip and a query — there's no instant client-side re-sort. It also means "select all" selects the current page, not the whole filtered set, which surprises users who expect spreadsheet behavior. And deep pagination (page 4,000) gets expensive because OFFSET still scans skipped rows; if users genuinely need to walk huge result sets, cursor pagination or keyset-style filtering by ID is the better pattern, though it changes what the UI can offer.
Check your own table in five minutes
Open the browser's network tab, click a sortable column, and inspect the Livewire request: the payload should include the sort column, direction, and page. Then enable Laravel's query log (or a debug bar) and confirm each interaction produces one page query, one count query, and no per-row relationship queries. If you see dozens of queries per page load, you have an N+1 problem; if you see one slow query, run EXPLAIN and index accordingly. That loop — observe, explain, index — fixes the large majority of slow Filament tables without touching the framework at all.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.