Originally published at hafiz.dev
Every roles system starts the same way. You add a permission called edit articles, give it to the editor role, and ship it. It works for a year.
Then a client asks for something small. Sarah should be able to edit this one article, the one she wrote with the legal team, and nothing else. Your permission is a string. Strings don't know about article 4,182.
That request is the moment the decision gets made for you, and it decides which package you should have picked. So here's the question that separates the two, before any code.
The question that decides it
Are your permissions about kinds of things, or about particular things?
"Editors can edit articles" is a kind of thing. The permission is a name, the same name for every article in the table, and a role carries it. That's spatie/laravel-permission.
"Sarah can edit article 4,182" is a particular thing. The permission points at one row. That's silber/bouncer.
Both register themselves on Laravel's Gate, so $user->can(...) works either way and your controllers look identical. The difference sits underneath, in what the database can express.
Spatie Permission: permissions are names
Spatie's model is roles and permissions as strings, stored in tables, editable at runtime. You add a trait and you're running:
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}
Then you create the data:
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
$editor = Role::create(['name' => 'editor']);
$editor->givePermissionTo(Permission::create(['name' => 'edit articles']));
$user->assignRole('editor');
$user->can('edit articles'); // true
That is most of the package. There's a query scope for finding users by permission, syncPermissions and syncRoles for bulk changes, and getAllPermissions for showing someone what they hold.
The adoption gap matters more than people admit. Spatie's package has over 110 million installs on Packagist. Every Laravel developer you hire has used it, every AI assistant knows its API, and every question you'll ever have is already answered on Stack Overflow. That is worth real money on a team.
The ceiling. Permissions are global names. The package does not model a permission attached to one specific row, and this is a documented limitation rather than an oversight, asked and answered on the issue tracker years ago. Spatie's own docs point you at Laravel policies for row-level rules, which is the right answer and also the moment you notice the package stopped helping.
So Sarah and article 4,182 become your problem, solved with a policy and a pivot table you build yourself.
Bouncer: abilities can point at a row
Bouncer calls them abilities, and an ability can be granted against a class or against one model:
Bouncer::allow($user)->to('edit', Post::class); // any post
Bouncer::allow($user)->to('edit', $post); // this post
That second line is the whole reason Bouncer exists. Sarah and article 4,182 take one call and no schema of your own.
Ownership is built in, which removes a policy method most apps write by hand:
Bouncer::allow($user)->toOwn(Post::class);
Bouncer::allow($user)->toOwn(Post::class)->to(['view', 'update']);
Bouncer::ownedVia(Post::class, 'created_by');
And there's a capability Spatie has no answer for. Bouncer can forbid, which beats any allow that would otherwise apply:
Bouncer::allow('admin')->everything();
Bouncer::forbid('admin')->toManage(User::class);
Bouncer::forbid('banned')->everything();
Bouncer::assign('banned')->to($user);
Suspending an account without stripping and later rebuilding someone's roles is a real operational need, and forbidding is the clean way to do it. Note that unforbid only removes the block. It does not grant the ability back, so the underlying allow has to still be there.
The cost is adoption. Bouncer sits around 5 million installs against Spatie's 110 million plus, so you'll find fewer examples, fewer colleagues who know it, and thinner coverage when you ask an AI assistant about it. It is maintained, with v1.0.4 released in March 2026 supporting Laravel 11 through 13, but it moves at a slower pace.
Policies still sit on top of both
Neither package replaces policies, and the mistake I see most often is treating them as if it did. Permission checks scattered through controllers and Blade files are the same problem as business logic scattered through controllers, and they cause the same trouble later.
Put the package check inside the policy:
public function update(User $user, Post $post): bool
{
return $user->can('edit articles') && $post->status !== 'locked';
}
Your controller then calls Gate::authorize('update', $post) and knows nothing about which package you chose. Swap Spatie for Bouncer later and the controllers don't change. Spatie's own documentation recommends exactly this, describing policies as the place where your application logic combines with your permission rules.
I've covered how policies and gates work in detail in the authorization guide, including the before() super-admin shortcut and rich response objects, so I won't repeat that ground here.
Multi-tenancy is where they diverge again
Both handle tenants, differently enough that it should influence your choice.
Spatie has a teams mode you turn on in config before running migrations, then set the active team per request:
// config/permission.php
'teams' => true,
setPermissionsTeamId(session('team_id'));
Two details cause problems. That middleware has to run before SubstituteBindings or you'll get 404 responses instead of 403s, which is a confusing afternoon. Spatie's documentation still shows this being set in app/Http/Kernel.php, and that file hasn't existed since Laravel 11. On Laravel 13 the priority goes in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->prependToPriorityList(
before: \Illuminate\Routing\Middleware\SubstituteBindings::class,
prepend: \App\Http\Middleware\TeamsPermission::class,
);
})
And when you switch teams inside a single request you must clear the loaded relations, or you'll read the previous team's answers:
setPermissionsTeamId($newTeamId);
$user->unsetRelation('roles')->unsetRelation('permissions');
Bouncer scopes everything through one call instead:
Bouncer::scope()->to($tenantId);
Cleaner to read, and it scopes abilities and roles together. If you're deciding tenancy strategy at the same time, the tenancy comparison covers the layer below this one, and Filament's tenancy implementation shows how it plays out in an admin panel.
The cache bug that catches both
Permission checks run on nearly every request, so both packages cache. Both then hand you the same class of production bug, where you change a permission and nothing happens.
Spatie caches the role and permission registry for 24 hours by default. The helper methods reset it for you, but editing rows directly in the database does not, and neither does a deploy. When permissions look stale:
php artisan permission:cache-reset
Bouncer caches per request by default, which is safe. Turn on cross-request caching for speed and you take on invalidation yourself:
Bouncer::cache(); // faster
Bouncer::refreshFor($user); // now your job, after every change
With a scoped Bouncer, refreshFor only clears the current tenant's cache, so a user in three tenants needs three calls. That one has cost me an evening.
Whichever you pick, log permission changes. An audit trail turns "the permission isn't working" into a question you can answer, and activity logging is a twenty-minute install.
What I'd actually choose
Spatie, for most applications. Coarse permissions cover far more real systems than people expect, the ecosystem advantage is large and compounding, and you can express the occasional row-level rule in a policy with a pivot table when it comes up.
Bouncer when per-row permissions are the product rather than an exception. Shared documents, per-project collaborators, anything where users grant each other access to specific records. If your app has a share button, that's Bouncer.
Neither, if you have three roles that never change. A role column and a policy will outlive both packages, and you can add one later when the requirements actually arrive. Reaching for a permissions package on day one is a common way to carry two tables and a cache layer you never needed.
FAQ
Can I migrate from Spatie to Bouncer later?
Yes, and it's less painful than it sounds if your checks live in policies. Both register on the Gate, so can() calls and @can directives keep working. You rewrite the seeding and admin screens, migrate the data, and swap the calls inside your policy methods. If permission checks are scattered across controllers and Blade files instead, the migration touches every one of them, which is the strongest practical argument for the policy layer.
Do I still need policies if I use one of these packages?
Yes. Packages answer what a user holds. Policies answer whether an action is allowed right now, which usually combines the permission with state, like a locked post, a closed invoice or an expired subscription. Skipping policies means encoding that state logic into permission names, and you'll end up with strings like edit unlocked articles.
Which one for a multi-tenant SaaS?
Either works. Spatie's teams mode needs the config flag set before you migrate, so decide early, and be careful with the middleware ordering. Bouncer's scopes read more cleanly and cover abilities and roles in one call. If your tenants need to grant each other access to individual records, that pushes toward Bouncer regardless of tenancy.
Is Bouncer still maintained?
Yes. Version 1.0.4 shipped in March 2026 with support for Laravel 11, 12 and 13. It moves slower than Spatie's package and has a fraction of the installs, so judge it on release cadence and open issues rather than assuming abandonment. The smaller community is a real cost, just not a correctness one.
The thing worth remembering
The choice comes down to whether your permissions name kinds of things or particular things. That answer comes from the product, not from the code.
Get that answer from whoever writes the requirements, before you install anything. If nobody can tell you whether users will ever share single records with each other, you don't have enough information to choose, and the safe move is a role column until you do.
Top comments (0)