The Problem: Filament Doesn't Know About Your Tenant
Filament v3 ships with a first-party multi-tenancy feature built around HasTenancy and panel ->tenant() configuration. It works well for simple setups, but the moment you need fine-grained control — per-resource overrides, scoped relationship managers, or custom auth logic — the abstraction leaks. This article shows a lower-level, fully explicit approach that gives you total control without fighting the framework.
Resolving the Current Tenant
Store the resolved tenant on a scoped singleton so every layer can read it without touching the request:
// app/Tenant/CurrentTenant.php
final class CurrentTenant
{
private ?Team $team = null;
public function set(Team $team): void
{
$this->team = $team;
}
public function get(): Team
{
return $this->team ?? throw new \RuntimeException('No tenant resolved.');
}
public function id(): int
{
return $this->get()->id;
}
}
Bind it as a singleton in AppServiceProvider:
$this->app->singleton(CurrentTenant::class);
Resolve it inside a middleware that runs before Filament's own middleware stack:
public function handle(Request $request, Closure $next): Response
{
$slug = $request->route('tenant'); // e.g. /app/{tenant}/...
$team = Team::where('slug', $slug)->firstOrFail();
abort_unless($request->user()->belongsToTeam($team), 403);
app(CurrentTenant::class)->set($team);
return $next($request);
}
A Reusable TenantScope Trait for Resources
Rather than overriding getEloquentQuery() in every resource, extract the pattern into a trait:
// app/Filament/Concerns/ScopedToTenant.php
trait ScopedToTenant
{
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where('team_id', app(CurrentTenant::class)->id());
}
}
Apply it to any resource:
class ProjectResource extends Resource
{
use ScopedToTenant;
protected static ?string $model = Project::class;
// ...
}
Every table query, export, and bulk action now inherits the scope automatically.
Scoping Relationship Managers
Relationship managers run their own queries. Override getTableQuery() on the manager:
class TasksRelationManager extends RelationManager
{
protected static string $relationship = 'tasks';
protected function getTableQuery(): Builder
{
return parent::getTableQuery()
->where('team_id', app(CurrentTenant::class)->id());
}
}
This prevents a crafted URL from surfacing tasks belonging to another team through a legitimate project record.
Scoping Select Options in Forms
Dropdowns that load related models are a common data-leak vector:
Select::make('assignee_id')
->label('Assignee')
->options(
fn () => User::whereHas('teams', fn ($q) =>
$q->where('teams.id', app(CurrentTenant::class)->id())
)->pluck('name', 'id')
)
->searchable(),
Never use User::all() here. Always filter through the tenant boundary.
Testing the Scope with Pest
use App\Tenant\CurrentTenant;
use App\Models\{Team, Project, User};
it('only lists projects belonging to the current tenant', function () {
$team = Team::factory()->create();
$other = Team::factory()->create();
Project::factory()->for($team)->count(3)->create();
Project::factory()->for($other)->count(2)->create();
app(CurrentTenant::class)->set($team);
$user = User::factory()->hasAttached($team)->create();
livewire(ProjectResource\Pages\ListProjects::class)
->actingAs($user)
->assertCanSeeTableRecords(Project::where('team_id', $team->id)->get())
->assertCanNotSeeTableRecords(Project::where('team_id', $other->id)->get());
});
This test resolves the singleton directly, bypassing HTTP middleware — fast and deterministic.
Key Takeaways
- Centralise tenant resolution in a singleton; never read
auth()->user()->current_team_idad-hoc inside resources. ScopedToTenanttrait keeps resource classes thin and the scoping logic in one auditable place.- Relationship managers need their own scope — inheriting from the parent resource is not automatic.
- Form selects are a leak vector — always filter option queries through the tenant boundary.
- Test with Livewire + Pest by injecting the
CurrentTenantsingleton directly, skipping the HTTP stack.