Filament v4 Panel Multi-Tenancy with Teams — From Zero to Scoped Resources

Add team-based multi-tenancy to a Filament v4 panel using HasTenants, ->tenant(slug), and isScopedToTenant — auto-scoped resources, no extra packages.

Steven Richardson
Steven Richardson
· 11 min read

Every Filament SaaS hits the same fork in the road: bolt on stancl/tenancy and split databases, or hand-roll a team_id foreign key everywhere and pray nobody forgets a where() clause. Filament v4 ships a third option — first-party panel-level tenancy that auto-scopes resources to a Team model and gives you a tenant switcher, registration page, and profile page in three method calls on the panel provider.

This is the build I keep reaching for when a client wants /{team}/posts without setting a separate-database fire to put out. If you're new to the panel itself, the zero-to-production Filament v4 walkthrough covers the install and resource basics — this article picks up from there and adds tenancy.

Create the Team model and pivot table#

Start with the simplest possible team model and a team_user pivot. Run php artisan make:model Team -mf to scaffold the model, migration, and factory, then run php artisan make:migration create_team_user_table for the pivot. The team needs a name and a unique slug — the slug becomes the URL segment in /{team:slug}/posts, so collisions break routing.

// database/migrations/xxxx_create_teams_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('teams', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->timestamps();
        });
    }
};
// database/migrations/xxxx_create_team_user_table.php
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'); // member, admin, owner
    $table->timestamps();

    $table->unique(['team_id', 'user_id']);
});
// app/Models/Team.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Team extends Model
{
    protected $fillable = ['name', 'slug'];

    public function members(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->withPivot('role')
            ->withTimestamps();
    }

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

The posts() relationship matters. Filament's auto-scoper uses Laravel's relationship guessing to find a tenant's records — App\Models\Post resolves to a posts() method on the tenant. Skip it and Filament falls back to manual ownership resolution. The pivot pattern itself is the same one I covered in Filament v4 many-to-many relation managers — you'll reuse that knowledge when you build a Members management UI for the team profile.

Implement HasTenants on the User model#

The User model is where Filament asks "which teams can this user see?" by calling getTenants(Panel $panel). Add the Filament\Models\Contracts\HasTenants contract, implement the method to return the teams collection, and add canAccessTenant() to enforce the access check on every page load.

// app/Models/User.php
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();
    }

    // Which tenants this user can choose from in the tenant menu.
    public function getTenants(Panel $panel): Collection
    {
        return $this->teams;
    }

    // Hard authorisation gate — called on every tenanted request.
    public function canAccessTenant(Model $tenant): bool
    {
        return $this->teams()->whereKey($tenant)->exists();
    }

    public function canAccessPanel(Panel $panel): bool
    {
        return true; // Replace with your own admin check if needed.
    }
}

canAccessTenant() is the one that catches the "user manually edits the URL slug" case. Filament will call it after resolving the tenant from the URL and 403 anyone whose membership query returns false. Do not skip it.

Enable tenancy on the panel provider with ->tenant(Team::class)#

Open app/Providers/Filament/AdminPanelProvider.php and chain ->tenant(Team::class, slugAttribute: 'slug'). While you're there, register the tenant registration page, the tenant profile page, and the tenant menu — these three calls hand you a complete onboarding flow that you'd otherwise build by hand.

// app/Providers/Filament/AdminPanelProvider.php
namespace App\Providers\Filament;

use App\Filament\Pages\Tenancy\EditTeamProfile;
use App\Filament\Pages\Tenancy\RegisterTeam;
use App\Models\Team;
use Filament\Panel;
use Filament\PanelProvider;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('admin')
            ->path('admin')
            ->login()
            ->tenant(Team::class, slugAttribute: 'slug')
            ->tenantRegistration(RegisterTeam::class)
            ->tenantProfile(EditTeamProfile::class)
            ->tenantMenu()
            ->discoverResources(
                in: app_path('Filament/Resources'),
                for: 'App\\Filament\\Resources'
            )
            ->discoverPages(
                in: app_path('Filament/Pages'),
                for: 'App\\Filament\\Pages'
            );
    }
}

URLs are now shaped like /admin/{tenant:slug}/posts. The {tenant:slug} segment is resolved against the slug column you defined on the Team migration. Bookmark a route, log in as a user with no team membership, and you'll land on the registration page automatically — Filament keeps redirecting until the user owns at least one tenant.

Add the team_id column and scope models to the tenant#

Every model that should be tenant-scoped needs a team_id foreign key and a team() belongsTo relationship. That's it — Filament v4 does the scoping at the resource layer using the relationships, not via a model trait. No BelongsToTenant trait is required.

