Combining Laravel Jetstream Teams with Spatie Permission cover image

Combining Laravel Jetstream Teams with Spatie Permission

Tim Geisendörfer • January 9, 2022

laravel jetstream spatie-permission

When using Laravel Jetstream Teams the default Jetstream roles may be to unflexible for some needs. When you need more features regarding your team role management you can combine Laravel Jetstream with the popular Spatie Permission package.

Getting started

I assume that you have a fresh Laravel Jetstream Installation with the teams feature enabled if you dont know how to do that please follow the Laravel Jetstream Documentation.

In this example im using the Livewire stack but since I am focusing on backend stuff this tutorial should equally work with the Inertia stack.

Installing the Spatie Permission Package

We start installing the package via composer.

composer require spatie/laravel-permission

Now we publish the migration and the permission.php config file with:

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"

Now add the Spatie Permission HasRoles trait to your User model.

use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasApiTokens;
    use HasFactory;
    use HasProfilePhoto;
    use HasTeams;
    use Notifiable;
    use TwoFactorAuthenticatable;
    use HasRoles;
}

Now we need to enable the team permissions feature. Mainly we are following the Spatie documentation with some Jetstream customization.

Enable the teams permission configuration:

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

Now we need to add a middleware which applies the users current team scope to the Spatie Permission package.

php artisan make:middleware TeamsPermission

In the TeamsPermission middleware we have to extract the users current team id and apply it to the Spatie Permissions Team scope.

use Closure;
use Illuminate\Http\Request;
use Spatie\Permission\PermissionRegistrar;

class TeamsPermission
{
    public function handle(Request $request, Closure $next)
    {
        if (!empty($user = auth()->user()) && !empty($user->current_team_id)) {
            app(PermissionRegistrar::class)->setPermissionsTeamId($user->current_team_id);
        }

        return $next($request);
    }
}

This middleware has to be applied on every HTTP request coming into our application. To do that we have to extend the Kernel.php file.

        //app/Http/Kernel.php
        'web' => [
            ...
            \App\Http\Middleware\TeamsPermission::class
        ],

In my case I don't want this middleware to be applied on api requests, so I only added it to the "web" middleware group. If you want it to be applied on all requests you can add it to the global middleware stack.

Now the general Setup is done we can start playing around with our new Team specific roles and permissions.

Setting up seeders for demo data

To play around with our new codebase we need some demo data. Lets setup some quick and dirty seeders.

php artisan make:seeder PermissionSeeder

Seed Roles and Permissions

//Database\Seeders\PermissionSeeder.php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class PermissionSeeder extends Seeder
{
    public function run(): void
    {
        //Some initially role configuration
        $roles = [
            'Admin' => [
                'view posts',
                'create posts',
                'update posts',
                'delete posts',
            ],
            'Editor' => [
                'view posts',
                'create posts',
                'update posts'
            ],
            'Member' => [
                'view posts'
            ]
        ];

        collect($roles)->each(function ($permissions, $role) {
            $role = Role::findOrCreate($role);
            collect($permissions)->each(function ($permission) use ($role) {
                $role->permissions()->save(Permission::findOrCreate($permission));
            });
        });
    }
}

Seed a Demo User with a personal and 3 other associated Team memberships

//Database\Seeders\DatabaseSeeder.php
namespace Database\Seeders;

use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([PermissionSeeder::class]);

        User::factory(['email' => '[email protected]'])
            ->withPersonalTeam()
            ->hasAttached(Team::factory()->count(3))
            ->create();

    }
}

Managing Roles in the Frontend

I assume that you want to show the users current roles in the frontend and you want to have kind of admin users who can manage the team member roles. Since the Jetstream codebase will change in the future we won't extend the default Jetstream manage roles / team memberships actions for that.

So I will provide some code examples how to use multiple roles within Jetstream teams.

Since every frontend request coming to our application is served through our \App\Http\Middleware\TeamsPermission:: class middleware the Spatie Permissions package is always aware of the users current team scope. So every role or permission request which got called in the frontend is scoped to the users current team id.

So we can use the Spatie Permission package like we normally would do. Everything just works as expected!

//Get the user roles
auth()->user->roles()->get();
//Get the user permissions
auth()->user->permissions()->get();
//Give permission to a user
$role->givePermissionTo($permission);
//assign Roles
$permission->assignRole($role);

Managing roles per API / Backend

Let's assume we want to manage your applications teams per api. Typically, you want to do this when another external system or microservice wants to update Team Members.

To do this we need an invokable api controller who can update teams.

php artisan make:controller Api/UpdateTeamController -i

For simplicity, we won't create Request and Resource classes for this example.

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Spatie\Permission\PermissionRegistrar;

class UpdateTeamController extends Controller
{
    public function __invoke(Request $request, Team $team): Team
    {
        $data = $request->validate([
            'name' => ['string', 'required', 'max:255'],
            'memberships' => ['array', 'sometimes'],
            'memberships.*.user_id' => ['required', 'integer', 'exists:users,id'],
            'memberships.*.roles' => ['present', 'array'],
            'memberships.*.roles.*' => ['string', Rule::exists('roles', 'name')->where('guard_name', 'web')]
        ]);

        DB::transaction(function () use (&$team, $data, $request) {
            //Update team fields
            $team->forceFill($request->except(['memberships']));
            $team->saveOrFail();

            if ($request->has('memberships')) {
                $memberships = collect($data['memberships']);
                //sync team memberships
                $team->users()->sync($memberships->map(function ($membership) {
                    return $membership['user_id'];
                })->toArray());
                //set Spatie permissions team foreign id scope
                app(PermissionRegistrar::class)->setPermissionsTeamId($team->id);
                //sync roles
                $memberships->each(function ($membership) {
                    $user = User::findOrFail($membership['user_id']);
                    $user->syncRoles($membership['roles']);
                });
            }

        });
        return $team;
    }
}

With this code the controller can update team names, team memberships and the team user roles. Let's break down this code into simple steps:

We assume that we are receiving a list of users with their corresponding role for this particular team. This information is stored in the memberships array. After validating the request input we can start to update our database state.

With DB::transaction() every database request executed within the closure is packed within a database commit. If an Exception is thrown in the closure the Laravel Framework rollbacks all our changes. When everything runs fine it auto commits our changes to the database.

Within the transaction we start updating the team fields. Nothing special about that. After that we are processing fields from the memberships array. First we start syncing all of the provided user ids to the Jetstream team using the sync() method on the belongsToMany() memberships relation.

Now we are getting to the Spatie Laravel Permission specific stuff:

At first, we have to tell the Permission package in which Team scope we are app(PermissionRegistrar::class)->setPermissionsTeamId($team->id);. So all permissions or roles which are updated or selected after this line of code are always scoped to the current Jetstream team. In this case we want it to be the particular team provided per model route binding to our controller. But sometimes you want to update Permissions for different teams in a loop then you have to set this scope everytime when you're changing the corresponding team scope.

After changing the team scope we can update the Roles like we normally would do.

At the end of our Controller Action we return the updated Team object.

Final words

We have learned how to combine Laravel Jetstream Teams with the awesome Spatie Permission package. Thanks to the awesome documentation of these packages, we are mostly following the official Docs. But I think this article is going to be helpful when you start tinkering with these packages.

All the source Code from this article is available on GitHub.

Since this is one of my first Blog articles of all time I would be honoured if you can provide me feedback - no matter if it is positive or negative. Feedback is always helpful.

You can contact me on Twitter @djgeisi or per mail at [email protected].

Do you need help with your next project?

Unleash the full potential of your business, and schedule a no-obligation consultation with our team of experts now!