Key takeaways
- ✓ Measure first. Most slow Filament tables are query problems (N+1,
COUNT(*), unindexedLIKE), not Livewire problems. - ✓ Biggest wins: eager loading,
counts()/sum()aggregates instead of per-row relationship calls, and simple or cursor pagination on huge tables. - ✓ Search and sort only on indexed columns. Private S3 images and per-row closures are hidden costs.
- ✓ In production, always run
php artisan filament:optimizeandphp artisan optimizewith OPcache enabled.
Also Read: Filament v3 to v5 Upgrade: PHP Skip v4 Without Breaking Your Panel
“The customers page takes six seconds to load” is the most common Filament performance complaint, and it’s nearly always fixable in an afternoon. The table itself is rarely the problem. The work you’ve asked it to do per row and per request is. Here are the eight fixes that make the biggest difference, in the order to try them.
Step 0: Measure before you change anything
Install Laravel Debugbar locally (or use Telescope or Nightwatch), load the slow page, and look at three numbers:
- Query count. More than ~15 for one table page usually means N+1.
- Slowest query. Often a
COUNT(*)or aLIKE '%…%'. - Total PHP time outside queries. Heavy closures, image URL signing or huge Livewire payloads.
Also turn on lazy-loading prevention in development. It turns silent N+1 queries into loud exceptions:
// AppServiceProvider::boot()
use Illuminate\Database\Eloquent\Model;
Model::preventLazyLoading(! app()->isProduction());Also Read: Laravel and PHP
Fix 1: Eliminate N+1 queries
Dot-notation columns (customer.name) are eager loaded by Filament automatically. N+1 queries sneak in through everything else: formatStateUsing(), description(), state(), color() and url() closures that touch relationships, or model accessors that do.
// Slow: $record->company is lazy-loaded for every row
TextColumn::make('name')
->description(fn (Customer $record) => $record->company->name),Also Read: Laravel: Livewire 4 Islands
Load what your closures need on the table’s query:
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with(['company', 'accountManager']))
->columns([...]);
}Also Read: Filament on Laravel 13: The Complete Compatibility Checklist
(getEloquentQuery() on the resource works too, but it affects every page and global search, so keep table-only eager loads on the table.)
Fix 2: Aggregate in SQL, not in PHP
// Slow: loads every order for every row, then counts in PHP
TextColumn::make('orders_total')
->state(fn (Customer $record) => $record->orders->sum('total')),Also Read: Build a Multi-Tenant SaaS Panel PHP with Filament v5 (Step by Step)
Filament has relationship aggregates built in. They become subqueries in a single SQL statement:
TextColumn::make('orders_count')->counts('orders')->label('Orders')->sortable(),
TextColumn::make('orders_sum_total')->sum('orders', 'total')->money('GBP')->sortable(),
TextColumn::make('orders_max_created_at')->max('orders', 'created_at')->since()->label('Last order'),Also Read: Claude Opus 5.5 Released: Price Cut, Benchmarks & Breaking Changes for Developers
They’re sortable, too, which a PHP-computed value never is.
Fix 3: Fix pagination for big tables
Length-aware pagination runs SELECT COUNT(*) on every request. On tens of millions of rows with filters, that count can take longer than fetching the page itself.
use Filament\Tables\Enums\PaginationMode;
$table
->paginationMode(PaginationMode::Simple) // previous/next, no total count
// or
->paginationMode(PaginationMode::Cursor) // fastest for very large, key-sorted data
->paginated([25, 50, 100])
->defaultPaginationPageOption(25);And don’t bring back 'all'. Filament v4 removed it from the default page options for exactly this reason. Cursor pagination needs a stable, unique sort (usually the primary key), so check your default sort when you switch.
Fix 4: Make search cheap
Each ->searchable() column adds an OR col LIKE '%term%' condition. Relationship columns add whereHas subqueries. Ten searchable columns across three relationships makes every keystroke expensive.
- Search fewer columns. Name, email and reference number usually cover 95% of real searches.
- Search individually where it makes sense:
->searchable(isIndividual: true)searches only that column, from its own input. Debounce harder, or search on blur:
$table->searchDebounce('750ms'); // or $table->searchOnBlur();Use exact matches for identifiers. For order numbers and SKUs, a custom search query with
=uses the index:TextColumn::make('number') ->searchable(query: fn (Builder $query, string $search) => $query->where('number', $search)),- Use full-text search for text:
->searchable(query: fn ($q, $s) => $q->whereFullText('description', $s))with aFULLTEXTindex on MySQL, or apg_trgmindex on PostgreSQL.
Fix 5: Index what you sort and filter
Every ->sortable() column and every filter should hit an index, and in a multi-tenant panel it should be a composite index starting with team_id:
Schema::table('orders', function (Blueprint $table) {
$table->index(['team_id', 'created_at']);
$table->index(['team_id', 'status', 'created_at']);
});Check your default sort too. ->defaultSort('created_at', 'desc') on an unindexed column forces a full sort on every load. Since v4, Filament also adds a primary-key tiebreaker to sorts for stable ordering. If that makes an already-sorted query slower on a table with a custom key, ->defaultKeySort(false) turns it off.
Fix 6: Tame image and file columns
Since Filament v4, ImageColumn defaults to private visibility on non-local disks such as S3, which means generating a temporary signed URL for every image on every render. Fifty avatars means fifty signing operations per page load, and per poll.
- If the files are genuinely public, say so:
ImageColumn::make('avatar')->visibility('public'). - Serve thumbnails, not originals. Store a small version on upload.
- Limit stacked images:
->stacked()->limit(3)->limitedRemainingText().
Fix 7: Defer loading and stop needless polling
$table->deferLoading();The page shell renders immediately, and the table loads in a follow-up request. That improves perceived speed on heavy tables and lets users start using the filters straight away.
Then audit polling. ->poll('5s') on a 100-row table with aggregates re-runs everything every five seconds for every open tab. Lengthen the interval, or refresh on events instead. Our real-time widgets guide covers the options.
Also keep Livewire payloads small. Don’t put large arrays or collections in public properties on custom pages. They’re serialised and sent back and forth on every request.
Fix 8: Production optimisations everyone forgets
php artisan optimize # config, events, routes and views
php artisan filament:optimize # caches Filament components and Blade iconsAdd both to your deploy script, and make sure OPcache is on in production PHP. Filament renders a lot of small Blade components, and uncached view compilation plus a cold OPcache makes every page feel sluggish. The Filament deployment docs list the production steps.
In local development, the Filament docs also have a page on optimising local development. Things like Xdebug left on and missing OPcache make local panels feel much slower than production.
Bonus: select fewer columns
Filament selects * by default. If the table has large TEXT or JSON columns you never display, trim the select:
->modifyQueryUsing(fn (Builder $query) => $query->select([
'id', 'team_id', 'number', 'status', 'total', 'customer_id', 'created_at',
]))Include every column your columns, closures, policies and route keys need, or you’ll get nulls and missing-attribute errors. Enable Model::preventAccessingMissingAttributes() in development to catch those.
A quick diagnostic table
| Symptom | Likely cause | Fix |
|---|---|---|
| 50+ queries per page | N+1 in closures or accessors | Fix 1, 2 |
| Page slow, rows fast | COUNT(*) for pagination | Fix 3 |
| Typing in search lags | Many LIKE/whereHas columns | Fix 4 |
| Sorting a column is slow | No index | Fix 5 |
| Slow only with images | Signed S3 URLs, big originals | Fix 6 |
| CPU busy with nobody clicking | Polling | Fix 7 |
| Everything a bit slow | No caches or OPcache | Fix 8 |
Does Filament v5 or Livewire 4 make tables faster?
Filament v4 improved table rendering performance a lot compared with v3, so if you’re still on v3, upgrading helps (see going from v3 to v5). Livewire 4 (Filament v5) adds non-blocking polling and parallel live updates, which improve responsiveness. Neither fixes an N+1 query or a missing index, though. Those are your fixes to make.
FAQ
Why is my Filament table so slow?
The usual causes are N+1 queries from closures or accessors, relationship sums computed in PHP, COUNT(*) for pagination on huge tables, many LIKE searches, unindexed sorts, and private S3 image URLs signed per row.
How do I eager load relationships in a Filament table?
Use $table->modifyQueryUsing(fn (Builder $query) => $query->with(['relation'])). Dot-notation columns like customer.name are eager loaded automatically.
What does php artisan filament:optimize do?
It caches Filament’s component discovery and Blade Icons, which speeds up every panel request in production. Run it in your deploy script along with php artisan optimize.
Should I use cursor pagination in Filament?
For very large tables sorted by a unique, indexed column, yes. ->paginationMode(PaginationMode::Cursor) avoids expensive counts and deep offsets. Use PaginationMode::Simple when you just want to skip the total count.
Sources and further reading
- Filament tables overview (pagination, deferred loading)
- Filament text column (aggregates)
- Filament deployment
- Laravel Eloquent: preventing lazy loading
- Use The Index, Luke (SQL indexing explained)
Related on The Web Tier: Filament global search performance · Custom table columns
