Multi-Tenant SaaS with Filament: Per-Tenant Panel Themes and Dynamic Config at Runtime
#laravel #filament #multi-tenant #saas #octane

Multi-Tenant SaaS with Filament: Per-Tenant Panel Themes and Dynamic Config at Runtime

3 min read Mohamed Said Mohamed Said

The Problem With Static Panel Configuration

Most Filament multi-tenant guides stop at scoping Eloquent queries. That solves data isolation, but a real SaaS product often needs per-tenant branding: different primary colours, logos, navigation groups, and even feature flags that hide entire resources. Doing this safely — without state leaking between requests on Octane or FPM — requires a deliberate approach.

Resolving the Tenant Early

Before Filament boots its panel, you need to know which tenant owns the request. A dedicated middleware registered in bootstrap/app.php is the right place:

// app/Http/Middleware/IdentifyTenant.php
public function handle(Request $request, Closure $next): Response
{
    $subdomain = explode('.', $request->getHost())[0];

    $tenant = Tenant::where('subdomain', $subdomain)
        ->with('settings')
        ->firstOrFail();

    // Bind to the container for this request lifecycle only
    app()->instance(Tenant::class, $tenant);

    return $next($request);
}

Using instance() here is intentional. On FPM every request gets a fresh container. On Octane you must reset this binding — covered below.

Dynamically Configuring the Panel

Filament panels are configured inside a PanelProvider. The trick is to defer any tenant-specific calls until after the request is resolved, using a booted callback:

// app/Providers/Filament/AppPanelProvider.php
public function panel(Panel $panel): Panel
{
    return $panel
        ->id('app')
        ->path('app')
        ->bootUsing(function (Panel $panel) {
            $tenant = app(Tenant::class);

            $panel
                ->colors([
                    'primary' => $tenant->settings->primary_color ?? '#6366f1',
                ])
                ->brandName($tenant->name)
                ->brandLogo($tenant->settings->logo_url);

            if ($tenant->settings->feature_reports) {
                $panel->resources([
                    ...config('filament.default_resources'),
                    ReportResource::class,
                ]);
            }
        });
}

bootUsing runs once per request after the container is warm, so app(Tenant::class) resolves the bound instance correctly.

Preventing State Leakage on Octane

Octane workers are long-lived. An instance() binding from request A will still be present for request B unless you flush it. Register a terminating callback in your AppServiceProvider:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    if (app()->bound(\Laravel\Octane\Octane::class)) {
        app(\Laravel\Octane\Contracts\OperationTerminated::class, function () {
            app()->forgetInstance(Tenant::class);
        });
    }
}

Alternatively, use Octane's RequestHandled event listener to flush the binding after every response.

Caching Tenant Settings Without Cross-Tenant Pollution

Hitting the database on every request for tenant settings is wasteful. Use a tagged cache key scoped to the tenant:

$settings = Cache::remember(
    "tenant:{$tenant->id}:settings",
    now()->addMinutes(15),
    fn () => $tenant->settings
);

When a tenant updates their branding, fire a TenantSettingsUpdated event and forget the key:

Cache::forget("tenant:{$tenant->id}:settings");

This keeps the cache flat — no tags required — and avoids the tag-flush overhead on Redis Cluster.

Filament's navigationGroups and navigationItems can also be driven by tenant config:

->bootUsing(function (Panel $panel) {
    $tenant = app(Tenant::class);

    $groups = collect($tenant->settings->nav_groups ?? [])
        ->map(fn ($label) => NavigationGroup::make($label)->collapsible())
        ->all();

    $panel->navigationGroups($groups);
})

Store nav group order as a JSON column on the settings model and cast it to an array. Tenants can reorder groups through their own settings resource without a deploy.

Key Takeaways

  • Bind the resolved Tenant model via app()->instance() in middleware, not in a singleton.
  • Use bootUsing() on the Filament panel to apply tenant config after the container is warm.
  • On Octane, explicitly forget the Tenant binding after each request to prevent state leakage.
  • Cache tenant settings with a per-tenant key and invalidate on update — avoid global cache flushes.
  • Store feature flags and nav config as JSON on the tenant settings model for zero-deploy customisation.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why use bootUsing instead of configuring the panel directly in the panel() method?
The panel() method runs during service provider registration, before the request is fully resolved. bootUsing defers execution until after the container has the tenant binding, so app(Tenant::class) returns the correct instance.
Q02 Is it safe to call app()->instance(Tenant::class, $tenant) inside middleware on Octane?
Only if you flush the binding after the response. Use Octane's RequestHandled event or a terminating callback to call app()->forgetInstance(Tenant::class), otherwise the binding persists for the next request on the same worker.
Q03 Can I conditionally register Filament resources per tenant without affecting other tenants?
Yes. Inside bootUsing you have full access to the Panel instance. Call $panel->resources([...]) with a tenant-specific list. Because bootUsing runs per request, each tenant gets its own resource set without polluting a shared singleton.

Continue reading

More Articles

View all