// database/migrations/xxxx_add_team_id_to_posts_table.php
Schema::table('posts', function (Blueprint $table) {
    $table->foreignId('team_id')
        ->after('id')
        ->constrained()
        ->cascadeOnDelete();
});
// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Post extends Model
{
    protected $fillable = ['title', 'body'];

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }
}

The PostResource needs zero changes. When a user visits /admin/acme/posts, Filament resolves the acme team, then automatically:

  • Filters every query through where team_id = {current_team_id}.
  • Sets team_id on records created via the resource.
  • Blocks access to records belonging to other tenants in edit/view pages.

If your foreign key isn't team_id — say it's organization_id on a Post model with an organization() relationship — tell Filament about it once on the panel:

$panel->tenant(
    model: Team::class,
    slugAttribute: 'slug',
    ownershipRelationship: 'organization'
);

Or scope it per-resource via protected static ?string $tenantOwnershipRelationshipName = 'organization'; if only one resource diverges from convention.

Opt shared resources out with isScopedToTenant = false#

Not every resource belongs to a tenant. Subscription plans, system settings, feature flags — these are global to the application. Mark them with a static boolean on the resource and Filament leaves them un-scoped.

// app/Filament/Resources/PlanResource.php
namespace App\Filament\Resources;

use App\Models\Plan;
use Filament\Resources\Resource;

class PlanResource extends Resource
{
    protected static ?string $model = Plan::class;

    // Shared across all tenants — do not scope.
    protected static bool $isScopedToTenant = false;

    // ... rest of the resource
}

There is a sharp edge here that bit me on a recent build: if two resources point at the same model and only one is opt-out, you'll get a LogicException complaining about inconsistent scoping. Pick one resource per model, scope it consistently, and reach for a separate panel (more on that below) if you genuinely need both views.

Add the tenant registration and profile pages#

Filament needs a full-page Livewire component to handle "create a new team" and another to handle "edit this team's name and slug." Both extend Filament's base pages and use the schema-based form builder. Run php artisan make:filament-page Tenancy/RegisterTeam and php artisan make:filament-page Tenancy/EditTeamProfile, then replace the bodies.

// 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\Schema;
use Illuminate\Support\Str;

class RegisterTeam extends RegisterTenant
{
    public static function getLabel(): string
    {
        return 'Register team';
    }

    public function form(Schema $schema): Schema
    {
        return $schema
            ->components([
                TextInput::make('name')
                    ->required()
                    ->live(onBlur: true)
                    ->afterStateUpdated(fn ($state, callable $set) =>
                        $set('slug', Str::slug($state))
                    ),
                TextInput::make('slug')
                    ->required()
                    ->unique('teams', 'slug')
                    ->rules(['alpha_dash']),
            ]);
    }

    protected function handleRegistration(array $data): Team
    {
        $team = Team::create($data);

        $team->members()->attach(auth()->id(), ['role' => 'owner']);

        return $team;
    }
}
// app/Filament/Pages/Tenancy/EditTeamProfile.php
namespace App\Filament\Pages\Tenancy;

use Filament\Forms\Components\TextInput;
use Filament\Pages\Tenancy\EditTenantProfile;
use Filament\Schemas\Schema;

class EditTeamProfile extends EditTenantProfile
{
    public static function getLabel(): string
    {
        return 'Team settings';
    }

    public function form(Schema $schema): Schema
    {
        return $schema
            ->components([
                TextInput::make('name')->required(),
                TextInput::make('slug')
                    ->required()
                    ->unique('teams', 'slug', ignoreRecord: true)
                    ->rules(['alpha_dash']),
            ]);
    }
}

The attach() call inside handleRegistration() is what makes the new team appear in the tenant switcher immediately. Skip it and the user creates a team they can't access, then gets redirected back to the registration page in a confused loop. If you want a confirmation step before destructive actions in this flow, the patterns in custom Filament v4 actions with confirmation modals translate directly to tenant settings pages.

Gotchas and Edge Cases#

A few traps that aren't obvious from the docs:

Slug uniqueness across deletes. If you soft-delete teams, your unique('teams', 'slug') rule will refuse to reuse a slug from a deleted team. Either hard-delete on tenant offboarding or scope the unique rule with where('deleted_at', null).

Auto-scoping requires the relationship to exist. If Team doesn't have a posts() relationship and you don't set ownershipRelationship, Filament will throw a RelationNotFoundException the moment you visit the resource. The error message is clear, but the fix is non-obvious if you're convinced you've configured everything.

