Filament at Scale: Multi-Panel Auth, Custom Panels, and Table Query Tuning
#filament #laravel #performance #multi-tenant

Filament at Scale: Multi-Panel Auth, Custom Panels, and Table Query Tuning

3 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can two Filament panels share the same Eloquent model but use different guards?
Yes. The guard controls which session store and provider are used for authentication, not which model is queried. You can point both guards at the same `User` model but use different providers or add role checks in panel middleware.
Q02 Does cursor pagination work with Filament's search and filter features?
Cursor pagination works with filters that do not change the sort order. Full-text search that re-orders results by relevance is incompatible with keyset cursors; fall back to offset pagination for those cases.
Q03 How do I prevent a logged-in admin from accessing the tenant panel URL directly?
Add a custom middleware to the tenant panel's `->middleware()` stack that checks `auth()->guard('web')->check()` and redirects admins away. Because each panel uses a separate guard, the admin session is invisible to the tenant panel by default, but an admin who also has a web session could still access it without the extra check.

Continue reading

More Articles

View all