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:
-
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.
-
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
| Before | After | |
|---|---|---|
| Database tables | 5 (roles, permissions, model_has_roles, model_has_permissions, role_has_permissions) | 1 (team_user) |
| Composer dependencies | spatie/laravel-permission | none |
| Authorization rules | 4 permissions, 2 roles, a config file, a trait | 1 Gate (manage-team) |
Helper methods on User | hasRole(), hasPermissionTo(), assignRole(), syncRoles() | isManagerOf(), isManagerOfCurrentTeam(), setRoleForTeam(), roleOnTeam() |
setPermissionsTeamId() calls | 21+ scattered across actions, middleware, tests | 0 |
| Mental model | Roles + 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:
- Phase 1 — Additive. Add the new pivot, define the Gate, dual-write to both systems. Nothing reads from the new pivot yet. Production behaviour is unchanged.
- Phase 2 — Switch reads. Every authorization check, every blade template, every middleware now reads from the new system. Spatie is still installed and still being written to as a safety net.
- Phase 3 — Remove Spatie. Stop dual-writing, delete the trait, remove the package. The five Spatie database tables stay in place for one more week as an undo button, then get dropped in Phase 4.
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:
- −295 lines of code, net, across app code and tests
- 1 composer dependency removed
- 5 database tables collapsed to 1 pivot
- 21
setPermissionsTeamId()calls deleted, down to zero - 1 Gate, 4 helper methods, and the entire authorization system fits on one screen
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.