Search

Build a Multi-Tenant SaaS Admin Panel with Filament v5

Build a Multi-Tenant SaaS Admin Panel with Filament v5

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, the HasTenants interface on User, and a team() relationship on each tenant-owned model.
  • Laravel’s unique and exists rules ignore tenant scopes. Use scopedUnique() and scopedExists() 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

ApproachHow it worksFilament fit
Single database, team_id columnEvery row belongs to a tenantNative. This is what ->tenant() does
Database per tenantSwitch connection per requestPossible with packages like stancl/tenancy, but outside Filament’s tenancy feature
Separate app per customerOne deployment eachNo 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 --generate

With no extra code, the Customer resource:

  • lists only the current team’s customers,
  • resolves /app/acme/customers/15/edit only if customer 15 belongs to Acme,
  • fills in team_id automatically 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 scope

The 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_id from 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() replace unique() / 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



Related on The Web Tier: Filament + Stripe subscription billing · Roles and permissions in Filament

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy