Filament v5 Performance: Making a 100,000-Row Panel Feel Instant

Fix Filament performance at scale: measure the query, render and payload layers, kill N+1 column closures, index the real query and tune search.

Steven Richardson
Steven Richardson
· 17 min read

The orders table took eleven seconds. Sorting by customer name timed out entirely. The dashboard spun for six seconds before a single number appeared, and typing in global search locked the browser tab hard enough that people stopped using it. The panel had taken an afternoon to build and everybody loved it in staging, where the database had 400 rows.

Nothing about that is a Filament problem. Every symptom above is a specific, diagnosable query or rendering fault with a known fix — and the expensive mistake is not picking the wrong fix, it is picking the right fix for the wrong layer. Optimising queries when your bottleneck is Blade render time costs you a day and changes nothing. What follows is the order I work in, on a seeded 100,000-row orders table with customers and line items, measuring at every step.

Measure the three layers before you change anything#

Filament performance problems live in three separate layers that produce identical symptoms, so the first job is always deciding which one you are in. Layer one is the database: how many queries the page runs and how long they take in total. Layer two is render: how long PHP spends turning those records into HTML, which is pure Blade and has nothing to do with your indexes. Layer three is transport: how many kilobytes Livewire ships back and forth on every interaction.

Install Telescope or Debugbar, load one page of the table, and write down three numbers.

composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

Then read them like this:

Symptom Query count Query time Render time Likely layer
Table takes 8s, every page 200+ 3s+ low N+1 in a column
Table takes 8s, worse when sorted 4 7s+ low Missing index
Table takes 8s, queries all sub-10ms 4 40ms 6s+ Blade render
First load fine, every click slow 4 40ms low Livewire payload

That fourth row is the one people miss, and the third row is the one that surprises them: a table can run four fast queries and still take six seconds, because rendering fifty rows of columns with actions is genuine CPU work. If you are unsure which monitoring tool to reach for, I compared them in Telescope vs Debugbar vs Pulse — for this job Debugbar is fastest to read, Telescope is better for capturing the exact SQL to run EXPLAIN against. For watching a single Livewire request end to end, Spatie Ray with Livewire and Eloquent beats both.

One rule before you go further: do this against production-scale data locally. A 500-row development database hides every problem in this article, and "it's fine on my machine" is the single most wasted sentence in this whole exercise.

Upgrade to a current Filament patch release#

Before you touch your own code, check what version you are actually running, because the framework got materially faster in a patch release and upgrading is free. Filament v4.12.6 and v5.7.6 shipped a substantial performance pass: form components like TextInput and Select render around 92% faster, a Repeater with ten items around 90% faster, and tables with 50 rows and row actions are roughly 49–52% faster through lighter rendering paths and per-record caching of visibility and authorization checks. Those same releases also resolved a set of CVEs, so there is a security argument as well as a speed one.

composer show filament/filament | head -3
composer update "filament/*" --with-all-dependencies

If that closes the gap, stop — you have just fixed a render-layer problem without writing any code. If you are still on v4 and have been putting the jump off, the step-by-step upgrade path from v4 to v5 covers the breaking changes that bite, and the schema unification in v5 is what makes the newer render optimisations possible in the first place.

Shape the table query in getEloquentQuery()#

Every query Filament runs for a resource starts at getEloquentQuery(), and that is the one correct place to shape it. For a single table page load, Filament builds a base query, applies search constraints, applies the sort, runs a separate COUNT(*) for the paginator, and then — if you are not careful — issues one more query per relationship column per row.

<?php

namespace App\Filament\Resources\Orders;

use App\Models\Order;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;

class OrderResource extends Resource
{
    protected static ?string $model = Order::class;

    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()
            ->select([
                'orders.id',
                'orders.reference',
                'orders.status',
                'orders.total_cents',
                'orders.customer_id',
                'orders.created_at',
            ])
            ->with('customer:id,name')
            ->withCount('lines')
            ->withSum('lines as lines_total_sum', 'total_cents');
    }
}

Two things matter here beyond the eager loading. The explicit select() stops you hydrating a notes TEXT column and a payload JSON blob into fifty models you never display — that is bytes off the query, bytes out of memory, and bytes out of the Livewire payload later. And with('customer:id,name') constrains the eager load to the two columns the table actually shows. Filament will eager-load a dot-notation column like customer.name for you automatically, but it loads the whole related model; naming the columns yourself is strictly better once the related table is wide.

