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

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

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 PanelProvider with a dedicated authGuard.
  • Scope table queries in getEloquentQuery(), not via global Eloquent scopes.
  • Disable COUNT(*) pagination on very large tables using simplePaginate.
  • Declare eager loads explicitly — Filament will not infer them from column definitions.
  • Add composite indexes that match your filter + sort column order.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can two Filament panels share the same Eloquent models but use different guards?
Yes. Guards control authentication, not model access. Both panels can query the same models; the difference is which user table and token driver authenticates the request. Scope queries per-panel inside `getEloquentQuery()` rather than relying on the guard to restrict data.
Q02 How do I prevent a Filament resource registered in one panel from appearing in another?
Use `->discoverResources(in: ..., for: ...)` with panel-specific namespaces, or register resources explicitly with `->resources([...])`. Resources are scoped to the panel they are registered in and will not bleed across panels.
Q03 Does switching to simplePaginate break Filament's built-in pagination UI?
Simple pagination removes the total-count-based page links, so users see only previous/next controls. Filament renders this correctly when you return a `Paginator` instance from `paginateUsing`. It is a deliberate UX trade-off worth making on very large datasets.

Continue reading

More Articles

View all