Originally published at hafiz.dev
Every SaaS app eventually hits the same question: how do you make one application serve multiple customers with separate data? If you're building with Filament, the answer is closer than you think. Filament ships with a built-in tenancy system that handles tenant switching, automatic resource scoping, registration, and profile management out of the box.
But here's the thing: the docs cover what's available without walking you through the full implementation. You get a list of methods and interfaces, and then you're on your own to wire them together. If you've read the Building a SaaS with Filament guide, you have the foundation. This post picks up where that left off: adding proper multi-tenancy so your users can belong to teams, switch between them, and see only their team's data.
We'll build the full system: tenant model, user relationships, panel configuration, registration, profile editing, automatic scoping, and the security gotchas that trip up most developers.
How Filament's Tenancy Works
Before we write any code, it's worth understanding what Filament means by "multi-tenancy." It's not database-per-tenant isolation (like stancl/tenancy). Filament uses a shared database with a many-to-many relationship between users and tenants.
The mental model: a user belongs to many teams. A team has many users. Every resource (projects, invoices, tickets, whatever you're building) belongs to a team. When a user logs in, they pick a team, and Filament automatically scopes all resources to that team. The user can switch teams from a dropdown in the sidebar.
If your app has a simpler model where each user belongs to exactly one organization (one-to-many), you don't actually need Filament's tenancy system. You can use a global scope and a belongsTo relationship instead. Filament's tenancy is designed for the many-to-many case where users switch between contexts.
Step 1: Create the Tenant Model
Your tenant can be called anything: Team, Organization, Company, Workspace. We'll use Team here. Create the model, migration, and pivot table:
php artisan make:model Team -m
php artisan make:migration create_team_user_table
The Team migration:
Schema::create('teams', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
The pivot table:
Schema::create('team_user', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('member');
$table->timestamps();
$table->unique(['team_id', 'user_id']);
});
That role column on the pivot is optional, but you'll want it eventually for permissions (owner, admin, member). Adding it now saves a migration later.
Step 2: Set Up the Relationships
The Team model needs a users relationship and the HasName interface so Filament can display the team name in the switcher:
namespace App\Models;
use Filament\Models\Contracts\HasName;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Team extends Model implements HasName
{
protected $fillable = ['name', 'slug'];
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->withPivot('role')->withTimestamps();
}
public function getFilamentName(): string
{
return $this->name;
}
}
The User model needs the HasTenants interface. This tells Filament which tenants the user belongs to and whether they can access a specific tenant:
namespace App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Collection;
class User extends Authenticatable implements FilamentUser, HasTenants
{
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)->withPivot('role')->withTimestamps();
}
public function getTenants(Panel $panel): array|Collection
{
return $this->teams;
}
public function canAccessTenant(Model $tenant): bool
{
return $this->teams()->whereKey($tenant)->exists();
}
public function canAccessPanel(Panel $panel): bool
{
return true;
}
}
getTenants() returns the teams the user belongs to. Filament calls this to populate the tenant switcher dropdown. canAccessTenant() is the security gate: it runs on every request to make sure the user actually belongs to the tenant in the URL. Don't skip this. Without it, a user could change the team ID in the URL and access another team's data.
Step 3: Configure the Panel
Open your panel provider (usually app/Providers/Filament/AdminPanelProvider.php) and add the tenant configuration:
use App\Models\Team;
use App\Filament\Pages\Tenancy\RegisterTeam;
use App\Filament\Pages\Tenancy\EditTeamProfile;
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->registration()
->tenant(Team::class, slugAttribute: 'slug')
->tenantRegistration(RegisterTeam::class)
->tenantProfile(EditTeamProfile::class);
}
The slugAttribute: 'slug' parameter tells Filament to use the slug column in URLs instead of the auto-incrementing ID. Your URLs will look like /admin/acme-corp/projects instead of /admin/1/projects. This is cleaner and doesn't leak information about how many teams exist.
After a user logs in, Filament redirects them to their first team (from getTenants()). If they don't have a team yet, they're sent to the registration page.
Step 4: Build the Registration Page
Create a page that extends RegisterTenant. This is what new users see when they don't belong to any team yet:
namespace App\Filament\Pages\Tenancy;
use App\Models\Team;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Pages\Tenancy\RegisterTenant;
use Illuminate\Support\Str;
class RegisterTeam extends RegisterTenant
{
public static function getLabel(): string
{
return 'Create a Team';
}
public function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')
->required()
->maxLength(255)
->live(debounce: 500)
->afterStateUpdated(fn ($set, $state) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->required()
->unique(Team::class, 'slug')
->maxLength(255),
]);
}
protected function handleRegistration(array $data): Team
{
$team = Team::create($data);
$team->users()->attach(auth()->id(), ['role' => 'owner']);
return $team;
}
}
The handleRegistration method creates the team and attaches the current user as the owner. This is where you'd add any onboarding logic: creating default settings, seeding initial data, or sending a welcome notification.
Step 5: Build the Profile Page
The profile page lets users edit their team settings. Same pattern:
namespace App\Filament\Pages\Tenancy;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Pages\Tenancy\EditTenantProfile;
class EditTeamProfile extends EditTenantProfile
{
public static function getLabel(): string
{
return 'Team Settings';
}
public function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')
->required()
->maxLength(255),
]);
}
}
This page is accessible from the tenant menu in the sidebar. Add fields as your team model grows: logo upload, billing email, timezone, whatever your app needs.
Step 6: Add the Tenant Relationship to Your Resources
This is the part most tutorials skip, and it's where data leaks happen.
Every model that belongs to a team needs a team_id column and a belongsTo relationship:
// In your migration
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
// In your model
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
And the Team model needs the inverse:
// In Team.php
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
Filament uses this relationship to automatically scope queries. When a user is viewing the "Acme Corp" team, ProjectResource will only show projects where team_id matches the current tenant. You don't need to add any where clauses or global scopes yourself. Filament handles it.
But you DO need to make sure the team_id gets set when creating new records. The simplest way is a model observer or the creating event:
// In AppServiceProvider boot() or a dedicated observer
use Filament\Facades\Filament;
Project::creating(function (Project $project) {
if (Filament::getTenant()) {
$project->team_id = Filament::getTenant()->id;
}
});
Without this, new records won't have a team_id and will be invisible to the tenant scoping.
The Gotcha That Trips Up Everyone
Filament's automatic scoping works for resource tables and queries. But it does NOT automatically scope form components that load options from the database.
If you have a Select component that pulls options via a relationship() method, those options are not filtered by tenant:
// This will show ALL categories from ALL teams
Select::make('category_id')
->relationship('category', 'name')
The official docs are explicit about this. The form components live in a separate package and don't know about tenancy. You need to scope them manually:
Select::make('category_id')
->relationship(
'category',
'name',
fn (Builder $query) => $query->whereBelongsTo(Filament::getTenant())
)
This applies to Select, CheckboxList, Repeater, and SelectFilter. Any component that fetches data from the database through a relationship needs manual scoping. Miss one, and users from Team A will see Team B's categories in their dropdown. That's a data leak.
If you have a lot of these, consider creating a base resource class that overrides the form builder to inject tenant scoping automatically. Or add a global scope to the models themselves, though that can cause issues outside of Filament.
Disabling Tenancy for Specific Resources
Not everything belongs to a team. Settings, plans, or shared lookup tables might be global. You can opt a resource out of tenant scoping:
class PlanResource extends Resource
{
protected static bool $isScopedToTenant = false;
}
Or flip the default so resources are NOT scoped unless you explicitly opt in:
// In a service provider
use Filament\Resources\Resource;
Resource::scopeToTenant(false);
Then add protected static bool $isScopedToTenant = true; to each resource that should be scoped. This opt-in approach is safer for apps with a mix of global and tenant-specific data.
Controlling the Tenant Switcher
By default, Filament shows a dropdown in the sidebar for switching between teams. You can customize it in several ways.
Add a label above the current tenant name:
use Filament\Models\Contracts\HasCurrentTenantLabel;
class Team extends Model implements HasName, HasCurrentTenantLabel
{
public function getCurrentTenantLabel(): string
{
return 'Active team';
}
}
Set a default tenant when the user logs in:
// In User.php
public function getDefaultTenant(Panel $panel): ?Model
{
return $this->teams()->first();
}
And if you want to keep the tenant menu (for profile and billing links) but hide the switcher itself, Filament v5.2 added switchableTenants():
$panel->switchableTenants(condition: false);
This is useful when tenants are selected through other means (like a URL subdomain) and the switcher UI is unnecessary.
Subdomain-Based Tenancy
Instead of path-based URLs (/admin/acme-corp/projects), you can identify tenants by subdomain (acme-corp.yourapp.com):
$panel->tenantDomain('{tenant:slug}.yourapp.com');
One thing to know: when you use a domain parameter for the entire domain, Filament registers a global route parameter pattern that allows dots and hyphens. This might conflict with other panels or routes in your app. Test thoroughly if you have multiple panels.
Applying Middleware to Tenant Routes
If you need to run middleware on all tenant-aware routes (like checking subscription status), use tenantMiddleware:
$panel->tenantMiddleware([
\App\Http\Middleware\EnsureTeamIsSubscribed::class,
]);
This middleware runs after the tenant is resolved, so you have access to Filament::getTenant() inside it.
Accessing the Current Tenant Anywhere
Use Filament::getTenant() to get the current team in controllers, jobs, notifications, or anywhere else in your app:
use Filament\Facades\Filament;
$team = Filament::getTenant();
$teamName = $team->name;
This returns null outside of a Filament panel context, so check for that in shared code like jobs or API controllers.
FAQ
Can I use Filament's multi-tenancy with separate databases per tenant?
Filament's built-in tenancy uses a shared database with relationship-based scoping. If you need database-per-tenant isolation, look at stancl/tenancy or the FilamentTenancy plugin by TomatoPHP, which bridges stancl/tenancy with Filament panels. These are separate packages, not part of Filament core.
Do I need spatie/laravel-multitenancy alongside Filament's built-in tenancy?
For most cases, no. Filament's tenancy handles the common SaaS pattern (users belong to teams, data is scoped by team) without additional packages. Spatie's package or stancl/tenancy adds features like database isolation, domain identification, and tenant-specific configurations. If your needs are simpler, Filament alone is enough.
What happens when a user doesn't belong to any team?
Filament redirects them to the tenant registration page (if you've configured one with tenantRegistration()). If you haven't configured a registration page, the user will see an error. Always set up a registration page, even if new teams are created by admins. You can restrict who sees the registration page using a middleware or by conditionally setting it in the panel configuration.
How do I invite users to a team?
Filament doesn't include an invitation system out of the box. You'll need to build one yourself or use a package like filament-companies by Andrew Wallo, which includes team invitations, role management, and profile features similar to Laravel Jetstream. The invitation flow typically involves creating an invitation record, sending an email with a signed URL, and attaching the user to the team when they accept.
Is the automatic resource scoping safe for production?
The resource scoping is safe as long as you handle two things: implement canAccessTenant() on your User model (to prevent URL manipulation), and manually scope form components that load options via relationships. If you skip either of these, tenant data can leak. Both are covered in this guide.
What's Next
If you followed the SaaS guide and the admin dashboard guide, multi-tenancy is the natural next step. Your application now supports multiple customers, each with their own data, their own team settings, and the ability to switch between workspaces.
For a deeper look at how multi-tenancy strategies compare at the database level (shared database vs. schema isolation vs. database per tenant), the Laravel Multi-Tenancy post covers the broader architectural decisions.
If you're building a multi-tenant SaaS with Filament and need help with architecture, data isolation, or production deployment, get in touch.
This article was originally published by DEV Community and written by Hafiz.
Read original article on DEV Community