Replace column closures with Eloquent aggregates#

Filament resolves every column for every row, which makes a closure that touches a relationship an N+1 by construction rather than by accident. This is the shape that puts 200 queries on a 50-row page:

// Don't. One query per row, every row, every page.
TextColumn::make('lines_total')
    ->getStateUsing(fn (Order $record) => $record->lines->sum('total_cents'));

Move the work into the database and read the aggregate as a plain column. Laravel's relationship aggregate functions store results using a fixed naming convention, and Filament's column helpers follow the same convention:

use Filament\Tables\Columns\TextColumn;
use Illuminate\Database\Eloquent\Builder;

TextColumn::make('lines_count')
    ->counts('lines')
    ->label('Items'),

TextColumn::make('lines_total_sum')
    ->sum('lines', 'total_cents')
    ->money('gbp')
    ->label('Line total'),

// Scope the aggregate without leaving the column definition.
TextColumn::make('lines_count')
    ->counts([
        'lines' => fn (Builder $query) => $query->where('is_refunded', false),
    ]),

On the seeded table that took the page from 210 queries to 4. Two caveats worth knowing. First, with() and withCount() are not interchangeable: if you only need a number, withCount() runs a subquery, while with() hydrates every related model just to count them — which is how eager loading occasionally makes things dramatically worse. Second, if what you actually want is a totals row rather than a per-row figure, Filament v5 table summarizers do it in one aggregate query instead of summing in PHP.

The durable fix is to stop these reaching production at all. Turning on Eloquent strict mode so lazy loading throws in local and CI means the next closure like this fails a test instead of becoming a support ticket eighteen months from now.

Index the queries Filament actually runs#

Most index advice is written for queries nobody runs. Take the SQL out of Telescope verbatim, run EXPLAIN on it, and index that. The default sort is the highest-value index in the whole panel because it is on every single page load:

EXPLAIN SELECT orders.id, orders.reference, orders.status, orders.total_cents
FROM orders
WHERE orders.status = 'pending'
ORDER BY orders.created_at DESC
LIMIT 25 OFFSET 0;

Before the index that reports type: ALL, rows: 98214, Extra: Using filesort — a full scan and an in-memory sort of a hundred thousand rows to return twenty-five. The fix is a composite index in the order the query uses the columns: equality predicate first, sort column second.

public function up(): void
{
    Schema::table('orders', function (Blueprint $table) {
        $table->index(['status', 'created_at'], 'orders_status_created_at_index');
    });
}

After: type: ref, rows: 25, no filesort. Three points that decide whether an index helps at all:

  • Order matters. ['created_at', 'status'] will not serve that query well; ['status', 'created_at'] will. If your table has a default filter and a default sort, index the pair together.
  • Accessors cannot be indexed. If a column sorts on full_name computed in PHP, no index exists that can help. Use sortable(['first_name', 'last_name']) to sort by the real columns instead, or a custom sortable(query: ...) closure.
  • Index for the default state. Users sort by six different columns; they load the page in one default state thousands of times a day. Index that first, then the two sorts people actually use.

Cut the pagination count when it dominates the page#

Once the sort is indexed, the remaining slow query on a very large table is often the paginator's COUNT(*), which has to consider every matching row just to render "Page 1 of 4,129". You can see it plainly in Telescope: a fast 25-row fetch sitting next to a 900ms count. Filament gives you two escape hatches, both on the table.

use Filament\Tables\Enums\PaginationMode;
use Filament\Tables\Table;

public function table(Table $table): Table
{
    return $table
        ->paginationMode(PaginationMode::Simple)
        ->paginated([25, 50, 100])
        ->defaultPaginationPageOption(25);
}

PaginationMode::Simple drops the count and renders previous/next only. PaginationMode::Cursor goes further and paginates by key rather than offset, which also fixes the other half of the problem — deep OFFSET 50000 pages are slow even with a perfect index, because the database still walks the rows it is skipping. Cursor pagination has a real cost in usability (no page numbers, no jumping to the end), so trade it deliberately.

