How I Used Spatie Laravel Permission to Manage Roles in a Multi Tenant SaaS Product

Software Engineer Dublin

Software Engineer Dublin

Engineer Built To Scale

Beyond The Code

What They Say

Let's Build

Message Received

Privacy Policy

Terms Of Use

Cookie Policy

Disclaimer

Latest Updates

Selected Work

Showcase

What We Offer

Industries We Serve

Beyond The Screen

Built Together

View View
Nben Malla
Nben Malla

Software Engineer

Nben Malla is a software engineer based in Dublin, Ireland, specializing in microservices architecture, legacy system modernization and full stack development.

With experience across FinTech and SaaS, he has delivered scalable backend systems for global clients using Go, Java, Python, Django and Laravel, collaborating with teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices and distributed systems, the focus has always been to understand the problem deeply, build it right and deliver software that lasts.

  • Read Article Read Article

    Tutorials 7 mins

    How I Used Spatie Laravel Permission to Manage Roles in a Multi Tenant SaaS Product

    Nben M. 03 Aug, 2026 7 mins

    How I Used Spatie Laravel Permission to Manage Roles in a Multi Tenant SaaS Product

    A customer support ticket taught me more about permission design than any documentation ever did. One of my tenants had granted a support agent role read only access to invoices, and somehow that same agent could edit invoices belonging to a completely different organization. The bug was not in the application logic anyone had written recently. It was baked into an assumption from the day I picked Spatie Laravel Permission as my authorization layer.

    That assumption was simple and wrong. I treated roles as global objects that just happened to be assigned to users who belonged to different tenants. In a single tenant product that assumption costs nothing. In a multi tenant SaaS product it means every role name is a shared resource, and two organizations editing what they think is their own role are actually editing the same database row.

    Fixing this properly meant rethinking how tenant boundaries intersect with authorization, not just patching the one ticket. Here is how that rework went, including the parts that took longer than expected.

    The Default Setup Is Not Tenant Safe

    Spatie Laravel Permission ships with a straightforward model. Roles and permissions live in their own tables, users get assigned roles, and roles get assigned permissions. None of this changes based on which organization a user belongs to unless you explicitly configure it that way.

    I had assumed that scoping roles by tenant was a matter of filtering queries at the application layer, checking a tenant id before rendering a role management screen. That approach looks fine in the admin panel but does nothing to protect the actual permission checks running throughout the application. A $user->can('edit invoices') call has no idea which tenant it should be scoped to unless you tell it.

    The real fix lives in the package's teams feature, which scopes every role and permission record to a foreign key of your choosing. I mapped that foreign key directly onto my tenant id column, which meant every role creation, assignment, and check now carries tenant context automatically instead of relying on me remembering to filter manually in every controller.

    Turning On Team Scoping

    Enabling this took one configuration change and one line of context setting per request. The configuration change tells Spatie which column represents your tenant boundary.

    php
    // config/permission.php
    'teams' => true,
    'team_foreign_key' => 'tenant_id',

    The context setting is what actually activates tenant awareness on a per request basis. Without it, every permission check falls back to a null team, which in my case meant it silently matched nothing rather than throwing a helpful error.

    php
    setPermissionsTeamId($request->user()->tenant_id);

    I place this call inside dedicated middleware rather than scattering it across controllers. Centralizing it means there is exactly one place in the codebase responsible for deciding which tenant's permissions apply to the current request, and exactly one place to check if something goes wrong.

    Middleware Ordering Matters More Than It Looks

    The middleware that sets the tenant context has to run before any authorization middleware, and I mean strictly before, not just registered earlier in a list that Laravel might reorder. I missed this the first time I wired it up, and the result was permission checks silently passing against the previous request's leftover context on the same worker.

    php
    class ScopePermissionsToTenant
    {
        public function handle(Request $request, Closure $next)
        {
            if ($user = $request->user()) {
                setPermissionsTeamId($user->tenant_id);
            }
    
            return $next($request);
        }
    }

    I now register this as the very first entry in the global middleware stack, ahead of anything related to authentication guards or route model binding. Anything that touches a permission check downstream needs the tenant context to already be correct, and there is no safe way to check for that at the point of failure.

    Giving Every Tenant Its Own Role Set

    Once team scoping was live, the next problem was making sure new tenants started with a usable set of roles instead of an empty permission table. I built this into the tenant creation flow so a new organization never has to configure authorization from a blank slate.

    php
    class SeedTenantRoles
    {
        public function handle(Tenant $tenant): void
        {
            setPermissionsTeamId($tenant->id);
    
            $owner = Role::create(['name' => 'owner']);
            $owner->givePermissionTo(Permission::all());
    
            $agent = Role::create(['name' => 'support agent']);
            $agent->givePermissionTo(['view invoices', 'view customers']);
        }
    }

    Because each role is created inside that tenant's team context, the word owner at one organization and the word owner at another point to entirely separate database rows. An organization can rename its support agent role, strip permissions from it, or add new ones, and none of that touches any other tenant's copy.

    This also meant my support tooling needed updating. A support engineer looking up a user's role across tenants had to be aware that the same role name could mean different things depending on which organization they were currently viewing, which is a small mental shift but an important one for anyone debugging access issues.

    The Bug That Started All of This

    The original support ticket traced back to a queued job, not a web request. My invoice export job ran permission checks to decide which line items a report should include, and that job had no concept of the tenant context because queued jobs do not pass through HTTP middleware.

    php
    class ExportInvoices implements ShouldQueue
    {
        public function __construct(
            public int $tenantId,
            public int $requestedById,
        ) {}
    
        public function handle(): void
        {
            setPermissionsTeamId($this->tenantId);
    
            $user = User::find($this->requestedById);
    
            if (! $user->can('view invoices')) {
                throw new UnauthorizedException('User cannot view invoices.');
            }
    
            // build and store export
        }
    }

    Before this fix, the job simply used whatever team context was left over from the last thing that ran on that worker process, which on a shared queue worker could belong to a completely different tenant. Explicitly passing the tenant id into the job and setting the context at the top of handle closed that gap permanently.

    I now treat this as a non negotiable pattern for any background job that performs an authorization check. The job constructor takes a tenant id as an explicit argument, and the first line of handle sets that context before touching anything else. No job in my codebase is allowed to assume context carries over from wherever it was dispatched.

    A Safety Net at the Model Layer

    Even with team scoping wired through requests and jobs correctly, I added a second layer of protection directly on the models holding tenant sensitive data. This was not meant to replace correct context handling, it was meant to catch the case where something upstream still gets it wrong.

    php
    class Invoice extends Model
    {
        protected static function booted()
        {
            static::addGlobalScope('tenant', function (Builder $query) {
                if ($tenantId = getPermissionsTeamId()) {
                    $query->where('tenant_id', $tenantId);
                }
            });
        }
    }

    This scope means that even if a permission check somehow evaluated against the wrong or missing tenant context, the underlying query returning invoice data would still be restricted correctly. It is a defense in depth measure, and I apply it to every model that holds data one tenant should never see belonging to another.

    What I Would Set Up Differently From Day One

    I would enable team scoping before writing a single controller, not after a support ticket forced the issue. Migrating existing role assignments to include a tenant id while the product had live customer data was the riskiest part of this entire rework, and it is entirely avoidable if the decision gets made early.

    I would also document the queued job pattern as a required convention in the codebase's contribution guide, not something a new engineer discovers by causing an incident. Authorization bugs in background jobs are quiet by nature, because nothing in a job failure looks like a security problem until someone traces it back far enough.

    Conclusion

    Spatie Laravel Permission handles roles and permissions well, but its defaults assume one tenant unless you explicitly tell it otherwise. The teams feature closes that gap, and it does so cleanly once the tenant context is set consistently across requests, jobs, and anywhere else authorization checks run.

    Every incident I traced back through this system had the same root cause. Somewhere in the code, a permission check ran without knowing which tenant it belonged to, and the system trusted that missing context instead of rejecting it. Getting that one assumption right is worth more than any amount of role and permission configuration built on top of it.