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.
Navigation Customisation Per Tenant
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
Tenantmodel viaapp()->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
Tenantbinding 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.