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

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

3 min read Mohamed Said Mohamed Said

The Problem With One Panel for Everything

Most Filament tutorials show a single AdminPanelProvider. That works until you need a customer-facing portal sitting beside your internal admin, each with its own user model, guard, and middleware stack. Bolting both concerns onto one panel produces a tangled mess of policy checks and route conflicts.

The cleaner path: register two discrete panels, each owning its auth contract.


Registering a Second Panel

Filament resolves panels through service providers. Create a dedicated provider for each panel.

php artisan make:filament-panel customer

This scaffolds app/Providers/Filament/CustomerPanelProvider.php. Configure it independently:

public function panel(Panel $panel): Panel
{
    return $panel
        ->id('customer')
        ->path('portal')
        ->authGuard('customer')          // dedicated guard
        ->login(CustomerLogin::class)    // custom login page
        ->colors(['primary' => Color::Teal])
        ->discoverResources(
            in: app_path('Filament/Customer/Resources'),
            for: 'App\\Filament\\Customer\\Resources'
        )
        ->middleware([
            EncryptCookies::class,
            VerifyCsrfToken::class,
            SubstituteBindings::class,
        ])
        ->authMiddleware([Authenticate::class]);
}

Register both providers in bootstrap/providers.php (Laravel 11+) or config/app.php.

Separate Guards and User Models

// config/auth.php
'guards' => [
    'web'      => ['driver' => 'session', 'provider' => 'users'],
    'customer' => ['driver' => 'session', 'provider' => 'customers'],
],
'providers' => [
    'users'     => ['driver' => 'eloquent', 'model' => App\Models\User::class],
    'customers' => ['driver' => 'eloquent', 'model' => App\Models\Customer::class],
],

Filament calls auth()->guard($panel->getAuthGuard()) internally, so the panel's guard name is the only coupling point.


Sharing Resources Across Panels

Occasionally an OrderResource belongs in both panels but with different column sets. Rather than duplicating the class, use a base resource and extend it:

// App\Filament\Base\BaseOrderResource.php
abstract class BaseOrderResource extends Resource
{
    protected static string $model = Order::class;

    public static function baseColumns(): array
    {
        return [
            TextColumn::make('id')->sortable(),
            TextColumn::make('total')->money('usd'),
        ];
    }
}

// App\Filament\Admin\Resources\OrderResource.php
class OrderResource extends BaseOrderResource
{
    public static function table(Table $table): Table
    {
        return $table->columns([
            ...static::baseColumns(),
            TextColumn::make('customer.email'),
        ]);
    }
}

Table Query Tuning at Scale

Filament tables call paginate() on the Eloquent builder. On a table with 500k rows, the default COUNT(*) for pagination becomes expensive fast.

Override the Table Query

Scope the query at the resource level to avoid full-table scans:

public static function getEloquentQuery(): Builder
{
    return parent::getEloquentQuery()
        ->select(['id', 'status', 'total', 'created_at', 'customer_id'])
        ->with('customer:id,email')   // eager-load only needed columns
        ->where('created_at', '>=', now()->subYear());
}

Disable Count-Based Pagination

Filament v3 supports ->paginationPageOptions([25, 50]) but still fires a count query. For very large tables, switch to simple pagination:

public static function table(Table $table): Table
{
    return $table
        ->paginated([25, 50])
        ->defaultPaginationPageOption(25)
        ->query(fn () => static::getEloquentQuery())
        // Filament respects simplePaginate when you override the paginator:
        ->paginateUsing(fn (Builder $query, int $page, int $perPage) =>
            $query->simplePaginate($perPage, ['*'], 'page', $page)
        );
}

Index Your Sort Columns

Every sortable column fires an ORDER BY. Ensure composite indexes cover the sort + filter combination:

CREATE INDEX orders_status_created_at_idx ON orders (status, created_at DESC);

Run EXPLAIN ANALYZE in PostgreSQL or EXPLAIN FORMAT=JSON in MySQL to confirm the index is used.


Takeaways

  • Register each panel in its own provider with a dedicated auth guard and user model — never share guards between panels.
  • Use abstract base resources to share schema logic without duplicating Eloquent models.
  • Override getEloquentQuery() to select only required columns and constrain result sets before Filament paginates.
  • Replace paginate() with simplePaginate() via paginateUsing() on high-volume tables to eliminate the expensive COUNT(*) query.
  • Add composite indexes on every column combination used for filtering and sorting in your tables.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can two Filament panels share the same Eloquent model with different guards?
Yes. The guard is configured on the panel, not the model. You can point both panels at the same User model but use different guards — though separate models per panel is cleaner when the authentication contracts differ.
Q02 Does overriding paginateUsing break Filament's built-in filter and sort state?
No. Filament applies filters and sorts to the Eloquent builder before the paginator runs. Swapping paginate() for simplePaginate() inside paginateUsing() only changes how the result set is sliced, not how the query is built.
Q03 How do I prevent a resource registered in one panel from appearing in another?
Use discoverResources() with panel-specific namespaces and directories. Resources discovered under App\Filament\Admin\Resources are invisible to the customer panel, which discovers from App\Filament\Customer\Resources.

Continue reading

More Articles

View all