Filament multi-tenancy with a team switcher menu
Scope a Filament 4 panel to the current team, let users switch teams from a menu, create new ones and remember the last team used.
FilamentNavigationfilamentmulti-tenancyteamsswitcher
Code
<?php
namespace App\Providers\Filament;
use App\Filament\Pages\Tenancy\RegisterTeam;
use App\Models\Team;
use Filament\Actions\Action;
use Filament\Panel;
use Filament\PanelProvider;
class AppPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('app')
->path('app')
->login()
// Every resource query is scoped to the current Team through this relation name.
->tenant(Team::class, slugAttribute: 'slug', ownershipRelationship: 'team')
->tenantRegistration(RegisterTeam::class) // "Create team" in the switcher
->tenantMenu(true)
->tenantMenuItems([
Action::make('team-settings')
->label('Team settings')
->icon('heroicon-o-cog-6-tooth')
->url(fn (): string => route('filament.app.pages.team-settings', ['tenant' => filament()->getTenant()]))
->visible(fn (): bool => auth()->user()->ownsTeam(filament()->getTenant())),
]);
}
}
<?php
namespace App\Models\Concerns;
use App\Models\Team;
use Filament\Panel;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
/**
* Add to User and implement:
* Filament\Models\Contracts\HasTenants, Filament\Models\Contracts\HasDefaultTenant.
* Requires a team_user pivot and a nullable users.current_team_id column.
*/
trait BelongsToTeams
{
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)->withPivot('role');
}
/** What the switcher lists. */
public function getTenants(Panel $panel): Collection
{
return $this->teams;
}
/** Called on every tenant URL: a guessed slug of someone else's team must fail. */
public function canAccessTenant(Model $tenant): bool
{
return $this->teams()->whereKey($tenant->getKey())->exists();
}
/** Where to land after login: the last team used, else the first one. */
public function getDefaultTenant(Panel $panel): ?Model
{
return $this->teams()->find($this->current_team_id) ?? $this->teams()->first();
}
public function ownsTeam(Team $team): bool
{
return $this->teams()->whereKey($team->getKey())->wherePivot('role', 'owner')->exists();
}
/** Hook this to the panel's tenant "booted" event or a middleware to remember the choice. */
public function rememberTeam(Team $team): void
{
if ($this->current_team_id !== $team->getKey()) {
$this->forceFill(['current_team_id' => $team->getKey()])->save();
}
}
}
Needs: filament/filament@^4.0
How it works
What it solves
A SaaS product with workspaces or teams needs two guarantees: users only ever see rows that belong to their current team, and they can hop between teams without logging out. Filament's tenancy feature provides both; this snippet shows the minimal wiring plus the parts people forget.
How it works
In the panel, tenant(Team::class, ...) turns on tenancy. The URL gains the team slug (/app/acme-team/...), and Filament automatically scopes resource queries and fills the foreign key on create through the ownershipRelationship. tenantRegistration() adds a "Create team" entry in the switcher, and tenantMenuItems() adds custom links, here "Team settings", which only the team owner sees.
The user model contract is the security core. getTenants() supplies the list in the switcher. canAccessTenant() runs on every tenant request and checks membership in the database, so changing the slug in the address bar to another customer's team returns 403 rather than data. getDefaultTenant() decides where a user lands after login: the team stored in current_team_id, falling back to the first membership.
Adapting it
Rename Team to Workspace or Organization throughout. Add roles to the pivot and expose helpers such as ownsTeam() for menu visibility. Persist the last used team by calling rememberTeam() from a small middleware that reads filament()->getTenant().
Pitfalls
- Every tenant-owned model needs the relation named in
ownershipRelationship(hereteam) and ateam_idcolumn. - Global scopes are applied by Filament only for Filament queries, scope API and job queries yourself.
- Slugs must be unique across all teams.
How to use it
- Create
Team, ateam_userpivot (withrole) andusers.current_team_id. - Make
UserimplementHasTenantsandHasDefaultTenant, and use the trait. - Add
team()to tenant-owned models and ateam_idcolumn to their tables. - Create the registration page:
php artisan make:filament-page RegisterTeamextendingRegisterTenant. - Open the panel: the team name in the sidebar opens the switcher.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.