leek/filament-subtenant-scope
Adds second-level tenancy to Filament panels with a top-nav dropdown that scopes all Eloquent queries to a sub-tenant (region, location, department, etc.). Persists via session/URL and auto-filters resources, widgets, badges, and global search.
Second-level tenancy for Filament panels. Adds a topnav dropdown that scopes every Eloquent query in the panel to a sub-tenant — service area, region, location, branch, department — without touching individual resources.
Filament's built-in tenancy gives you one tenant. This plugin adds another level on top: pick a sub-scope, and resources, widgets, navigation badges, and global search all auto-filter.
You already have multi-tenancy (e.g. Company). Inside each company you also need a soft filter — "show me only the North service area" — that:
This plugin does that with one trait + one ->scopes([...]) array.
composer require leek/filament-subtenant-scope
Tell your panel theme to compile the plugin's blade utility classes by adding a @source directive to the panel theme configured with ->viteTheme(...):
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@source '../../../../vendor/leek/filament-subtenant-scope/resources/views/**/*.blade.php';
Then rebuild your app assets:
npm run build
Without this, responsive utilities like hidden sm:inline used inside the dropdown won't be compiled into your panel CSS and the dropdown label may collapse on wide screens.
Register the plugin on your panel and define one or more scopes:
use Filament\Panel;
use Leek\FilamentSubtenantScope\SubtenantScope;
use Leek\FilamentSubtenantScope\SubtenantScopingPlugin;
use App\Models\ServiceArea;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(
SubtenantScopingPlugin::make()
->scopes([
SubtenantScope::make('service_area', 'Service Area', ServiceArea::class, 'service_area_id')
->icon('heroicon-o-map-pin')
->labelAttribute('name')
->optionsQuery(fn ($user) => ServiceArea::query()
->where('company_id', $user->company_id)
->where('is_active', true)
->orderBy('name')),
]),
);
}
That's the whole topnav setup. The dropdown renders next to the global search.
Add the HasSubtenantScopes trait and map each scope key to the FK column on the resource's model:
use Filament\Resources\Resource;
use Leek\FilamentSubtenantScope\Concerns\HasSubtenantScopes;
class AppointmentResource extends Resource
{
use HasSubtenantScopes;
/** @var array<string, string|null> */
protected static array $subTenantScopes = [
'service_area' => 'service_area_id',
];
}
The plugin walks every resource in the panel during boot() and registers an Eloquent global scope on the model. Once any resource opts in, all queries on that model auto-filter — list pages, relation managers, navigation badges, widgets, global search.
If the FK isn't on the model directly, pass null and define a static method named scopeSubTenant{Key}:
class ClientProfileResource extends Resource
{
use HasSubtenantScopes;
protected static array $subTenantScopes = ['service_area' => null];
public static function scopeSubTenantServiceArea(Builder $query, int $id): void
{
$query->where(function ($q) use ($id) {
$q->where('primary_service_area_id', $id)
->orWhereHas('serviceAreas', fn ($q) => $q->where('service_areas.id', $id));
});
}
}
?scope_<key>=<id> to any panel URL — the value is read, persisted, then stripped from the URL on the next render so it's sticky.By default, selections persist for the session. To make them sticky across sessions/devices, register get/set callbacks. The classic pattern is a JSON column on users:
SubtenantScopingPlugin::make()
->scopes([/* ... */])
->persistUsing(
get: fn ($user, string $key) => $user->settings['sub_tenant_scopes'][$key] ?? null,
set: function ($user, string $key, ?int $id): void {
$settings = $user->settings ?? [];
$settings['sub_tenant_scopes'][$key] = $id;
$user->settings = $settings;
$user->saveQuietly();
},
);
Resolution order: URL param → session → user storage. First non-null wins.
Override where the dropdown renders:
use Filament\View\PanelsRenderHook;
SubtenantScopingPlugin::make()
->scopes([/* ... */])
->renderHook(PanelsRenderHook::TOPBAR_END);
Disable the built-in render hook and embed the Livewire component anywhere:
SubtenantScopingPlugin::make()
->scopes([/* ... */])
->withoutDropdown();
@livewire(\Leek\FilamentSubtenantScope\Livewire\SubtenantScopeSelector::class)
Publish and edit the dropdown blade:
php artisan vendor:publish --tag=filament-subtenant-scope-views
Stack as many as you need. Each gets its own dropdown and storage key:
SubtenantScopingPlugin::make()
->scopes([
SubtenantScope::make('region', 'Region', Region::class, 'region_id'),
SubtenantScope::make('location', 'Location', Location::class, 'location_id'),
]);
Resources can opt into one or both:
protected static array $subTenantScopes = [
'region' => 'region_id',
'location' => 'location_id',
];
The Livewire component dispatches sub-scope-changed after every selection (it also triggers a full page reload to refresh server-rendered scoped data):
Livewire.on('sub-scope-changed', ({ scopeKey, value }) => {
// ...
});
composer test
boot() to attach Eloquent global scopes.HasSubtenantScopes) adds a panel-aware Eloquent global scope to the model. The scope is no-op outside the panel the plugin is registered on.Premium
Free & open source
MIT. See LICENSE.md.
How can I help you explore Laravel packages today?