While you are here, remove 'all' from your pagination options if it is there. The Filament v5 pagination documentation warns about it explicitly and it is right: one admin selecting "all" on a hundred thousand rows will take the panel down for everyone. The same logic applies to counts elsewhere in the UI — status tabs each run their own aggregate, so if you use status tabs with counts on a list page, those counts are queries too and deserve the same scrutiny.

Make search hit an index instead of scanning a join#

This is the single most common cause of a table that works at 5,000 rows and dies at 50,000. By default ->searchable() applies a where ... like '%term%' clause, and a leading wildcard means no B-tree index can be used — the database scans. Put that on a relationship column and you are scanning across a join. Worse, Filament splits the search term into individual words by default and searches each one separately, so a three-word search is three of those scans.

Start by turning the splitting off on large tables, which the column searching documentation flags explicitly as a performance measure:

$table->splitSearchTerms(false)
    ->searchDebounce('750ms')
    ->searchOnBlur();

Then decide what the search actually means. If users search by reference prefix, say so, and the index works:

use Illuminate\Database\Eloquent\Builder;

TextColumn::make('reference')
    ->searchable(query: fn (Builder $query, string $search): Builder => $query
        ->where('reference', 'like', "{$search}%")),

That trailing-only wildcard is servable by an index on reference. For genuine free-text search over customer names and notes, stop trying to make LIKE work and use full-text — MATCH ... AGAINST on MySQL, tsvector on Postgres — or move the whole thing to Scout. Filament supports Scout cleanly through searchUsing():

use App\Models\Order;

$table->searchUsing(fn (Builder $query, string $search) => $query
    ->whereKey(Order::search($search)->keys()));

Past a few hundred thousand rows that is the answer rather than a nicety, and my production guide to Laravel Scout with Typesense covers the indexing and faceting side. If you do keep search in the database, use searchable(isIndividual: true, isGlobal: false) on the expensive columns so they only run when someone deliberately searches that column, rather than on every global keystroke.

Defer loading so the panel shell paints first#

Deferring makes nothing faster. It changes when the user sees something, which is a different and often more valuable property — the page shell, navigation and table header paint immediately, and the rows stream in behind a skeleton state.

public function table(Table $table): Table
{
    return $table
        ->deferLoading()
        ->columns([
            // ...
        ]);
}

On the waterfall you go from a single eight-second document request to a 200ms document plus a background Livewire request, and the panel stops feeling broken. Be honest about when this is wrong, though: on a table that is the only thing on the page and already loads in 200ms, deferring adds a round trip and a visible flash of skeleton for no benefit. Defer the expensive things, not everything.

Collapse the dashboard into one cached aggregate query#

A dashboard with six stats widgets runs at minimum six aggregate queries before the first paint, usually against the same table, frequently with no index behind them. Widgets in Filament v5 are lazy-loaded by default, so they no longer block the page — but they still each cost a query, and if they poll they cost that query repeatedly, per admin, forever.

Check the polling interval first. Widgets poll on a default interval unless you say otherwise, and a five-second poll across twenty concurrent admins is 240 aggregate queries a minute for numbers that change hourly.

class OrderStats extends StatsOverviewWidget
{
    protected ?string $pollingInterval = null; // or '60s'

    protected function getStats(): array
    {
        $stats = cache()->remember('dashboard.order-stats', now()->addMinutes(5), fn () => DB::table('orders')
            ->selectRaw('count(*) as total')
            ->selectRaw("sum(case when status = 'pending' then 1 else 0 end) as pending")
            ->selectRaw('coalesce(sum(total_cents), 0) as revenue_cents')
            ->first());

        return [
            Stat::make('Orders', number_format($stats->total)),
            Stat::make('Pending', number_format($stats->pending)),
            Stat::make('Revenue', Number::currency($stats->revenue_cents / 100, 'GBP')),
        ];
    }
}

Six queries become one, and the five-minute TTL removes it from most page loads entirely. The TTL is the judgement call: five minutes of staleness on a revenue-to-date card is invisible, and five minutes of staleness on a "jobs currently failing" card is a bug that will cost someone an incident. Cache the slow-moving aggregates, leave the operational ones live.

For chart widgets specifically, polling intervals and deferred loading in Filament chart widgets goes deeper than I will here, and the pattern generalises past Filament — #[Computed(persist: true)] for caching heavy queries across Livewire requests is the right tool the moment you build a custom panel page with its own expensive lookups.

