Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Subtenant Scope Laravel Package

leek/filament-subtenant-scope

Adds second-level tenancy to Filament panels: a topnav dropdown scopes every Eloquent query—resources, widgets, badges, and global search—to a sub-tenant like a service area, region, or branch. One trait plus a scopes array, no per-resource code needed.

View on GitHub
Deep Wiki
Context7

Getting Started

Three commands and two files, in this order:

composer require leek/filament-subtenant-scope

Then wire the theme. If you skip this, the plugin still works but the dropdown label collapses on wide screens because hidden sm:inline never gets compiled:

/* resources/css/filament/admin/theme.css */
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@source '../../../../vendor/leek/filament-subtenant-scope/resources/views/**/*.blade.php';
npm run build

Register one scope on the panel — pick the FK that already exists on your busiest model, because that's where you'll see the value immediately:

->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')),
    ]),
)

Then opt one resource in:

class AppointmentResource extends Resource
{
    use HasSubtenantScopes;

    protected static array $subTenantScopes = ['service_area' => 'service_area_id'];
}

Where to look first when something misbehaves: the Manager (resolution order URL → session → user storage, cached per request), the HasSubtenantScopes trait (it attaches the Eloquent global scope during panel boot()), and Livewire\SubtenantScopeSelector (that's what renders the topbar and triggers the reload). If the dropdown renders but queries don't filter, the problem is the trait; if queries filter but the dropdown is empty, the problem is optionsQuery.

First use case worth shipping: a service-area filter on appointments plus the appointments widget. Two files touched, and you get an honest read on whether the global-scope behaviour matches your mental model before you roll it across twenty resources.

Verification pass: select a scope, DB::listen() or Laravel Telescope the query and confirm the where service_area_id = ? is present, then hard-reload and confirm the selection survives. Confirm a ?scope_service_area=3 bookmark works on a cold session.

Implementation Patterns

FK column vs. relation. The shorthand ('service_area' => 'service_area_id') covers the flat case. For "client profile has many service areas," pass null and write the static method — note the method name is derived from the scope key, camel-cased:

protected static array $subTenantScopes = ['service_area' => null];

public static function scopeSubTenantServiceArea(Builder $query, int $id): void
{
    $query->where('primary_service_area_id', $id)
        ->orWhereHas('serviceAreas', fn ($q) => $q->where('service_areas.id', $id));
}

Always qualify inner columns in whereHas (service_areas.id, not id) — this is the same class of bug the 1.0.4 release fixed for the shorthand path.

Stack scopes, opt in selectively. Region and location can coexist; each gets its own dropdown and its own storage key. Resources declare only what applies to them, so a UserResource can opt into region only while AppointmentResource opts into both. Both filters are ANDed via separate global scopes, so a user picking Region A + Location B gets the intersection — usually what you want, occasionally not, so be deliberate about which models take both.

Persistence beyond the session. The session default is fine for a single shift of work but resets across devices. The documented JSON-column pattern is the right one, and saveQuietly() is deliberate — don't "fix" it to save() unless you actually want observers and updated_at churn on every dropdown change:

->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();
    },
)

Treat optionsQuery as the authorization boundary. This is the single most important pattern in the package. Scope filtering is a view convenience, not an access control mechanism — so the optionsQuery closure must return exactly the options the user is permitted to see, and you should separately verify that a hand-crafted ?scope_service_area=99 for someone else's area is rejected. If it isn't, you have an information-disclosure surface, not just a UX quirk. The safe pattern is to make optionsQuery derive from a policy or a shared forUser() scope so the dropdown and the resource's own access checks can never disagree.

Placement. Default hook is global-search-before. ->renderHook(PanelsRenderHook::TOPBAR_END) for right-aligned, or ->withoutDropdown() plus @livewire(\Leek\FilamentSubtenantScope\Livewire\SubtenantScopeSelector::class) to embed it in a custom header, sidebar, or a per-page widget.

Reactive UI. The selector dispatches sub-scope-changed and reloads the page. Use the event for anything client-side that isn't server-rendered (a chart that refetches, a map that recentres):

Livewire.on('sub-scope-changed', ({ scopeKey, value }) => chart.setRegion(value));

Testing. Assert the scope lands in the SQL rather than asserting on rendered output:

$this->actingAs($user);
session(['sub_tenant_scopes' => ['service_area' => $area->id]]);

AppointmentResource::getEloquentQuery()->dd(); // confirm the where clause
$this->assertStringContainsString('service_area_id', AppointmentResource::getEloquentQuery()->toSql());

For Livewire, test the selector component directly and assert the session write. And write one test that asserts the unscoped behaviour outside the panel — that's the contract your jobs rely on.

Gotchas and Tips

The global scope is per-model, not per-resource. Once any resource on Appointment opts in, every query on Appointment inside that panel filters — list pages, relation managers, nav badges, widget aggregates, global search. You cannot pilot this on one screen. Sequence rollout: pick a model, audit every place it's queried in the panel (including relation managers you forgot about), then opt in. Expect a "why are my counts wrong" ticket the first time a widget aggregates across what used to be the full set.

Everything outside the panel sees the unfiltered dataset. The scope is panel-aware and no-ops elsewhere: queued jobs, scheduled reports, CSV exports, API controllers, artisan commands. That's the correct default, but it means the dashboard says 42 and the emailed report says 310. Any KPI that must reconcile between UI and background work needs the active scope passed explicitly into the job — don't assume the job inherits it.

Single option means no filter applied. With exactly one accessible option, the selector renders as a static label and no global scope is attached. This is intentional and it will bite you in tests: a user with one area sees everything for that model that they'd see unscoped, which is fine if optionsQuery genuinely returns only their one area — and catastrophic if you were relying on the scope for filtering. Same applies to zero options: nothing renders, nothing filters.

Ambiguous column joins. Fixed in 1.0.4 for the shorthand path by qualifying with the model's table, but your custom scopeSubTenant{Key} methods are your responsibility. Any join against a table that also has service_area_id will throw an ambiguity error the moment the scope fires. Qualify everything.

The URL param is consumed and stripped. ?scope_service_area=3 is read, persisted to session, then removed on the next render. Don't build a share-link that expects the query string to remain, and don't try to read it client-side after the first navigation.

Selection triggers a full page reload. This is how server-rendered data refreshes, and it means an in-progress form with unsaved changes gets blown away if someone fiddles with the dropdown. If your panel has long forms, put the selector on list/dashboard pages only (withoutDropdown() + per-page embed) rather than globally.

Stale selections. If the scoped record is deleted or deactivated, the persisted ID survives and the user sees empty tables with no explanation. Either clean up on delete, or make the Manager's resolution validate against optionsQuery and fall through to "All" when the ID no longer resolves.

The trait lives on the Resource, not the model. Removing or hiding the resource removes the scope from the model entirely. In a multi-panel app this is subtle: two panels sharing Appointment will behave differently depending on which panel registered the plugin, and only the registered panel is scoped at all.

Config quirks. labelAttribute() defaults to name — if your model uses title, the dropdown silently renders blanks. optionsQuery runs per request; cache it if the underlying table is large and the query is expensive, but key the cache per user.

Extension points, ranked by cost. Custom render hook (cheap, config-only) → publish the blade via php artisan vendor:publish --tag=filament-subtenant-scope-views and restyle (cheap, one file to maintain) → withoutDropdown() and embed your own Livewire component wrapping the Manager (moderate, best for non-standard UX) → fork. You should almost never need the last one; the Manager and trait are thin enough that anything you want to change probably belongs in optionsQuery instead.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin