Why Multi-Panel Filament Gets Messy Fast
Filament's panel system is powerful, but teams often bolt on a second panel without thinking through auth isolation, query scope, or table performance. The result is shared session state, leaking queries, and N+1 problems hiding behind pretty UI. This article covers the three areas that matter most at scale.
1. Separate Auth Guards Per Panel
Each panel should own its guard. Define them in config/auth.php:
'guards' => [
'admin' => [
'driver' => 'session',
'provider' => 'admins',
],
'tenant' => [
'driver' => 'session',
'provider' => 'users',
],
],
'providers' => [
'admins' => ['driver' => 'eloquent', 'model' => App\Models\Admin::class],
'users' => ['driver' => 'eloquent', 'model' => App\Models\User::class],
],
Then wire each panel to its guard inside the PanelProvider:
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->path('admin')
->authGuard('admin')
->login()
->middleware([
EncryptCookies::class,
StartSession::class,
AuthenticateSession::class,
])
->authMiddleware([Authenticate::class]);
}
Critical: do not share the default web middleware group between panels unless you want session collisions. Each panel should declare its own middleware stack explicitly.
2. Scoping Resources to the Authenticated Tenant
Global scopes are the cleanest way to ensure every Eloquent query inside a panel is automatically filtered. Register a scope in the panel's boot phase:
// Inside TenantPanelProvider::panel()
->tenant(Team::class, ownershipRelationship: 'team')
For custom scoping beyond Filament's built-in tenancy, override getEloquentQuery() on the resource:
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereBelongsTo(filament()->getTenant());
}
Avoid putting this logic in a global Eloquent scope unless you also need it outside Filament — mixing panel concerns into your domain models is a maintenance trap.
3. Table Query Tuning: The Three Common Killers
3a. Eager-load relationships used in columns
public static function table(Table $table): Table
{
return $table
->query(
Order::query()->with(['customer', 'items.product'])
)
->columns([
TextColumn::make('customer.name'),
TextColumn::make('items_count')
->counts('items'),
]);
}
Filament's ->counts() and ->exists() column modifiers push aggregates into the base query rather than triggering per-row subqueries — use them instead of accessor methods.
3b. Defer expensive columns
For columns that require heavy joins or subqueries, mark them as toggleable and hidden by default:
TextColumn::make('lifetime_value')
->toggleable(isToggledHiddenByDefault: true)
->getStateUsing(fn (Customer $r) => $r->orders()->sum('total')),
This keeps the default page load fast; power users can opt in.
3c. Paginate aggressively and add database indexes
Filament's default page size is 10, but teams often bump it to 50 or 100 without adding indexes on the sort column. Every TextColumn::make('created_at')->sortable() call becomes an ORDER BY created_at — ensure that column is indexed:
$table->index(['team_id', 'created_at']);
A composite index on (team_id, created_at) satisfies both the tenant scope WHERE and the ORDER BY in a single index scan.
Takeaways
- Assign a dedicated auth guard to every panel and declare middleware stacks explicitly to prevent session bleed.
- Scope resource queries at the resource level, not in global Eloquent scopes, to keep domain models clean.
- Use Filament's built-in
->counts()and->exists()modifiers to push aggregates into SQL rather than PHP. - Hide expensive columns behind
toggleable(isToggledHiddenByDefault: true)to protect default page load times. - Add composite indexes that match your tenant scope column plus the default sort column.