Shrink the Livewire payload#

Here is the layer almost nobody writes about. Filament round-trips component state on every interaction, so a wide table with fat models can ship hundreds of kilobytes each way to change a sort direction. Open DevTools, filter the network panel to livewire, click a column header, and read the request and response sizes. Anything over ~100KB on a table interaction is worth attacking.

Three things inflate it. Eager-loaded relations held on the component get serialised along with everything else — which is the second reason the constrained select() and with('customer:id,name') from earlier pay off. Accessors that build large arrays or format HTML get computed and shipped. And public properties on custom components are serialised in full when an ID would have done.

use Livewire\Attributes\Locked;

class OrderTriage extends Component
{
    // Not the model. The key.
    #[Locked]
    public int $orderId;

    public function getOrderProperty(): Order
    {
        return Order::findOrFail($this->orderId);
    }
}

#[Locked] does double duty: it keeps the property out of the client's reach so it cannot be tampered with between requests, and it signals intent clearly to the next developer. On one client panel, trimming the selected columns and replacing two model properties with IDs took a sort interaction from 340KB to 28KB — a bigger real-world win than any cache I added that week.

Tune global search so it stops firing on every keystroke#

Global search queries every globally searchable resource on each debounce tick. Six resources means six queries per keystroke burst, and if any of those resources shows relationship details in its results, each one lazy-loads those relations per result row. Fix it at three levels.

Panel level, widen the debounce:

$panel->globalSearchDebounce('750ms')
    ->globalSearchResourceOptIn();

globalSearchResourceOptIn() is the high-leverage one: it flips the default so that only resources declaring protected static bool $isGloballySearchable = true; participate at all. Most panels have three resources people search and nine they never do.

Resource level, cap the results and eager-load the details:

protected static int $globalSearchResultsLimit = 10;

protected static ?bool $shouldSplitGlobalSearchTerms = false;

public static function getGlobalSearchEloquentQuery(): Builder
{
    return parent::getGlobalSearchEloquentQuery()->with(['customer', 'channel']);
}

That last override is straight out of the global search documentation and it is the difference between ten queries and one when your results show customer names underneath the order reference. If you are customising what those results render, custom HTML result titles and inline actions in global search covers the presentation side — just remember that every relation you reference in a result detail needs to be in that eager-load list.

Queue bulk actions instead of running them in the request#

Someone will select "all 100,000" and click your bulk action. Done naively that hydrates every model into memory and runs until PHP's time limit kills it halfway through, leaving the job half-applied. Filament's bulk action documentation has a first-class answer:

use Filament\Actions\BulkAction;
use Illuminate\Support\LazyCollection;

BulkAction::make('markDispatched')
    ->chunkSelectedRecords(250)
    ->action(function (LazyCollection $records): void {
        $records->each(fn (Order $order) => $order->update(['status' => 'dispatched']));
    })
    ->deselectRecordsAfterCompletion();

chunkSelectedRecords() turns $records into a LazyCollection fetched 250 at a time, which caps memory regardless of selection size. For anything genuinely heavy, do not process it in the request at all — use fetchSelectedRecords(false) to receive keys only, dispatch a job, and notify the user when it finishes. The same discipline applies to Filament's built-in import and export bulk CSV actions, which already queue their work, and if you are now pushing real volume through workers, scaling Laravel queues in production is the next thing to read.

Lock the query count with a Pest test#

Every fix above is one careless pull request away from being undone, and the failure mode is silent — nobody notices a reintroduced N+1 until the table is slow again six months later. Assert the query count in CI instead.

use App\Filament\Resources\Orders\Pages\ListOrders;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

use function Pest\Livewire\livewire;

it('renders the orders table within a bounded query count', function () {
    Order::factory()->count(60)->hasLines(3)->create();

    $queries = 0;
    DB::listen(function () use (&$queries): void {
        $queries++;
    });

    livewire(ListOrders::class)->assertOk();

    expect($queries)->toBeLessThan(10);
});

