Filament at Scale: Multi-Panel Auth, Custom Panels, and Table Query Tuning
Filament v3/v4 ships with a clean panel-per-provider model, but most tutorials stop at a single AdminPanelProvider. Production SaaS apps routinely need an admin panel, a tenant panel, and a public-facing portal — each with its own auth guard, middleware stack, and URL prefix. Add a table that pages through 500 k rows and you have a real engineering problem.
This article covers both concerns with concrete, copy-paste-ready patterns.
Registering Multiple Panels
Each panel lives in its own service provider. Register them all in bootstrap/providers.php.
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->path('admin')
->authGuard('admin') // dedicated guard
->login(AdminLogin::class)
->middleware([
EncryptCookies::class,
VerifyCsrfToken::class,
RequireAdminRole::class, // custom middleware
])
->resources([
UserResource::class,
TenantResource::class,
]);
}
// app/Providers/Filament/TenantPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->id('tenant')
->path('app')
->authGuard('web') // standard guard, tenant resolved by middleware
->tenant(Team::class, slugAttribute: 'slug')
->tenantMiddleware([EnsureTeamIsActive::class], isPersistent: true)
->resources([DashboardResource::class]);
}
Isolating Auth Guards
Define a dedicated admin guard in config/auth.php:
'guards' => [
'admin' => [
'driver' => 'session',
'provider' => 'admins',
],
],
'providers' => [
'admins' => [
'driver' => 'eloquent',
'model' => App\Models\Admin::class,
],
],
This means an authenticated User session cannot bleed into the admin panel — a common security gap when teams share a single guard.
Custom Panel Middleware for Tenant Resolution
When the tenant panel resolves a team from the URL slug, you want that team bound into the container early so every resource query can scope itself automatically.
class ResolveCurrentTeam
{
public function handle(Request $request, Closure $next): mixed
{
$team = Team::where('slug', $request->route('tenant'))->firstOrFail();
app()->instance(CurrentTeam::class, $team);
app()->instance('current_team_id', $team->id);
return $next($request);
}
}
Resources then inject CurrentTeam via the container rather than re-querying:
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where('team_id', app('current_team_id'));
}
Table Query Tuning at Scale
Filament tables issue a COUNT(*) for pagination and a SELECT for the page. On large tables both queries hit the same indexes — or miss them.
Disable the Count Query When Unnecessary
public function table(Table $table): Table
{
return $table
->paginated([25, 50, 100])
->paginationPageOptions([25, 50])
->extremePaginationLinks(false)
->query(
Invoice::query()
->select(['id','number','status','total','created_at'])
->with('customer:id,name') // avoid N+1
);
}
For very large tables, replace the default paginator with a cursor paginator — Filament supports it natively:
->paginationMode(PaginationMode::Cursor)
Cursor pagination skips COUNT(*) entirely and uses a keyset on an indexed column.
Eager-Load Relationship Columns
Every TextColumn that calls ->relationship() under the hood issues a separate query per row unless you declare the relationship in $with or override getEloquentQuery().
TextColumn::make('customer.name')
->searchable(query: function (Builder $query, string $search): Builder {
return $query->whereHas('customer', fn ($q) =>
$q->where('name', 'like', "%{$search}%")
);
}),
Always profile with \DB::enableQueryLog() or Telescope before deploying a new resource to production.
Key Takeaways
- Register each panel in its own provider; use
->authGuard()to isolate session state per audience. - Bind resolved tenant models into the container in persistent middleware so resources never re-query.
- Use
->select()on the base query to avoid fetching unused columns across wide tables. - Switch to cursor pagination for append-only or time-series tables to eliminate expensive
COUNT(*)calls. - Always declare eager-loads explicitly; Filament's relationship columns will not batch-load automatically unless you tell them to.