Permissions are rarely a global yes or no
This is me
Latest articles
- GDPR cookie consent in Laravel with Wirecookies (opens on another site)
- Localized routes in Laravel with Laralang (opens on another site)
- Collecting user feedback in Laravel with Wirebug (opens on another site)
- The same operation ends up in three places, and none of them agree
- Laravel makes you choose between readable templates and stable translation keys
- Model observers are the wrong home for derived fields
- A coding agent is a generalist, and your codebase is not general
- Translating a Laravel app is two problems, and only one of them is about words
- The tags table is simple until the fourth requirement arrives
Most permission packages model the same thing: a user has a permission, or does not. It is a row.
That is right for a lot of apps and wrong for most of the ones I build, where the answer to "can this user manage the office" is "which office?". Someone is a manager in Barcelona and an ordinary member in Madrid. Both staff and client accounts need permissions, and they are different models. And the list of permissions is a property of the application (it changes when the code changes) yet it lives in a table you populate with a seeder.
You can bend a global permission system into this shape. Everyone does, and the workaround is always the same: encode the scope into the handle. manage-office-17. manage-office-42.
It works for about a month. Then you want every user who can manage office 17 and you are doing a LIKE over permission names. Then a permission implies another and you have to expand the implication across every scope. Then you add a second scope type and the handles become manage-office-17-group-3. The scope is real data and you have stuffed it into a string, so nothing can query it.
The scope belongs on the grant
The fix is to make the scope a first-class part of the grant rather than part of the name:
$user->allow('manage-office', $office);
The handle stays manage-office forever, and where it applies is a polymorphic reference stored alongside it. That single change is what makes the rest possible: the scope is now a column, so the database can answer questions about it.
User::withPermission('manage-office', $office);
Every user who can manage that office, implications included, in one query. With scope-in-the-handle that question is not expressible.
And because the scope is a model reference rather than a magic string, an app can have several scope types at once (an office and a group) with the declaration saying which ones a given permission is even valid in:
Permission::create('manage-clients')
->for(User::class)
->on([Office::class, Group::class])
->implies('handle-clients');
Permissions are code, roles are data
The second decision splits the two things most packages store the same way.
The set of permissions an application has is not user data. It is a property of the code: a permission exists because there is a feature behind it, appears in the same commit as that feature, and disappears when the feature does. Storing it in a table means the source of truth for what your app can do lives outside your app, populated by a seeder that is only correct if someone remembered to update it.
So permissions are declared in a service provider, like routes:
Permission::create([
'manage-office' => 'Manage office',
])->for(User::class)->on(Office::class);
Version-controlled, diffable, deployed with the feature, impossible to have drift between environments.
Roles are the opposite and stay in the database, because a role genuinely is user data. Someone in the app invents "Regional Supervisor" on a Tuesday, and a tenant's roles are that tenant's business: one workspace's "Office Manager" is not another's, which is why a role can belong to a tenant.
The rule that falls out: what the app can do is code, how a customer organises who does it is data.
Two check methods, on purpose
There are two ways to ask, and merging them would have been a mistake:
$user->hasPermission('manage-office'); // direct grants only
$user->permissions('manage-office')->on($office)->check(); // direct or via a role
Ninety-nine percent of the time you want the second, because authorisation does not care how the permission arrived. The first exists because administration screens do: "remove this permission from this user" has to know whether the grant is on the user or coming from a role, or the button lies about what it will do.
One method with a flag would have made the common case take an argument, and the default would be wrong half the time in the codebases that need the distinction. Two names, each honest about what it looks at.
Where it earns its keep
A real-estate platform I run declares every permission with for(User::class) and on([Office::class, Group::class]). Rights depend on which office or group the person is acting in, broad permissions imply narrower ones, and labels come from the translation layer so the permission screen reads in the staff member's language. None of it is in a seeder.
If your app's permissions are global, use Spatie's: it is excellent and simpler. This is for when "yes, in this office" is the normal answer.
Full API, the trait setup, roles and the query scopes are on the Larallow page. Source on GitHub, package on Packagist.