Why Multi-Panel Filament Gets Messy Fast
Filament v3 and v4 make it trivial to spin up a second panel — php artisan make:filament-panel. The trouble starts when that second panel needs its own auth guard, its own user model, and table queries that do not bleed across tenant or role boundaries. Most tutorials stop at "add a panel, ship it." Production does not.
Registering Separate Auth Guards Per Panel
Each PanelProvider exposes an authGuard() method. Pair it with a dedicated guard in config/auth.php:
// config/auth.php
'guards' => [
'admin' => [
'driver' => 'session',
'provider' => 'admins',
],
'partner' => [
'driver' => 'session',
'provider' => 'partners',
],
],
'providers' => [
'admins' => ['driver' => 'eloquent', 'model' => App\Models\Admin::class],
'partners' => ['driver' => 'eloquent', 'model' => App\Models\Partner::class],
],
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->path('admin')
->authGuard('admin')
->login()
->colors(['primary' => Color::Slate]);
}
// app/Providers/Filament/PartnerPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->id('partner')
->path('partner')
->authGuard('partner')
->login()
->colors(['primary' => Color::Teal]);
}
Filament stores the authenticated user in the guard you specify. auth('partner')->user() inside a Partner resource will never return an Admin — as long as you never fall back to the default guard.
Scoping Resources to the Authenticated Panel User
The most common production bug: a resource's getEloquentQuery() is not scoped, so one panel's users can read another's data.
// app/Filament/Partner/Resources/OrderResource.php
public static function getEloquentQuery(): Builder
{
$partner = auth('partner')->user();
return parent::getEloquentQuery()
->whereBelongsTo($partner)
->withoutGlobalScope(SoftDeletingScope::class);
}
Always override getEloquentQuery() in every resource that lives inside a scoped panel. Do not rely on global Eloquent scopes alone — they are easy to accidentally remove in tests or eager loads.
Table Query Tuning at Scale
Filament tables issue a COUNT(*) for pagination and a SELECT for the visible page. On a table with 500 k rows and several ->searchable() columns, that count query alone can take seconds.
Disable Exact Pagination Counts
->paginated([10, 25, 50])
->paginationPageOptions([10, 25, 50])
->defaultPaginationPageOption(25)
->countQuery(fn (Builder $query) => $query->toBase()) // skip count
Filament v4 exposes simplePaginate() via ->simplePagination() on the table. Use it when you do not need "page X of Y":
Table::make()
->simplePagination()
Eager-Load Strategically
Filament will not automatically eager-load relationships referenced in columns. Declare them explicitly:
Table::make()
->query(
Order::query()
->with(['partner:id,name', 'items:id,order_id,sku'])
)
Avoid ->with('*') — it defeats the purpose of column-level selection.
Index the Columns You Search and Sort
Every ->searchable() column that hits the database needs an index. For multi-column search Filament generates an OR LIKE clause:
WHERE (orders.reference LIKE ? OR partners.name LIKE ?)
A composite index will not help here. Use full-text indexes (MySQL FULLTEXT, PostgreSQL GIN with pg_trgm) or push search to a dedicated search engine for large datasets.
-- PostgreSQL
CREATE INDEX orders_reference_trgm_idx
ON orders USING GIN (reference gin_trgm_ops);
Defer Expensive Aggregates
If a column shows a sum or count, do not compute it in the main query. Use a sub-select or a pre-computed column:
TextColumn::make('items_total')
->label('Total Items')
->getStateUsing(fn (Order $record) => $record->items_count)
->sortable(query: fn (Builder $q, string $dir) =>
$q->orderBy('items_count', $dir)
),
Load items_count via ->withCount('items') in getEloquentQuery() once, rather than triggering a sub-query per row.
Key Takeaways
- Register a dedicated
authGuardin everyPanelProvider; never share guards between panels. - Always override
getEloquentQuery()in scoped resources — do not trust global scopes alone. - Switch to
->simplePagination()on large datasets to eliminate theCOUNT(*)round-trip. - Declare eager loads in the table query, not in column definitions.
- Add
pg_trgmorFULLTEXTindexes on searchable columns before traffic hits them.