Two-resource, one-model LogicException. Filament 4 throws if you try to have two resources for the same model where one is scoped and the other isn't (issue #18561). Solution: one resource per model per panel, or a second panel for the shared view.

Tenant switching loses unsaved form state. This is by design — the tenant menu reloads the panel — but it surprises users who half-fill a form and switch teams. Add an unsavedChangesAlerts() modifier on long forms.

Superadmin patterns need a separate panel. A "see all data across tenants" admin view doesn't fit inside a tenanted panel. The cleanest pattern is a second SuperadminPanelProvider registered at path('superadmin') with no ->tenant() call. Both panels live in the same app, both use the same User model, and tenancy stays a per-panel concern.

Authorization stacks on top. canAccessTenant() gates which tenant, but policies still gate which records inside that tenant. If you use Filament Shield or Spatie Permissions, scope role-checks against the current tenant via Filament::getTenant() inside your policy methods. If you've already wired Laravel's native team scaffolding, the Laravel 13 multi-tenancy with teams guide covers the data layer and team invitations that Filament's panel tenancy intentionally leaves to you.

Wrapping Up#

Filament v4's panel tenancy hits the 80% of SaaS tenancy that doesn't need a separate database — and it does it in maybe sixty lines of code spread across a model, a panel provider, and two pages. Get your team registration flow shipped first, prove the team_id scoping works for your real resources, then layer billing and roles on top of that foundation.

The natural next stop is metering: when teams are real, billing them per usage is the obvious follow-up — Stripe meters with Laravel Cashier plugs in cleanly to the tenant model you just built. If your team profile page needs CSV import for bulk member invitations, the Filament v4 import & export actions tutorial drops in without further config.

FAQ#

How do I enable multi-tenancy in Filament v4?

Call ->tenant(Team::class, slugAttribute: 'slug') on your panel provider's panel configuration, then implement the Filament\Models\Contracts\HasTenants interface on your User model with getTenants() and canAccessTenant() methods. Every resource in that panel will then auto-scope to the current team based on Laravel's relationship guessing. You also need a team_id foreign key on each model you want scoped and a matching team() belongsTo relationship.

What is the difference between Filament tenancy and Laravel tenancy packages like stancl/tenancy?

Filament's panel tenancy is a single-database, shared-schema, application-layer feature — it adds team_id filtering to Eloquent queries inside the Filament panel only. stancl/tenancy is a database-level isolation package: separate databases or separate schemas per tenant, with global query bootstrapping. Filament tenancy is the right call when one database is fine and you only need scoping in the admin panel. stancl/tenancy is the right call when you have a hard data-isolation requirement, a per-tenant database, or a domain-per-tenant model.

How does Filament automatically scope resources to the current tenant?

Filament inspects two relationships: an "ownership" relationship on the resource model (defaulting to team() when the tenant model is Team) and a reverse relationship on the tenant model (defaulting to posts() for a Post resource). It then attaches a where team_id = {current_tenant_id} clause to every resource query, sets the foreign key on record creation, and validates that records in edit and view pages belong to the current tenant. If your relationship names diverge from Laravel conventions, pass ownershipRelationship: 'organization' to the ->tenant() call.

How do I have a resource that is NOT scoped to a tenant in a tenanted Filament panel?

Set protected static bool $isScopedToTenant = false on the resource class. Filament will then skip the tenant scoping for that resource, and queries will run against the full table. Use this for global models like subscription plans, system-wide settings, or feature flags. Be careful not to point two resources at the same model where one is scoped and the other isn't — Filament 4 throws a LogicException in that case.

Can a single Filament app have both tenanted and non-tenanted panels?

Yes — tenancy is a panel-level configuration, not an application-level one. Register a AdminPanelProvider with ->tenant(Team::class) for the per-team admin view at /admin/{team}, and a separate SuperadminPanelProvider without ->tenant() at /superadmin. Both panel providers live in bootstrap/providers.php, both share the User model, and Filament keeps their routing, sidebars, and resources completely separate. This is the cleanest pattern for a "tenant admin sees their team, superadmin sees everything" SaaS shape.

How do I add billing or roles to a Filament tenant?

For billing, Filament ships a tenantBillingProvider() hook that integrates Laravel Cashier — register a Spark or Cashier provider against the tenant model and Filament adds a "Billing" link to the tenant menu. For roles, use the role column on your team_user pivot table to drive Spatie Permission scopes, or wire Filament Shield against the tenant by checking Filament::getTenant() inside your policies. Authorization sits on top of tenancy, not beside it: canAccessTenant() decides which team a user enters, then your policies decide what they can do inside that team.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.