DEV Community

Cover image for Why I moved away from Spatie's team mode for multi-tenancy
yebor974 for Filament Mastery

Posted on • Originally published at filamentmastery.com on

Why I moved away from Spatie's team mode for multi-tenancy

Multi-tenancy almost always raises the question: where should tenant-specific roles actually live?

When I started building the Multi-Tenant Starter, I didn't begin with a TeamUser pivot table. I began with Spatie's own team's feature, since it's already there in laravel-permission and it seemed like the obvious starting point.

I'd even written about that exact approach here on Filament Mastery a while back: enabling teams => true in Spatie's config, scoping role assignments via setPermissionsTeamId() in a tenant middleware, the whole setup.

It didn't work out, and the reason why turned out to be more interesting than I expected.

The starting point

Spatie's laravel-permission package has built-in support for teams. Enable it, and every row in model_has_roles gets a team_idcolumn alongside the usual model_id and role_id. A role assignment becomes scoped to a team instead of being global, which sounds like exactly what multi-tenancy needs.

So that's where I started. A User belongs to a Team, and their role within that team gets stored as a Spatie role scoped to team_id.

Where it broke

In the Multi-Tenant Starter, a user can have the Member application role (which grants access to the member panel) before belonging to any team at all. Someone gets invited, registers, or signs up, and lands in the member panel before they've joined or created a team.

With Spatie's team mode active, every row in model_has_roles requires a team_id. Looking at Spatie's own migration, team_idbecomes part of the table's composite primary key:

$table->primary(
[$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], 'model_has_roles_role_model_type_primary'
);

Enter fullscreen mode Exit fullscreen mode

A composite primary key can't contain NULL on any of its columns, that's a standard SQL constraint, not something Spatie chose. Which is exactly why the column needs a default value to exist at all:

$table->unsignedBigInteger($columnNames['team_foreign_key'])->default('1');

Enter fullscreen mode Exit fullscreen mode

Without it, any role assignment made before a team_id is known would simply fail at the database level. With it, the insert succeeds, but a role assigned before the user has a team doesn't fail, it silently gets attached to whichever team has ID 1. Not an error, not an exception, just a role quietly pointing at a team the user has no actual relationship with. That's arguably worse than a hard failure: nothing crashes, nothing warns you, and the data is simply wrong until someone notices a user with permissions in a team they never joined.

I didn't want to work around that by special-casing team ID 1 or patching Spatie's migration to make team_id nullable, which the primary key wouldn't even allow anyway without removing the composite primary key entirely and replacing it with a unique index. So I stepped back from team mode entirely.

What I do instead

The full article is available on Filament Mastery for free members.

Top comments (2)

Collapse
 
topstar_ai profile image
Luis

Great write-up. Permission models that work well for a single application don't always scale cleanly to multi-tenant SaaS. Moving away from a generic teams abstraction in favor of tenant-aware authorization can make ownership, isolation, and maintenance much clearer.

I also like that you focused on the trade-offs instead of presenting a one-size-fits-all solution. Authorization is deeply tied to domain modeling, and choosing the right approach early can prevent a lot of complexity as the product grows. Thanks for sharing your experience!

Collapse
 
yebor974 profile image
yebor974 Filament Mastery

Thanks for your comment!
Authoriation and domain modeling being tightly coupled is exactly what made this tricky.
No universal answer here, just what worked for my specific case.