A Filament table that opens instantly on seed data can slow down a lot once a customer has used it for two years. In a multi-tenant panel the slowdown is uneven: most workspaces stay fast, and the one with 400,000 orders opens a support ticket. The causes are usually a short list of queries you can see in a profiler, and Filament v5 has a setting for most of them.
This guide works through one table: the Orders list in a tenant panel, where Team is the tenant and every order has a team_id. Every snippet below was run against a test app on PostgreSQL with one team holding 400,000 orders, 1.2 million order items and 40,000 customers, next to five small teams with 5,000 orders each. The query counts are what Filament sent; the timings are from that machine, and the results are collected at the end. The examples use 25 rows a page. Each section looks at the SQL the table sends first, because that is what decides which fix helps.
Measure the request before changing anything
A Filament table page is a Livewire component. The first page load renders it, and after that every search, sort, filter and page change is a Livewire update request that runs the table's queries again. Those update requests are what people sit through, so measure them as well as the first load.
One table render usually sends:
- a
count(*)over the filtered rows, for the "Showing 1 to 25 of 400,000" line, - the
selectfor the current page, - one query per eager-loaded relationship,
- one query per summary row, if the table has summaries,
- anything your column closures, filters and actions load themselves.
For the large team, the guide's finished Orders table sent exactly four: the count, the page, the customers and their companies.
Laravel Debugbar lists every query of a request with its time and bindings, and its request picker includes the Livewire update calls. Laravel Telescope records queries across many requests and tags slow ones, which helps when you want to click around for a while and look afterwards. Keep both to local and staging. For production, the framework can log slow queries itself:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
public function boot(): void
{
// Throw on lazy loading outside production, so an N+1 fails while you develop.
Model::preventLazyLoading(! app()->isProduction());
DB::listen(function (QueryExecuted $query) {
if ($query->time > 200) {
Log::warning('Slow query', ['ms' => $query->time, 'sql' => $query->toRawSql()]);
}
});
}
In the test app, the first entries this logged were the pagination count for the large team.
Test against a tenant that looks like your largest customer. You don't need factories for that: a few insert ... select ... from generate_series(...) statements seeded the 400,000 orders and 1.2 million items above in about a minute. Take the slowest query from that tenant and ask the database how it plans to run it: ->explain() on a query builder returns the plan (chain ->dd() to dump it). A Seq Scan on PostgreSQL or Using filesort on MySQL for orders is usually where the time goes.
Index for the query the table really runs
When a panel has tenancy, Filament registers a global scope on each tenant-scoped resource's model. Every query the table runs carries where orders.team_id in (?) (Filament uses whereBelongsTo(), which writes a one-value in; the database treats it as =), and that includes the count and the summaries. The active filters and the sort come on top. Filament also appends the primary key to the sort, in the same direction as the default sort, so rows never swap places between pages. The Orders page query looks like this:
select * from orders
where orders.status = ? and orders.team_id in (?)
order by placed_at desc, orders.id desc
limit 25 offset 0
With an index on team_id alone, the database finds all of the team's rows and then sorts them: for a small team in the test app, an index scan over its 5,000 rows plus a sort, for every page. With an index on placed_at alone, it walks every tenant's orders in date order and throws away the ones that belong to someone else. That was fast for the large team, which owns most rows, and slowest for the small teams, which had to skip past everybody else's orders. A composite index serves the query for everyone: the columns compared with = first, then the sort columns.
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::table('orders', function (Blueprint $table) {
// Default list: newest first, inside one team.
$table->index(['team_id', 'placed_at', 'id']);
// The status filter, then the same sort.
$table->index(['team_id', 'status', 'placed_at', 'id']);
// Order number lookups (see the search section).
$table->index(['team_id', 'number']);
});
Schema::table('order_items', function (Blueprint $table) {
// InnoDB creates an index for a foreign key constraint; PostgreSQL does not.
$table->index('order_id');
});
With these, explain() showed an Index Scan Backward on the first index with no sort step for the default list, for the large team and the small ones alike, and the status filter used the second. Put team_id first in every index the table uses, since every query filters on it. An index that starts with team_id also serves queries that filter by the team alone, so a separate single-column team_id index usually becomes redundant. Index the default sort and the filters people actually apply rather than one index per sortable column; every index adds a little to each insert and update. After adding one, run explain() again and check that the plan uses it with no separate sort step for the default view.
How Filament writes the scope matters too. When the model's tenant relationship is a BelongsTo (or a MorphTo), the scope is a plain team_id condition. When orders reach the team only through another table, Filament falls back to a whereHas() subquery, which is much harder to index. For a large table scoped that way, adding a team_id column to the table itself is usually worth it.
Columns that load relationships once per page
Filament eager-loads the relationships named in your columns. TextColumn::make('customer.name') adds with('customer') to the table query, so 25 rows cost one extra query instead of 25. N+1 queries come back through closures that reach into a relationship on their own, such as a description() or formatStateUsing() that reads $record->customer->company. In the test app that one description took the page from 4 queries to 28, and with preventLazyLoading() on it threw instead. Either name the relationship in a column or load it once with modifyQueryUsing().
Counts and totals of related rows have their own column methods, which become withCount(), withExists() and withSum() on the table query:
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('customer.company'))
->columns([
TextColumn::make('number')
->searchable(),
TextColumn::make('customer.name')
->description(fn ($record) => $record->customer->company?->name),
TextColumn::make('items_count')
->counts('items')
->label('Items'),
TextColumn::make('items_sum_quantity')
->sum('items', 'quantity')
->label('Units')
->toggleable(isToggledHiddenByDefault: true),
IconColumn::make('refunds_exists')
->exists('refunds')
->boolean()
->label('Refunded'),
TextColumn::make('total')
->money('EUR')
->sortable(),
TextColumn::make('placed_at')
->dateTime()
->sortable(),
])
->defaultSort('placed_at', 'desc');
}
The same two columns written as closures that call $record->items()->count() and $record->refunds()->exists() sent 54 queries for one page instead of 4.
avg(), min() and max() work the same way. Each one adds a correlated subquery to the page select, and it stays cheap only with the order_items.order_id index from the previous section: without it, every subquery scanned all 1.2 million items, and the page query took about 3 seconds instead of a few milliseconds. The subqueries run for each row the database reads, not just the ones it returns, so on page 1,600 they also run for the 39,975 rows skipped by the offset. The pagination count leaves these subqueries out.
Filament applies aggregates and eager loads only for visible columns. A column hidden with toggleable(isToggledHiddenByDefault: true) adds nothing to the query until someone switches it on, which makes it a good home for the expensive ones. Be careful with sortable() on aggregate and relationship columns: those sort on an expression that no composite index can serve. Keep sorting for columns that have an index behind them, and for small tables where any sort is cheap.
Let the page paint first with deferLoading()
deferLoading() renders the page with an empty table and fetches the rows in a second request:
return $table
->deferLoading()
// ...
The first request ran no table queries at all; the second ran the same four as before, so a slow table stays slow. The difference is in how the page feels: navigation, header actions and the search field show up right away, and one heavy table stops holding up everything around it. The filter and column buttons appear too, but stay disabled until the rows arrive. It suits dashboards with table widgets, and relation managers on a record page, where the table is not the first thing a person reads.
Filters already wait in Filament v5. They are deferred by default: opening the filter dropdown and picking values sent no queries, and the table query ran once, when Apply was clicked. On a large table, leave that default alone.
Pagination without the count
Default pagination runs count(*) over every filtered row of the tenant on each request, to print the total and the page links. With the right index, the page select reads 25 rows, but the count still visits all 400,000. For the large team in the test app it was the slowest query on the page: PostgreSQL scanned the table in parallel rather than use the index, since that team owns most of it.
Filament has two other modes:
use Filament\Tables\Enums\PaginationMode;
return $table
->paginationMode(PaginationMode::Simple)
->paginationPageOptions([25, 50, 100]);
PaginationMode::Simple uses Laravel's simplePaginate(). It fetches one row more than the page size (limit 26) to find out whether there is a next page, and never counts: the page went from 4 queries to 3, and its database time from about 150 ms to 35 ms. People lose the total and the numbered links and keep previous and next.
PaginationMode::Cursor uses cursorPaginate(), which also skips the count. Instead of offset 39975, the next page asks for rows after the last one seen, which an index on the sort columns answers just as fast on page 1,600 as on page 2. On the large team, that query took about 12 ms against about 160 ms for the same page by offset. Every sort column has to be a real column on the table. The indexed placed_at plus the primary key that Filament appends fits that, but a sortable aggregate or relationship column does not, so drop sortable() from those on a cursor-paginated table.
paginationMode() also accepts a closure. A panel can keep numbered pages for most workspaces and switch the largest ones to simple pagination, based on a plan or a flag you already store on the tenant. Filament's default page options are 5, 10, 25 and 50; if you change them, keep them modest and leave 'all' out on a table that can grow without limit.
Table-wide summaries cost as much as the count
A summarizer such as Sum::make() on the total column can render two rows: one for the current page, shown when there is more than one page, and one for every filtered record. Each is one more query. The page row sums the 25 rows on the page. The all-records row wraps the whole filtered table query in select sum(total) from (...) on every request, and costs about as much as the pagination count (PostgreSQL drops the unused aggregate subqueries from it).
If nobody needs the grand total on every click, turn it off and keep the page row:
use Filament\Tables\Columns\Summarizers\Sum;
return $table
->summaries(pageCondition: true, allTableCondition: false)
->columns([
TextColumn::make('total')
->money('EUR')
->summarize(Sum::make()->money('EUR')),
// ...
]);
On the large team, that took the page from 6 queries to 5 and cut its database time by more than half.
The page row is only drawn for numbered or simple pagination; with cursor pagination Filament shows no page summary.
Headline numbers like this month's revenue fit better in a stats widget above the table, cached for a few minutes. In a tenant panel, put the tenant in the cache key, or one team sees another team's revenue:
use App\Models\Order;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Cache;
$revenue = Cache::remember(
'team:'.Filament::getTenant()->getKey().':orders:revenue:'.now()->format('Y-m'),
now()->addMinutes(10),
fn () => Order::query()->where('placed_at', '>=', now()->startOfMonth())->sum('total'),
);
Search that can and cannot use an index
A column marked searchable() adds where number like '%term%' to the query. Several searchable columns are joined with or, and a searchable relationship column such as customer.name turns into a whereHas() subquery. On PostgreSQL, Filament also wraps the column in lower(...::text) so search ignores case. A pattern that starts with % cannot use an ordinary B-tree index, so on a large tenant each search reads every one of that tenant's rows, once per debounced keystroke. Searching the order number scanned the table for both the count and the page; adding customer.name to the same search made it several times slower.
What to do depends on what people search for.
Search fewer columns. The order number and the customer email are usually enough. A column that only makes sense on its own can take searchable(isIndividual: true, isGlobal: false), which gives it its own search field and keeps it out of the main one.
Match identifiers by prefix. People type order numbers from the start, so a custom search query that matches the prefix can use an index on (team_id, number):
TextColumn::make('number')
->searchable(query: fn (Builder $query, string $search): Builder => $query->where('number', 'like', "{$search}%")),
MySQL uses a normal index for a prefix like. PostgreSQL does so only when the column uses the C collation or the index is built with varchar_pattern_ops (or text_pattern_ops for a text column), which needs a raw create index statement in the migration. In the test app, with the default en_US.UTF-8 collation, the plain (team_id, number) index was ignored; create index on orders (team_id, number varchar_pattern_ops) turned the prefix search into an index-only scan. PostgreSQL's like is also case-sensitive, which is usually fine for order numbers.
Send fewer requests. Table search waits 500 ms after the last keystroke by default; typing a ten-character order number in the browser sent one request. searchDebounce('750ms') stretches the wait, and searchOnBlur() searches only when the field loses focus.
Use a full-text index for free text like notes or addresses. Laravel's $table->fullText('notes') and whereFullText('notes', $search) work on MySQL, MariaDB and PostgreSQL, and the same query: closure can call them. On PostgreSQL, a trigram index from the pg_trgm extension can also serve like '%term%'. Build it on the same expression Filament searches, for example create index on orders using gin (lower(number::text) gin_trgm_ops), and confirm with explain() that the query picks it up. It did for Filament's default search in the test app, which dropped from a table scan to a bitmap index scan, for terms of three characters or more; shorter terms still scan.
For the largest tables, a search engine through Laravel Scout takes the work off the database. The table-level searchUsing() hands the term to Scout and turns the result into a whereKey() on the table query:
use App\Models\Order;
use Filament\Facades\Filament;
return $table
->searchable()
->searchUsing(fn (Builder $query, string $search) => $query->whereKey(
Order::search($search)->where('team_id', Filament::getTenant()->getKey())->keys(),
));
The tenant scope still applies to the final query, so a record from another team cannot appear. Filtering the Scout search by team_id as well keeps the engine's result set to rows the table can show. Most engines return a limited number of hits by default; add ->take() before ->keys() if people need to page deep into results. In the test app the closure ran twice per search request, so with a remote engine, caching the keys for a term for a few seconds saves a round trip.
Global search in a tenant panel
The search field in the panel's top bar queries every globally searchable resource in turn, up to 50 results each, as people type. Each resource runs the same like '%term%' against its title attribute, and by default splits the input into words that must all match. These settings keep it light:
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class OrderResource extends Resource
{
protected static ?string $recordTitleAttribute = 'number';
protected static bool $isGloballySearchable = true;
protected static int $globalSearchResultsLimit = 10;
protected static ?bool $shouldSplitGlobalSearchTerms = false;
public static function getGlobalSearchEloquentQuery(): Builder
{
return parent::getGlobalSearchEloquentQuery()->with('customer');
}
public static function getGlobalSearchResultDetails(Model $record): array
{
return ['Customer' => $record->customer->name];
}
}
Result details are built per result, so without the with('customer') ten results cost ten more queries (11 instead of 2 in the test app), or throw with lazy loading prevented. On the panel, ->globalSearchDebounce('750ms') raises the 500 ms default. ->globalSearchResourceOptIn() limits global search to resources that declare $isGloballySearchable on their own class, as OrderResource does above, so a large resource is not searched just because it has a title attribute.
Filters with large option lists
A SelectFilter with ->relationship('customer', 'name') and no searchable() loads every customer the relationship query returns into the dropdown when the filter form renders: all 40,000 of the large team's customers, in one query, on every render of the table. Add searchable() and leave out preload(): nothing loads until the person types, and each search returns at most 50 options (optionsLimit() changes that). With preload() on a searchable filter, Filament loads the first 50 up front.
Those options are limited to the current team only because Customer has its own resource in the panel, which gives it the tenant scope. Without one, the same filter listed the customers of every team, 42,500 in the test app. Scope the relationship query yourself in that case: ->relationship('customer', 'name', fn (Builder $query) => $query->whereBelongsTo(Filament::getTenant())).
Keep each tenant's tables small with a database per tenant
All of the above works in a single shared database, and for many SaaS apps that is the right place to stay. Filament's built-in tenancy with a team_id column is simple to run, and good indexes carry it a long way.
Some costs keep growing in a shared database, though. Every tenant's orders sit in one table with one set of indexes, and the largest tenants account for most of the rows. The pagination count, the table summary and every like '%term%' scan only touch one team's rows, but those rows live in indexes sized for everybody and compete for the same buffer pool. Backups, migrations that rewrite a column and maintenance jobs all run against the full table.
With one database per tenant, orders holds a single tenant's rows. There is no team_id and no scope, and the indexes shrink to (placed_at, id) and (status, placed_at, id). The team with 400,000 orders counts 400,000 rows in a table of 400,000, and a team with 2,000 orders never shares an index with anyone else's data. A tenant that outgrows its neighbours can move to its own server.
Filament Tenancy brings that model to a Filament v5 panel, built on stancl/tenancy: each workspace gets its own database, provisioned by queued jobs when the workspace is created, and each request runs on that workspace's connection. The database strategies page covers the trade-offs in both directions, including a hybrid setup where most workspaces share a database and the large ones get their own. Horizontal scaling spreads tenant databases across several servers.
A separate database does not replace the work in the earlier sections. One tenant with a million orders still needs the right indexes, eager loading and pagination mode; the difference is that its table only ever holds its own data.
Lock it in with a test
Once the table is fast, a test keeps it that way. Lazy-loading prevention turns a new N+1 into an exception, and comparing query counts at two page sizes catches any query that runs once per row:
use App\Filament\Resources\Orders\Pages\ListOrders;
use App\Models\Order;
use App\Models\Team;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
it('runs the same queries for 25 or 100 orders a page', function () {
Model::preventLazyLoading();
$team = Team::factory()->create();
Order::factory()->for($team)->hasItems(3)->count(150)->create();
$this->actingAs(User::factory()->hasAttached($team)->create());
Filament::setCurrentPanel('app');
Filament::setTenant($team);
Filament::bootCurrentPanel();
DB::enableQueryLog();
$queriesFor = function (int $perPage): int {
DB::flushQueryLog();
Livewire::test(ListOrders::class)
->set('tableRecordsPerPage', $perPage)
->loadTable();
return count(DB::getQueryLog());
};
$small = $queriesFor(25);
$large = $queriesFor(100);
expect($large)->toBeLessThanOrEqual($small);
});
Adjust the panel id, factories and relationship names to your app. bootCurrentPanel() registers the tenant scopes the way the panel middleware does in a real request, and loadTable() triggers the deferred load when the table uses deferLoading(). The assertion is "no more" rather than "equal" so that one-off queries in the first run don't make it flaky. For the Orders table above, both runs sent 12 queries: without deferLoading(), mounting the component, set() and loadTable() each render the table. Adding a column that runs one query per row made it 72 against 237, and the test failed. With deferLoading() only loadTable() renders, and the same column made it 29 against 104.
The numbers
The Orders list for the large team, rendered through Livewire's test helper, median of five warm runs. Query counts are exact. The machine was busy with other work, so treat the times as orders of magnitude; where two passes disagreed, both are shown.
| Scenario | Queries | Database time |
|---|---|---|
| Finished table, no indexes beyond primary keys | 4 | 3.2 s |
| Finished table, with the indexes above | 4 | 0.12–0.17 s |
Company name read in a closure, no with() |
28 | 0.3 s |
| Items and refunds as per-row closures | 54 | 0.6 s |
| Simple pagination | 3 | 35 ms |
| Cursor pagination | 3 | 40 ms |
| Sum summary, page and all records | 6 | 0.4 s |
| Sum summary, page only | 5 | 0.15 s |
| Page 1,600 by offset | 4 | 0.85–1 s |
deferLoading(), first request |
0 | – |
| Status filter "cancelled" | 4 | 30–65 ms |
| Search on the order number | 4 | 0.2–0.65 s |
| Same search with a trigram index | 4 | 0.09 s |
Search on the order number and customer.name |
4 | 1–4.5 s |
Global search, with / without with('customer') |
2 / 11 | 0.1–0.4 s |
Setup: PostgreSQL 18.1, Laravel 13.34, Filament 5.9, Livewire 4.4, PHP 8.5 on an 8-core Intel i9-9880H laptop with 16 GB of RAM. One team with 400,000 orders, 1.2 million order items and 40,000 customers; five teams with 5,000 orders each.
Where to start
When a large tenant reports a slow table, reproduce it with that tenant's data volume and read the queries of a single Livewire update. In most cases the culprit is a page query or count without a team_id-first index, a related-row aggregate without an index on the foreign key, a column that queries once per row, or a like '%term%' search over a column that does not need it, and the sections above cover each. Simple or cursor pagination and dropping the all-records summary come next, and a database per tenant is the step for when the biggest tenants keep outgrowing a shared table.