Key takeaways
- Filament’s built-in tenancy is single-database, row-based: every tenant-owned table has a
team_id, and the panel scopes queries to the current team automatically. - You need three pieces:
->tenant(Team::class)on the panel, theHasTenantsinterface onUser, and ateam()relationship on each tenant-owned model. - Laravel’s
uniqueandexistsrules ignore tenant scopes. UsescopedUnique()andscopedExists()instead. - Anything outside the panel request (queued jobs, commands, APIs) isn’t scoped. Pass the tenant explicitly.
Most SaaS products end up needing the same thing: a panel where each customer (a “team”, “workspace” or “organisation”) sees only its own data, can invite colleagues and has its own billing. Filament v5 has this built in, and since v4 it’s much safer by default, because Filament now scopes all queries in a tenant panel instead of just resource tables.
We’ll build a multi-tenant “app” panel for a simple CRM, with teams, members and a Customer resource.
Choosing a tenancy model
| Approach | How it works | Filament fit |
|---|---|---|
Single database, team_id column | Every row belongs to a tenant | Native. This is what ->tenant() does |
| Database per tenant | Switch connection per request | Possible with packages like stancl/tenancy, but outside Filament’s tenancy feature |
| Separate app per customer | One deployment each | No tenancy needed. High ops cost |
Filament’s docs are clear that tenancy here means “a single instance of the application serves multiple customers” with shared tables. It’s the right choice for most B2B SaaS products up to very large scale. If compliance requires physically separate databases, look at a database-per-tenant package instead.
Step 1: Data model
php artisan make:model Team -m
php artisan make:migration create_team_user_table
php artisan make:model Customer -m// create_teams_table
Schema::create('teams', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
// create_team_user_table
Schema::create('team_user', function (Blueprint $table) {
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('member');
$table->primary(['team_id', 'user_id']);
});
// create_customers_table
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('email');
$table->timestamps();
$table->unique(['team_id', 'email']); // unique per tenant, not globally
$table->index(['team_id', 'created_at']);
});Two details here save pain later: composite unique indexes that include team_id, and composite indexes that start with team_id, because every tenant query filters on it first.
// app/Models/Team.php
class Team extends Model
{
protected $fillable = ['name', 'slug'];
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class)->withPivot('role');
}
public function customers(): HasMany
{
return $this->hasMany(Customer::class);
}
}
// app/Models/Customer.php
class Customer extends Model
{
protected $fillable = ['name', 'email'];
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
}By convention, Filament looks for a relationship named after the tenant model (team) on each resource’s model, and for a plural relationship on the tenant (customers) for the resource.
Step 2: Make users tenant-aware
// app/Models/User.php
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasDefaultTenant;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class User extends Authenticatable implements FilamentUser, HasTenants, HasDefaultTenant
{
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)->withPivot('role');
}
public function canAccessPanel(Panel $panel): bool
{
return $this->hasVerifiedEmail();
}
public function getTenants(Panel $panel): Collection
{
return $this->teams;
}
public function canAccessTenant(Model $tenant): bool
{
return $this->teams()->whereKey($tenant)->exists();
}
public function getDefaultTenant(Panel $panel): ?Model
{
return $this->teams()->latest('team_user.team_id')->first();
}
}canAccessTenant() is your main security check. Filament calls it to stop a user from changing /app/acme in the URL to someone else’s team.
Step 3: Configure the panel
// app/Providers/Filament/AppPanelProvider.php
use App\Filament\Pages\Tenancy\EditTeamProfile;
use App\Filament\Pages\Tenancy\RegisterTeam;
use App\Models\Team;
public function panel(Panel $panel): Panel
{
return $panel
->id('app')
->path('app')
->login()
->registration()
->tenant(Team::class, slugAttribute: 'slug')
->tenantRegistration(RegisterTeam::class)
->tenantProfile(EditTeamProfile::class)
->searchableTenantMenu()
// ...
;
}URLs now look like /app/acme/customers. If you prefer subdomains, use ->tenantDomain('{tenant:slug}.example.com'), and read the domain-routing note in our Laravel 13 compatibility checklist first.
Step 4: Tenant registration and profile pages
// app/Filament/Pages/Tenancy/RegisterTeam.php
namespace App\Filament\Pages\Tenancy;
use App\Models\Team;
use Filament\Forms\Components\TextInput;
use Filament\Pages\Tenancy\RegisterTenant;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class RegisterTeam extends RegisterTenant
{
public static function getLabel(): string
{
return 'Create a team';
}
public function form(Schema $schema): Schema
{
return $schema->components([
TextInput::make('name')
->required()
->maxLength(100)
->live(onBlur: true)
->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->required()
->alphaDash()
->unique(Team::class, 'slug'),
]);
}
protected function handleRegistration(array $data): Team
{
$team = Team::create($data);
$team->members()->attach(auth()->user(), ['role' => 'owner']);
return $team;
}
}EditTeamProfile extends Filament\Pages\Tenancy\EditTenantProfile and has the same shape: a getLabel() and a form(). Restrict who can edit it with canView(Model $tenant), for example owners only.
Step 5: Resources are scoped automatically
php artisan make:filament-resource Customer --generateWith no extra code, the Customer resource:
- lists only the current team’s customers,
- resolves
/app/acme/customers/15/editonly if customer 15 belongs to Acme, - fills in
team_idautomatically when a customer is created, and - scopes global search results to the team.
Since v4, Filament applies tenant scoping to all queries made in the panel, using global scopes, and associates new records with the tenant through model events. In v3, only resource queries were scoped.
If your relationship names don’t follow the convention, set them explicitly:
protected static ?string $tenantOwnershipRelationshipName = 'organisation';
protected static ?string $tenantRelationshipName = 'clients';For resources that are genuinely global inside a tenant panel (a shared Country list, for example), opt out:
protected static bool $isScopedToTenant = false;Step 6: Validation that respects tenants
This is the most common tenancy bug in Filament apps. Laravel’s unique and exists rules run their own queries, without Eloquent global scopes. A plain ->unique() on a customer’s email checks every team’s customers, so two companies can’t both have [email protected] as a customer, and the error message leaks that the email exists somewhere else.
Use the scoped variants:
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
TextInput::make('email')
->email()
->required()
->scopedUnique(), // unique within the current team
Select::make('account_manager_id')
->relationship('accountManager', 'name')
->scopedExists(), // must exist within the current team's scopeThe same applies to Select::relationship() options for models the panel doesn’t scope. Check that every dropdown only lists the current team’s records.
Step 7: Work that runs outside the panel
Tenant scoping comes from the panel’s request lifecycle. These don’t go through it:
- Queued jobs. There’s no current tenant in a worker. Pass the team into the job and query through it (
$team->customers()), or re-apply a scope yourself. - Artisan commands and schedules. Loop over teams explicitly.
- API routes (Sanctum). Resolve the team from the token or the route and scope queries yourself.
- Imports and exports. Filament’s import jobs don’t do per-record authorisation. Set
team_idfrom the import’s options or the user, and validate against it (see our CSV import/export guide).
class RecalculateCustomerScores implements ShouldQueue
{
use Queueable;
public function __construct(public Team $team) {}
public function handle(): void
{
$this->team->customers()->lazyById()->each->recalculateScore();
}
}To apply global scopes to other models in panel requests too, register tenant middleware that is persistent across Livewire requests:
->tenantMiddleware([
\App\Http\Middleware\ApplyTenantScopes::class,
], isPersistent: true)Step 8: Roles inside a team
The role pivot column is enough for owner/admin/member rules in policies:
public function delete(User $user, Customer $customer): bool
{
$role = $user->teams()->whereKey($customer->team_id)->first()?->pivot->role;
return in_array($role, ['owner', 'admin'], true);
}For granular permissions per team, combine spatie/laravel-permission’s teams mode with Filament Shield’s tenancy support. We cover that in roles and permissions in Filament.
Step 9: Billing per tenant
Filament ships a billing hook: ->tenantBillingProvider(...) plus ->requiresTenantSubscription(). You can use Laravel Spark’s provider, or write your own with Laravel Cashier. Our Filament + Stripe subscription guide builds the Cashier version step by step.
Step 10: Test the tenant boundary
use Filament\Facades\Filament;
use function Pest\Livewire\livewire;
it('never shows another team\'s customers', function () {
[$acme, $globex] = Team::factory()->count(2)->create();
$user = User::factory()->create();
$user->teams()->attach($acme);
$mine = Customer::factory()->for($acme)->count(3)->create();
$theirs = Customer::factory()->for($globex)->count(3)->create();
$this->actingAs($user);
Filament::setCurrentPanel('app');
Filament::setTenant($acme);
Filament::bootCurrentPanel();
livewire(ListCustomers::class)
->assertCanSeeTableRecords($mine)
->assertCanNotSeeTableRecords($theirs);
});Filament::bootCurrentPanel() applies the tenant scopes and model listeners in tests, as described in the testing docs. More patterns are in testing Filament panels with Pest.
Tenant security checklist
canAccessTenant()checks membership, not just “logged in”.- Every tenant-owned table has
team_id, a foreign key and composite indexes. scopedUnique()/scopedExists()replaceunique()/exists()on tenant data.- Jobs, commands and APIs receive the tenant explicitly.
- Tests assert that another tenant’s records are invisible, for every resource.
- File uploads are stored under a tenant-specific path (
->directory(fn () => 'teams/'.Filament::getTenant()->id)).
FAQ
Does Filament support multi-tenancy out of the box?
Yes. Filament has built-in single-database tenancy with ->tenant(), tenant registration and profile pages, a tenant switcher, automatic query scoping and billing hooks.
Can Filament do database-per-tenant?
Not natively. Filament’s tenancy is row-based. For a separate database per tenant, use a package such as stancl/tenancy alongside a panel that isn’t Filament-tenant-aware.
Why does unique validation fail across teams?
Laravel’s unique rule bypasses Eloquent global scopes, so it checks all tenants. Use Filament’s scopedUnique() and a composite unique index on (team_id, column).
How do I get the current tenant in code?
Call Filament\Facades\Filament::getTenant() inside panel requests. In jobs and commands there’s no current tenant, so pass it in yourself.
Sources and further reading
- Filament multi-tenancy documentation
- Filament v4 upgrade guide: automatic tenancy scoping
- Laravel Eloquent global scopes
- Filament testing resources (multi-tenant panels)
Related on The Web Tier: Filament + Stripe subscription billing · Roles and permissions in Filament
