Skip to content
Go back

Removing Spatie Permissions for a Single Laravel Gate

Growth Method had spatie/laravel-permission installed from day one. It is a brilliant package, but I was only ever using it to express one rule: managers can change settings, members can’t.

Five database tables, a config file, a trait, and 21 setPermissionsTeamId() calls scattered across the codebase, all to express a single boolean. A lot of rope for one knot.

Here’s how I removed it.

The starting question

I asked:

I’d like to consider removing spatie permissions from the codebase. I’m open to changing the way the app is configured for things to remain as simple and secure as possible. there are only 2 permissions (manager and member). what i’d like is that everyone can see and do everything, with the exception of everything under settings, where members only have read access, only managers can change. what do you think?

The audit came back with a surprise. Spatie was doing two jobs, not one. The model_has_roles table stored which user had which role and which user belonged to which team. The User::teams() relationship literally read from it:

// Before
public function teams(): BelongsToMany
{
    return $this->belongsToMany(Team::class, 'model_has_roles', 'model_id', 'team_id')
        ->where('model_type', self::class);
}

That overload made the package feel load-bearing when it was really doing two simple things in one place.

What would Taylor do?

I followed up:

what is the overall impact of this change on code complexity / amount of code? are we adding or removing code overall? what would taylor otwell do here - keep spatie or remove it, and why?

Spatie Permissions is a great package and Taylor has praised it publicly many times. His philosophy is use the simplest tool that solves the problem. Two signals pointed at removal:

  1. Laravel’s own authorization docs say Gates are the right tool for simple, app-wide rules. Reach for a package when you need fine-grained, user-editable, database-driven permissions — admin UIs where non-developers create roles at runtime. I had neither. I had one hardcoded rule that would never change without a code deploy.

  2. Jetstream itself ships with team roles built in. Taylor designed that API specifically because team-scoped roles are common enough to deserve first-class support — and simple enough not to need an external package.

The “Taylor test” question I kept coming back to: if I were starting Growth Method tomorrow, knowing I’d only ever have manager and member, would I run composer require spatie/laravel-permission? Almost certainly not. I’d add a role column to a team_user pivot and write a Gate. The only reason Spatie was there was historical inertia.

Before vs after

BeforeAfter
Database tables5 (roles, permissions, model_has_roles, model_has_permissions, role_has_permissions)1 (team_user)
Composer dependenciesspatie/laravel-permissionnone
Authorization rules4 permissions, 2 roles, a config file, a trait1 Gate (manage-team)
Helper methods on UserhasRole(), hasPermissionTo(), assignRole(), syncRoles()isManagerOf(), isManagerOfCurrentTeam(), setRoleForTeam(), roleOnTeam()
setPermissionsTeamId() calls21+ scattered across actions, middleware, tests0
Mental modelRoles + permissions + team scoping + a trait + a config”Manager can write settings, member can’t”

The plan: three phases, not one

The riskiest version of this change is to do it all in a single deploy. The safest version is three.

I broke it into:

Each phase gets its own commit. Each commit can be deployed and rolled back independently. If anything goes wrong in Phase 2, I can revert and the app keeps working because Spatie is still right there.

Phase 1 — the new foundation

The new pivot is one table. Three columns and a uniqueness constraint:

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');
    $table->timestamps();

    $table->unique(['team_id', 'user_id']);
    $table->index('user_id');
});

The cascadeOnDelete() on team_id is doing real work later — it means when a team is hard-deleted, the membership rows clean themselves up without any application code.

A second migration backfilled the new pivot from Spatie’s existing data:

DB::table('model_has_roles')
    ->join('roles', 'model_has_roles.role_id', '=', 'roles.id')
    ->where('model_has_roles.model_type', 'App\\Models\\User')
    ->select(['model_has_roles.team_id', 'model_has_roles.model_id as user_id', 'roles.name as role_name'])
    ->orderBy('model_has_roles.team_id')
    ->orderBy('model_has_roles.model_id')
    ->chunk(500, function ($rows) {
        // upsert into team_user...
    });

Then the User model picked up four small helpers:

public function isManagerOf(Team|int $team): bool
{
    $teamId = $team instanceof Team ? $team->id : $team;

    return $this->managerCache[$teamId] ??= $this->teams()
        ->wherePivot('team_id', $teamId)
        ->wherePivot('role', 'manager')
        ->exists();
}

