Subtenant Scope by Chris Jones
CommunitySecond-level tenancy scope (service area, region, location) for Filament panels — topnav dropdown that scopes Eloquent queries globally.
Author:
Chris Jones
Package health
Automated checks of this plugin's Composer package
15 checks
- Passed: GitHub Actions pinned to SHA
- Skipped: GitLab CI includes pinned to SHA
- Passed: Open security advisories
- Passed: Dependabot PR responsiveness — No open Dependabot PRs.
- Skipped: Renovate MR responsiveness
- Passed: Dependabot or Renovate configured
- Passed: Dependency update cooldown configured
- Passed: Provides a security policy
- Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
- Passed: Commit and release recency — Active: last commit 0 days ago; last release 0 days ago.
-
Passed:
composer.lock not committed by library
—
composer.lockis absent from the released dist archive. - Passed: Dist archive is lean
-
Passed:
Current Laravel version supported
—
Package dependencies resolve together with current Laravel
13.0. -
Passed:
Current PHP version supported
—
Constraint
^8.2supports current PHP8.5. - Skipped: Current Symfony version supported
filament/
namespace. Review the source and install at your own risk. Found
malware or an unresolved security issue the author won't
address?
Report it
.
Documentation
- Why
- Requirements
- Installation
- Opt resources into the scope
- Behavior
- Persistence
- Customizing the dropdown
- Multiple scopes
- Listening for changes
- Testing
- How it works
- More Filament plugins by Leek
- License
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.
#Why
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:
- persists across navigation
- survives logout/login
- is shareable via URL
- applies to every model query without per-resource code
This plugin does that with one trait + one ->scopes([...]) array.
#Requirements
- PHP 8.2+
- Filament v4.x or v5.x
- Livewire v3 or v4
#Installation
composer require leek/filament-subtenant-scope
#Styles
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
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.
#Manage link
Add a link at the bottom of the dropdown pointing to wherever the options are managed. The closure receives the authenticated user; return null to hide the link (e.g. when the user lacks permission). The label defaults to "Manage {plural label}".
SubtenantScope::make('service_area', 'Service Area', ServiceArea::class, 'service_area_id')
->manageUrl(fn ($user) => $user?->can('viewAny', ServiceArea::class)
? ServiceAreaResource::getUrl('index')
: null),
#Opt resources into the scope
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.
#Custom join logic
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));
});
}
}
#Behavior
- URL bookmarks: append
?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. - Single option: when the user has access to exactly one option, the scope renders as a static label (no dropdown) and applies no filter — there's nothing to filter between.
- Multiple options: dropdown with "All …" plus each option.
- No options: nothing renders.
#Persistence
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.
#Customizing the dropdown
#Render hook
Override where the dropdown renders:
use Filament\View\PanelsRenderHook;
SubtenantScopingPlugin::make()
->scopes([/* ... */])
->renderHook(PanelsRenderHook::TOPBAR_END);
#Render the selector yourself
Disable the built-in render hook and embed the Livewire component anywhere:
SubtenantScopingPlugin::make()
->scopes([/* ... */])
->withoutDropdown();
@livewire(\Leek\FilamentSubtenantScope\Livewire\SubtenantScopeSelector::class)
#Override the view
Publish and edit the dropdown blade:
php artisan vendor:publish --tag=filament-subtenant-scope-views
#Multiple scopes
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',
];
#Nested scopes
When one scope lives inside another (locations inside a region), declare the
parent with dependsOn(), naming the parent scope's key and the child model's
column that points at it:
SubtenantScope::make('location', 'Location', Location::class, 'location_id')
->dependsOn('region', 'region_id'),
- While a region is selected, the location dropdown offers only that region's locations, and its "All" option reads "All Locations in North".
- Picking a different region keeps the selected location only if it is inside the new region; going back to "All Regions" clears it.
- A stored location that falls outside the selected region (stale session, URL or user setting) is ignored, so a mismatched pair can never filter to nothing.
- A nested scope keeps its dropdown even with a single option, since picking the region's only location is still narrower than the region alone.
#Listening for changes
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 }) => {
// ...
});
#Testing
composer test
#How it works
- Plugin registers a render hook that pulls the dropdown into the topbar, scopes the manager request-singleton, and walks panel resources during
boot()to attach Eloquent global scopes. - Manager resolves the active selection per scope (URL → session → user storage), caches per request.
- Trait (
HasSubtenantScopes) adds a panel-aware Eloquent global scope to the model. The scope is no-op outside the panel the plugin is registered on. - Livewire selector renders one dropdown per registered scope, writes the selection through the manager, and reloads the page so server-rendered data picks up the new filter.
#More Filament plugins by Leek
Premium
- Filament UI Plus — Enhanced UI components: dual sub-navigation, animated sidebar, horizontal-scroll tables, loading bar, and more.
- Filament Workflow Engine — Automated workflows with a visual builder, triggers/actions, async execution, and audit logging.
- Filament Decision Tables — Business rules engine with spreadsheet-style decision tables.
Free & open source
- Filament Right Click — Right-click context menus for table rows.
- Filament Header Filters — Inline filters attached to table column headers.
- Filament DiceBear — DiceBear avatar provider with 31 styles.
#License
MIT. See LICENSE.md.
The author
Web Application Architect and Developer with a passion for helping businesses make sense of web-based technology and its numerous applications.
From the same author
DiceBear Avatars
DiceBear avatar provider for Filament panels with 31 avatar styles, disk caching, and per-model customization.
Author:
Chris Jones
Header Filters
Inline header filters for Filament tables. Attach any BaseFilter to a column header — select dropdowns, date pickers, min/max ranges, custom multi-field schemas — as a richer alternative to searchable(isIndividual: true).
Author:
Chris Jones
Right Click
Add native-feeling right-click menus to Filament table rows. Trigger regular Filament actions for single records, or show a separate bulk menu when users right-click selected rows. Actions stay server-enforced, so modals, authorization, validation, redirects, and notifications still work through Filament.
Author:
Chris Jones
Decision Tables (Rules Engine)
A visual rules engine for Filament v4 that lets users create and manage complex decision logic - no code required.
Author:
Chris Jones
Featured Plugins
A selection of plugins curated by the Filament team
Blueprint
Filament Blueprint is a premium Laravel Boost extension that helps AI agents produce accurate, detailed implementation plans and security reports for Filament apps.
Filament
Custom Dashboards
Let your users build and share their own dashboards with a drag-and-drop interface. Define your data sources in PHP and let them do the rest.
Filament
Custom Fields
Eliminate custom field migrations forever. Let your users create and manage form fields directly in Filament admin panels with 20+ built-in field types, validation, and zero database changes.
Relaticle