Why Multi-Panel Filament Gets Painful at Scale
Filament's panel system is elegant for a single admin area. The moment you add a customer portal, a vendor dashboard, and an internal ops panel — each with its own auth guard, middleware, and data scope — the default conventions start to fight you. Add tables with 500k+ rows and you have a performance problem on top of an architecture problem.
This article addresses both.
Registering Multiple Panels Without Coupling Them
Each panel lives in its own PanelProvider. Keep them in app/Panels/ and register them individually in bootstrap/providers.php.
// app/Panels/VendorPanelProvider.php
use Filament\Panel;
use Filament\PanelProvider;
class VendorPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('vendor')
->path('vendor')
->authGuard('vendor')
->login()
->middleware([
EncryptCookies::class,
StartSession::class,
VendorTenantMiddleware::class, // your custom scope
])
->authMiddleware([Authenticate::class])
->discoverResources(
in: app_path('Filament/Vendor/Resources'),
for: 'App\\Filament\\Vendor\\Resources'
);
}
}
The authGuard('vendor') call wires Filament's internal auth checks to your custom guard defined in config/auth.php. No shared session bleed between panels.
Per-Panel Middleware for Tenant Scoping
Don't rely on global middleware for tenant resolution when panels have different scoping rules. A dedicated middleware per panel keeps the logic explicit:
class VendorTenantMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$vendor = auth('vendor')->user()?->vendor;
if (! $vendor) {
abort(403);
}
app()->instance(CurrentVendor::class, $vendor);
return $next($request);
}
}
Bind CurrentVendor as a scoped singleton so every Eloquent query in that request can resolve it without a second DB hit.
Table Query Tuning: Where Filament Tables Actually Slow Down
Filament's Table component calls ->paginate() on your base query. The base query is built in getTableQuery() on the resource. This is where most N+1 and missing-index problems live.
Always Eager-Load in getTableQuery
public static function getTableQuery(): Builder
{
return parent::getTableQuery()
->with(['vendor', 'status', 'latestShipment'])
->withCount('lineItems');
}
Filament will not automatically eager-load relationships referenced in columns. If your TextColumn calls ->relationship('vendor', 'name'), Filament does add a join internally — but for anything more complex, explicit with() is safer and more predictable.
Deferring Expensive Counts
Avoid ->withCount() on large tables when the count isn't displayed by default. Use a TextColumn with ->default('-') and ->toggleable(isToggledHiddenByDefault: true) so the count only loads when the user opts in:
TextColumn::make('line_items_count')
->counts('lineItems')
->label('Lines')
->toggleable(isToggledHiddenByDefault: true),
Scoping the Base Query for Authorization
For row-level security, apply the scope directly in getTableQuery() rather than a global scope. Global scopes affect every query in the process — dangerous under Octane.
public static function getTableQuery(): Builder
{
$vendor = app(CurrentVendor::class);
return parent::getTableQuery()
->whereBelongsTo($vendor)
->with(['status']);
}
Index Your Filter Columns
Filament table filters translate to WHERE clauses. If your SelectFilter targets status or created_at, those columns need indexes. Use EXPLAIN ANALYZE in PostgreSQL or EXPLAIN in MySQL to confirm the planner is using them.
CREATE INDEX idx_orders_vendor_status
ON orders (vendor_id, status);
A composite index on (vendor_id, status) covers the common case of scoped + filtered queries in one scan.
Takeaways
- Register each panel in its own
PanelProviderwith a dedicatedauthGuardand middleware stack — never share session state across panels. - Inject tenant context via a scoped singleton bound in per-panel middleware, not a global scope.
- Always call
->with()explicitly ingetTableQuery(); don't rely on Filament's relationship column resolution for complex graphs. - Hide expensive aggregate columns behind
->toggleable(isToggledHiddenByDefault: true)to avoid counting millions of rows on every page load. - Add composite indexes that match your most common
(tenant_id, filter_column)query patterns.