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

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

4 min read Mohamed Said Mohamed Said

Why Multi-Panel Filament Gets Messy Fast

Filament v3 and v4 make it trivial to spin up a second panel — php artisan make:filament-panel. The trouble starts when that second panel needs its own auth guard, its own user model, and table queries that do not bleed across tenant or role boundaries. Most tutorials stop at "add a panel, ship it." Production does not.


Registering Separate Auth Guards Per Panel

Each PanelProvider exposes an authGuard() method. Pair it with a dedicated guard in config/auth.php:

// config/auth.php
'guards' => [
    'admin' => [
        'driver'   => 'session',
        'provider' => 'admins',
    ],
    'partner' => [
        'driver'   => 'session',
        'provider' => 'partners',
    ],
],
'providers' => [
    'admins'   => ['driver' => 'eloquent', 'model' => App\Models\Admin::class],
    'partners' => ['driver' => 'eloquent', 'model' => App\Models\Partner::class],
],
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
    return $panel
        ->id('admin')
        ->path('admin')
        ->authGuard('admin')
        ->login()
        ->colors(['primary' => Color::Slate]);
}
// app/Providers/Filament/PartnerPanelProvider.php
public function panel(Panel $panel): Panel
{
    return $panel
        ->id('partner')
        ->path('partner')
        ->authGuard('partner')
        ->login()
        ->colors(['primary' => Color::Teal]);
}

Filament stores the authenticated user in the guard you specify. auth('partner')->user() inside a Partner resource will never return an Admin — as long as you never fall back to the default guard.


Scoping Resources to the Authenticated Panel User

The most common production bug: a resource's getEloquentQuery() is not scoped, so one panel's users can read another's data.

// app/Filament/Partner/Resources/OrderResource.php
public static function getEloquentQuery(): Builder
{
    $partner = auth('partner')->user();

    return parent::getEloquentQuery()
        ->whereBelongsTo($partner)
        ->withoutGlobalScope(SoftDeletingScope::class);
}

Always override getEloquentQuery() in every resource that lives inside a scoped panel. Do not rely on global Eloquent scopes alone — they are easy to accidentally remove in tests or eager loads.


Table Query Tuning at Scale

Filament tables issue a COUNT(*) for pagination and a SELECT for the visible page. On a table with 500 k rows and several ->searchable() columns, that count query alone can take seconds.

Disable Exact Pagination Counts

->paginated([10, 25, 50])
->paginationPageOptions([10, 25, 50])
->defaultPaginationPageOption(25)
->countQuery(fn (Builder $query) => $query->toBase()) // skip count

Filament v4 exposes simplePaginate() via ->simplePagination() on the table. Use it when you do not need "page X of Y":

Table::make()
    ->simplePagination()

Eager-Load Strategically

Filament will not automatically eager-load relationships referenced in columns. Declare them explicitly:

Table::make()
    ->query(
        Order::query()
            ->with(['partner:id,name', 'items:id,order_id,sku'])
    )

Avoid ->with('*') — it defeats the purpose of column-level selection.

Index the Columns You Search and Sort

Every ->searchable() column that hits the database needs an index. For multi-column search Filament generates an OR LIKE clause:

WHERE (orders.reference LIKE ? OR partners.name LIKE ?)

A composite index will not help here. Use full-text indexes (MySQL FULLTEXT, PostgreSQL GIN with pg_trgm) or push search to a dedicated search engine for large datasets.

-- PostgreSQL
CREATE INDEX orders_reference_trgm_idx
    ON orders USING GIN (reference gin_trgm_ops);

Defer Expensive Aggregates

If a column shows a sum or count, do not compute it in the main query. Use a sub-select or a pre-computed column:

TextColumn::make('items_total')
    ->label('Total Items')
    ->getStateUsing(fn (Order $record) => $record->items_count)
    ->sortable(query: fn (Builder $q, string $dir) =>
        $q->orderBy('items_count', $dir)
    ),

Load items_count via ->withCount('items') in getEloquentQuery() once, rather than triggering a sub-query per row.


Key Takeaways

  • Register a dedicated authGuard in every PanelProvider; never share guards between panels.
  • Always override getEloquentQuery() in scoped resources — do not trust global scopes alone.
  • Switch to ->simplePagination() on large datasets to eliminate the COUNT(*) round-trip.
  • Declare eager loads in the table query, not in column definitions.
  • Add pg_trgm or FULLTEXT indexes on searchable columns before traffic hits them.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can two Filament panels share the same User model but use different guards?
Yes. Point both guards at the same Eloquent provider but use different session keys by giving each guard a unique name. Filament isolates sessions per guard, so a user logged into one panel will not be automatically authenticated in the other.
Q02 Does simplePagination break Filament's built-in page-size selector?
The page-size dropdown still renders, but navigation is limited to previous/next links because the total row count is not fetched. For most admin tables with large datasets this trade-off is worth the query savings.
Q03 How do I prevent a resource from appearing in the wrong panel?
Register resources explicitly in each PanelProvider via ->resources([...]) rather than relying on auto-discovery. Auto-discovery scans all registered resource classes regardless of panel, which can expose resources unintentionally.

Continue reading

More Articles

View all