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

4 min read Mohamed Said Mohamed Said

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 PanelProvider with a dedicated authGuard and 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 in getTableQuery(); 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can two Filament panels share the same auth guard?
Technically yes, but it creates session and authorization ambiguity. Each panel should use a distinct guard so Filament's internal auth checks, redirects, and middleware operate independently without interfering with each other.
Q02 Does calling ->with() in getTableQuery() conflict with Filament's built-in relationship column loading?
No. Filament's relationship columns add their own eager loads on top of your base query. Explicit ->with() calls in getTableQuery() are additive and give you full control over which relationships are loaded and how.
Q03 How do I prevent a global Eloquent scope from leaking across panels under Octane?
Avoid global scopes for per-request tenant filtering entirely. Instead, apply the scope directly in getTableQuery() using the scoped singleton bound in your panel's middleware. This keeps the scope request-local and safe under Octane's persistent worker model.

Continue reading

More Articles

View all