The assertion is deliberately a ceiling rather than an exact number, because an exact count turns every unrelated change into a failing test and gets deleted within a month. A ceiling catches the thing that matters — a per-row query — and tolerates the things that do not. Seed more rows than a page holds, or an N+1 on page two will slip through. For the surrounding Filament testing setup, authentication and table assertions, testing Filament v5 resources with Pest covers the harness this test sits in.

The mistakes I see most often, in rough order of how much time they waste: caching a table query and serving admins stale rows they just edited themselves; adding indexes for queries Filament does not actually run, because nobody read the SQL; deferring every widget and table until the panel feels laggy in a new and more confusing way; and reaching for Octane with FrankenPHP or a bigger database instance before running a single EXPLAIN. Octane is excellent and it will not save you from a full table scan.

FAQ#

Why is my Filament table so slow?

Almost always one of three things: an N+1 query caused by a column closure touching a relationship, a missing index on the column the table sorts by, or Blade render time on a wide table with many row actions. They produce identical symptoms, so measure query count, total query time and render time separately before changing anything. If query count is high, look at your columns; if query time is high but the count is low, run EXPLAIN on the sort query; if both are fine and the page is still slow, you are in the render layer and your fix is fewer columns, fewer rows per page, or a current Filament patch release.

How do I fix N+1 queries in a Filament resource?

Move the work into the query. Override getEloquentQuery() on the resource and add with() for relationships you display, withCount() for counts and withSum() for totals, then read the results using Filament's counts(), sum() and avg() column helpers rather than a getStateUsing() closure. Filament eager-loads dot-notation columns like customer.name automatically, but any closure that calls $record->relation will run once per row. Enable Eloquent strict mode locally so lazy loading throws an exception and the problem fails a test instead of reaching production.

How do I make Filament tables load faster with lots of records?

Work in order: upgrade to a current Filament patch release, eliminate N+1 queries in columns, add a composite index matching your default filter plus default sort, switch to simple or cursor pagination if the paginator's COUNT(*) is slow, constrain the query with an explicit select(), and only then reach for deferLoading() to improve perceived speed. Each step is cheap to verify — reload the page and re-read the three numbers — so you never spend a day on a fix that changes nothing.

What is deferLoading in Filament and when should I use it?

deferLoading() tells a Filament table not to run its query during the initial page render. The page shell paints immediately with a skeleton state and the rows arrive in a follow-up Livewire request. It does not make any query faster; it makes a slow panel feel responsive by getting something on screen in a couple of hundred milliseconds. Use it on tables that genuinely take seconds, and skip it on fast tables where the extra round trip and skeleton flash are a net loss.

How do I speed up Filament global search?

Reduce how many resources are searched, how often, and how much each search returns. Call globalSearchResourceOptIn() in your panel configuration so only resources explicitly marked $isGloballySearchable = true participate, widen globalSearchDebounce() to 750ms, lower $globalSearchResultsLimit to around ten per resource, and set $shouldSplitGlobalSearchTerms = false on large tables. If your results display relationship details, override getGlobalSearchEloquentQuery() to eager-load those relations or you will run one query per result row.

Should I cache Filament widgets?

Cache the widgets whose numbers can be slightly stale, and leave the operational ones live. A revenue-to-date or total-customers stat is fine behind a five-minute TTL and saves an aggregate query on every dashboard load. A queue-depth or failing-jobs card is not — a stale value there is worse than a slow one, because someone will act on it. Before caching anything, check the widget's polling interval: setting $pollingInterval to null or '60s' often removes more load than the cache would.

How many records can a Filament table handle?

There is no fixed ceiling — Filament paginates, so it renders 25 or 50 rows regardless of whether the table holds ten thousand or ten million. What breaks at scale is everything around the page of rows: unindexed sorts, LIKE '%term%' searches, the paginator's COUNT(*), and N+1 queries that multiply by page size. I have run Filament panels comfortably over tables with several million rows, but only with a composite index behind the default sort, cursor pagination, and search moved to full-text or Scout.

Why is my Filament dashboard slow to load?

Count the widgets and multiply. Six stats widgets are at least six aggregate queries, often against the same table and usually unindexed, and if they poll they repeat that cost for every admin who leaves the tab open. Combine related stats into a single query with conditional aggregates, cache the result with a short TTL, set $pollingInterval deliberately rather than accepting the default, and let Filament's default widget lazy loading keep them off the critical render path.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.