Filament at Scale: Multi-Panel Auth, Custom Panels, and Table Query Tuning
Most Filament tutorials assume a single admin panel with a single users table. Production SaaS apps rarely look like that. You might have a customer-facing portal, an internal ops panel, and a super-admin panel — each with its own guard, middleware, and data scope. Getting that wrong means auth bleed, slow tables, and unmaintainable panel code.
Registering Multiple Panels
Each panel lives in its own PanelProvider. Register them all in bootstrap/providers.php:
// app/Providers/Filament/CustomerPanelProvider.php
class CustomerPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('customer')
->path('portal')
->authGuard('customer')
->login(CustomerLogin::class)
->middleware([
EncryptCookies::class,
StartSession::class,
EnsureEmailIsVerified::class,
])
->authMiddleware([Authenticate::class])
->discoverResources(
in: app_path('Filament/Customer/Resources'),
for: 'App\\Filament\\Customer\\Resources'
);
}
}
The ->authGuard('customer') call is the critical line. Filament will use that guard for all authentication checks within this panel, completely isolated from your admin guard.
Per-Panel Middleware Stacks
Don't share middleware stacks between panels unless you have a deliberate reason. The ->middleware() call sets the web-layer stack; ->authMiddleware() runs after authentication resolves. A common pattern is adding a tenant-scoping middleware only to the customer panel:
->authMiddleware([
Authenticate::class,
ApplyTenantScope::class, // sets app()->instance('current_tenant', ...)
])
This keeps the ops panel free of tenant logic entirely.
Scoping Table Queries Without Global Scopes
Global Eloquent scopes are tempting but dangerous across panels — they apply everywhere, including jobs and CLI commands. Instead, scope at the resource level:
// app/Filament/Customer/Resources/OrderResource.php
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereBelongsTo(app('current_tenant'), 'tenant')
->with(['items', 'status']);
}
This query runs only when the Filament table renders. No leakage.
Table Query Tuning: The Real Bottlenecks
Filament tables generate a COUNT(*) query for pagination alongside the data query. On large tables this is expensive. Use ->paginationPageOptions([25, 50]) and consider disabling the count entirely for very large datasets:
public static function table(Table $table): Table
{
return $table
->query(fn () => static::getEloquentQuery())
->paginationPageOptions([25, 50])
->defaultPaginationPageOption(25)
->extremePaginationLinks(false)
->columns([...]);
}
For tables exceeding 500k rows, switch to simple pagination to drop the COUNT:
->paginated([25, 50])
->paginateUsing(fn (Builder $query, int $page, int $perPage) =>
$query->simplePaginate($perPage, page: $page)
)
Eager Loading in Table Columns
Filament does not automatically eager-load relationships referenced in columns. Declare them explicitly:
TextColumn::make('customer.name')
->label('Customer')
->searchable(query: fn (Builder $q, string $s) =>
$q->whereHas('customer', fn ($q) => $q->where('name', 'like', "%{$s}%"))
),
And in getEloquentQuery():
return parent::getEloquentQuery()->with(['customer', 'items.product']);
Missing this on a 10k-row table produces thousands of queries per page load.
Covering Indexes for Filter Columns
Filament filter dropdowns translate to WHERE clauses. If your orders table is filtered by status and sorted by created_at, add a composite index:
$table->index(['tenant_id', 'status', 'created_at']);
The column order matters: equality columns first, range/sort columns last.
Key Takeaways
- Register each panel in its own
PanelProviderwith a dedicatedauthGuard. - Scope table queries in
getEloquentQuery(), not via global Eloquent scopes. - Disable
COUNT(*)pagination on very large tables usingsimplePaginate. - Declare eager loads explicitly — Filament will not infer them from column definitions.
- Add composite indexes that match your filter + sort column order.