Filament Tables and Large Datasets: Getting Pagination Right Before It Hurts
Filament paginates table queries by default, so slow admin tables are usually a per-row query problem, not a pagination problem. Here's how to configure page sizes, fix N+1 aggregates, and handle bulk actions honestly.
17 Jul 2025, 17:31 UTC

Your Filament admin panel felt snappy with 200 test rows. Then production hit 80,000 orders and the table page started taking seconds to load — or worse, timing out. The instinct is to blame pagination, but pagination is usually the one thing Filament is already doing right. The real problem is almost always what happens inside each page.
This post walks through how Filament's table pagination actually works with Eloquent, how to configure it deliberately, and where the remaining performance traps hide. Method names below follow Filament v3 conventions; if you're on v2 or a later major release, check the exact API against your installed version, because these have changed between releases.
What Filament does for you by default
Filament tables paginate the underlying Eloquent query out of the box. Each request fetches only one page of rows from the database — not the full result set hydrated into memory. Sorting and filtering are applied to the query builder before pagination runs, so the WHERE and ORDER BY clauses land in the same SQL as the LIMIT/OFFSET, and page counts stay consistent without you writing anything.
That means the baseline architecture is sound: response size stays flat as the table grows. If your table is slow, the cause is almost never "too many rows in the table" — it's per-row work multiplied by the page size.
Configuring pagination deliberately
The defaults are fine, but two settings are worth making explicit so admins don't accidentally request 500-row pages on a heavy table: the default page size and the list of selectable options.
use Filament\Tables\Table;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('reference'),
Tables\Columns\TextColumn::make('customer.name'),
Tables\Columns\TextColumn::make('items_count')
->label('Items'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'pending' => 'Pending',
'paid' => 'Paid',
'shipped' => 'Shipped',
]),
])
->defaultPaginationPageOption(25)
->paginationPageOptions([10, 25, 50])
->defaultSort('created_at', 'desc');
}
Capping the options at 50 is a deliberate choice: every row on the page costs whatever your most expensive column costs, so the page size is a multiplier on your worst column, not just a display preference.
The trap pagination doesn't fix: per-row query cost
Pagination bounds the number of rows, not the work per row. The classic failure is a relationship count rendered per row:
// Bad: one COUNT query per row — 25 extra queries on a 25-row page
TextColumn::make('items_count')
->counts('items'),
Even where Filament optimizes some of this, the safe pattern for heavy aggregates is to compute them in the query itself, so the count rides along with the page fetch:
use Illuminate\Database\Eloquent\Builder;
->modifyQueryUsing(fn (Builder $query) => $query->withCount('items'))
The same logic applies to computed attributes that touch the database, un-eager-loaded nested relationships (customer.name is fine only if customer is eager loaded), and columns that call external services. Twenty-five rows times three lazy queries each is 75 round trips per page load — that's your seconds-long table.
The bulk-action trade-off you should document
Pagination changes what "all" means. Bulk actions operate on selected records — typically the current page's selection — not on every record matching the filter. An admin who filters to "pending" and hits a bulk action may reasonably expect it to hit all 40,000 pending orders; it won't. For genuinely global operations, the honest pattern is a queued job dispatched from an action that reads the current filters, processes in chunks, and notifies on completion. Whatever you choose, write the behavior down for your users — this is a support ticket waiting to happen otherwise.
Verify it instead of trusting it
Seed a realistic dataset (tens of thousands of rows via factories), then watch the queries. With Laravel's debug bar or DB::listen in a local test, load the table and confirm: one query fetching a page-sized slice of rows, a count query for pagination totals, and no per-row SELECT storms. Then toggle a filter and a sort and check the SQL contains the expected WHERE and ORDER BY. This takes fifteen minutes and tells you more than any config review.
The takeaway: Filament's pagination is already the right architecture — your job is to keep per-row work cheap, cap page sizes, and be explicit about what bulk actions actually cover.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.