The Problem With One Panel Fits All
Most Filament tutorials assume a single /admin panel. Real SaaS products need at least two: one for customers, one for internal staff — each with its own auth guard, middleware stack, and resource set. Filament v4's PanelProvider architecture makes this clean, but there are sharp edges around query performance once you push table row counts past a few thousand.
This article covers three concrete areas:
- Registering and isolating multiple panels
- Wiring per-panel authentication guards
- Tuning table queries so Filament doesn't become your slowest endpoint
Registering Multiple Panels
Each panel gets its own PanelProvider. Create two:
php artisan make:filament-panel customer
php artisan make:filament-panel staff
This generates app/Providers/Filament/CustomerPanelProvider.php and StaffPanelProvider.php. Register both in bootstrap/providers.php:
return [
App\Providers\Filament\CustomerPanelProvider::class,
App\Providers\Filament\StaffPanelProvider::class,
];
Each provider calls Panel::make() with a unique ID and path:
public function panel(Panel $panel): Panel
{
return $panel
->id('customer')
->path('portal')
->login()
->resources([
App\Filament\Customer\Resources\OrderResource::class,
]);
}
Per-Panel Authentication Guards
Filament v4 exposes ->authGuard() on the panel builder. Pair it with a dedicated guard in config/auth.php:
// config/auth.php
'guards' => [
'customer' => [
'driver' => 'session',
'provider' => 'customers',
],
'staff' => [
'driver' => 'session',
'provider' => 'staff',
],
],
'providers' => [
'customers' => ['driver' => 'eloquent', 'model' => App\Models\Customer::class],
'staff' => ['driver' => 'eloquent', 'model' => App\Models\StaffUser::class],
],
Then in each panel provider:
return $panel
->id('customer')
->path('portal')
->authGuard('customer')
->login(CustomerLoginPage::class);
Filament will resolve Auth::guard('customer') for all authorization checks inside that panel. Sessions are fully isolated — a customer cookie never authenticates a staff request.
Middleware Isolation
Add panel-specific middleware to avoid leaking staff-only rate limits or audit logging into the customer panel:
->middleware([
EncryptCookies::class,
StartSession::class,
App\Http\Middleware\LogCustomerActivity::class,
])
->authMiddleware([
Authenticate::class,
])
Table Query Tuning
Filament's Table component calls ->query() on every page load. The default is Model::query() — no eager loads, no scopes. At scale that's a disaster.
Scoped Default Query
Always scope to the authenticated tenant and eager-load relations the table columns touch:
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['customer', 'items.product'])
->where('tenant_id', Filament::getTenant()?->id)
->withoutGlobalScope(SoftDeletingScope::class);
}
Deferring Expensive Counts
Filament renders a row count badge by default. On large tables this fires a COUNT(*) on every render. Disable it when the table is filtered by default and the count is meaningless:
Table::make()
->paginated([25, 50, 100])
->paginationPageOptions([25, 50])
->defaultPaginationPageOption(25)
->extremePaginationLinks(false)
->queryStringIdentifier('orders')
For the count itself, override getTableRecordsPerPageSelectOptions or use a database view with a materialized count column rather than a live aggregate.
Column-Level Eager Loading
Filament v4 columns support ->relationship() declarations. Use them so the table builder can batch-load only what's rendered:
TextColumn::make('customer.name')
->label('Customer')
->searchable(query: fn (Builder $q, string $s) =>
$q->whereHas('customer', fn ($q) => $q->where('name', 'like', "%{$s}%"))
),
Avoid ->getStateUsing() callbacks that hit the database per row — they bypass Eloquent's eager loading entirely.
Takeaways
- Register each panel in its own
PanelProvider; use->authGuard()to fully isolate sessions. - Scope
getEloquentQuery()to the tenant and eager-load every relation the table touches. - Disable or defer row counts on large tables; a live
COUNT(*)on 500k rows is expensive. - Keep panel-specific middleware in the panel provider, not in global
app/Http/Kernel.php. - Use
->relationship()on columns so Filament can batch-load relations rather than resolving them per row.