Avoiding the N+1 Trap in FilamentPHP Tables: Eager‑Loading, Relation Columns, Filters & Actions
Learn how to eliminate the N+1 query problem in FilamentPHP v3 tables by using eager‑loading, custom query modifiers for relationship columns, dot‑notation filters, and guarded actions. Follow a practical example with a Post resource to keep your admin panel fast and efficient.
05 Aug 2026, 09:40 UTC

Why the N+1 Problem Hits Filament Tables Hard
When you build an admin panel with FilamentPHP v3, the Table Builder is the go‑to tool for listing Eloquent models. A common pitfall is the N+1 query problem: the table renders one query for the base records and then a separate query for each related record accessed in a column, filter, or action. On a page with 50 rows, you end up with 51 queries, which can choke performance and inflate memory usage.
The root cause is that Filament resolves relationship columns by calling getAttribute() on each model. If the relation isn’t eager‑loaded, Eloquent will lazy‑load it on demand.
Centralizing Eager‑Loading with getTableQuery()
Instead of sprinkling with() calls in every column or filter, define a protected getTableQuery() method in the resource. This method returns the base query with all the relations you’ll need for the current table view.
protected function getTableQuery(): Builder
{
return static::getEloquentQuery()
->with(['author', 'category', 'tags']);
}
Filament automatically uses this query in the table, so every row has the author, category, and tags relations loaded in a single request. Verify your implementation by enabling Laravel Debugbar and confirming only two queries fire: one for posts and one for the eager‑loaded relations.
Relationship Columns: Sorting & Searching Without Extra Queries
Simply declaring TextColumn::make('author.name') will display the author’s name, but sorting or searching will again trigger lazy loading. To keep the table efficient, supply a custom query modifier that joins the related table.
TextColumn::make('author.name')
->searchable()
->sortable()
->getStateUsing(fn (Post $record) => $record->author?->name)
->query(fn (Builder $query, string $direction) =>
$query->join('authors', 'posts.author_id', '=', 'authors.id')
->orderBy('authors.name', $direction)
);
Key points:
- getStateUsing retrieves the value from the already‑loaded relation, avoiding an extra query.
- The query closure adds a
JOIN, enabling native SQL sorting. - Remember to
select('posts.*')before the join if you’re also usingdistinct()to avoid duplicate rows.
Filters on Relationships: Dot‑Notation and Scoping
Filament’s SelectFilter can filter by a related model’s attribute using dot notation. The filter internally adds a whereHas clause, so the query stays efficient.
SelectFilter::make('tags')
->relationship('tags', 'name');
For nested relations (e.g., author.country), use:
SelectFilter::make('authorCountry')
->relationship('author.country', 'name');
⚠️ Note: Dot notation works only with belongsTo or hasOne. Using it on hasMany will throw an exception. Verify your Filament version is ≥ v3.2, where the bug that ignored searchable() on nested relationships was fixed.
Actions on Related Records
When an action needs to operate on a related model, construct the URL with the related record and guard against missing relations.
Action::make('viewAuthor')
->url(fn (Post $record) => AuthorResource::getUrl('view', ['record' => $record->author]))
->visible(fn (Post $record) => $record->author !== null);
Because the author is eager‑loaded, the action URL resolves instantly without an additional query. If you omit the visible guard, a null relation will cause an exception when generating the URL.
Trade‑offs & Limitations
- Memory Footprint: Eager‑loading many relations can bloat memory, especially with large datasets. Profile with
DB::enableQueryLog()and limitwith()to the relations actually displayed. - Duplicate Rows: JOIN‑based sorting can duplicate rows if the joined table has a one‑to‑many relationship. Use
distinct()or re‑select the base table columns. - Complex Filters: For filters that need to search across multiple related tables, you may need to write a custom
queryclosure that adds multiplewhereHasclauses. - Version Compatibility: Features like dot‑notation filters and fixed searchable relationship bugs are only available from Filament v3.2 onward. Always run
php artisan filament:upgradeand check the changelog before relying on them.
Actionable Checklist
- Define
getTableQuery()withwith()for all relation columns, filters, and actions you plan to use. - For sortable/searchable relationship columns, add
getStateUsingand aqueryclosure that joins the related table. - Use
SelectFilter::make(...)->relationship(...)for filters; test dot‑notation only onbelongsToorhasOne. - Guard actions that depend on relations with
visible(fn (Model $record) => $record->relation !== null). - Run the table in a test environment, enable Debugbar, and confirm the number of queries matches the expected eager‑loaded count.
By centralizing eager‑loading and explicitly defining query modifiers for relationship columns, you eliminate the N+1 problem and maintain fast, scalable admin tables in FilamentPHP.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.