public function isManagerOfCurrentTeam(): bool
{
    return $this->current_team_id
        ? $this->isManagerOf($this->current_team_id)
        : false;
}

The managerCache property is a tiny per-instance memo, so a Livewire component that calls @can('manage-team') five times in one render only hits the database once.

The whole authorization system collapses to this single line in AppServiceProvider::boot():

Gate::define(
    'manage-team',
    fn (User $user, ?Team $team = null) => $team
        ? $user->isManagerOf($team)
        : $user->isManagerOfCurrentTeam(),
);

That’s the entire rule.

Phase 2 — switching the reads

Every place in the app that asked Spatie a question now asks the Gate instead. The change in any given file looks like this:

// Before
$this->authorize(Permissions::MANAGE_SETTINGS);

// After
$this->authorize('manage-team');

And in blade:

{{-- Before --}}
@can(\App\Models\Permissions::MANAGE_SETTINGS)

{{-- After --}}
@can('manage-team')

The TeamsPermission middleware lost half its code. It used to call setPermissionsTeamId(), eager-load Spatie roles, and check hasRole('Manager') && hasRole('Member'). After Phase 2 it does one thing:

$belongsToCurrentTeam = $user->current_team_id
    && $user->teams()->whereKey($user->current_team_id)->exists();

Because of the cascade on the pivot, that single exists() check covers both “team was deleted” and “user was removed from the team.”

A “login as” detour

Mid-conversation I asked a forward-looking question:

if I wanted to add ‘login as’ functionality later, so that I (superadmin) can login as any user, does that have any impact on what we’re doing here?

No, and that’s one of the things I like about a Gate-based approach. Superadmin is a different axis from team roles. Manager/member is a per-team role; superadmin is a global cross-team flag. Mixing them into one system is what gets messy.

When I add impersonation later, I’ll use a package like lab404/laravel-impersonate plus a single line in the Gate definition:

Gate::before(fn (User $user) => $user->is_super_admin ? true : null);

Gate::before runs before every gate check and short-circuits to true for superadmins. Standard Laravel pattern, zero impact on the rest of the authorization system.

Phase 3 — the actual removal

With every read switched over, Phase 3 was mostly a deletion. Out came the dual-writes, the HasRoles trait, the middleware aliases in bootstrap/app.php, the Permissions model, the UserPolicy, and the UpdateRolesAndPermissions command. Then composer remove spatie/laravel-permission did the rest.

Three cleanup actions (DeleteUser, DeleteTeam, PurgeTeam) had been poking at model_has_roles directly via raw queries. I rewrote them as idiomatic Eloquent — the pattern the Laravel docs have always told you to prefer:

// Before
DB::table('model_has_roles')
    ->where('model_id', $user->id)
    ->where('model_type', User::class)
    ->delete();

// After
$user->teams()->detach();

The numbers

Across the seven commits — the three phases plus their review-fix commits plus a small documentation update plus a couple of stale-test fixes — the diff comes out at:

The line count understates the win. The bigger change is in mental surface area. Before, anyone reading the code had to understand Spatie’s whole model — guards, teams, roles, permissions, the trait, the team-id session — just to answer “can this user click this button?” Now the answer is $user->isManagerOfCurrentTeam(). That’s it.

What the test suite said

Throughout all three phases, the full Pest suite kept passing — 213 tests green at every checkpoint, with the same two pre-existing failures that predated the conversation. Once the Spatie removal landed I went back and fixed those two as well, and the suite finished at 215 passed, 0 failed.

That’s the reassuring part. A change that touched 50+ files, ripped out a database integration, and rewrote every authorization check in the app landed without breaking a single test that wasn’t already broken.

When to make this call yourself

If you have an installed permissions package and you’re wondering whether you should remove it, ask yourself one question:

Would I install this package today, knowing what I now know about how I’m actually using it?

If the answer is yes, keep it — packages exist for a reason and reaching for one is often the right call. If the answer is no, the package is historical inertia, and removing it is almost always cheaper than you expect. The Laravel authorization docs are very clear that Gates are the right tool for simple app-wide rules. Trust them.

For Growth Method, the answer was no. One rule, one Gate, one pivot.


Back to top ↑