Roles and Permissions in Laravel: Modeling Who Can Do What
The first multi-user feature arrives and someone adds an is_admin column to the users table. Three months later there's a role column with string values, an if in a controller checking for editor, and a client asking why a viewer could rename a workspace. Permission systems rarely fail because the tools are bad. They fail because the checks grew one at a time, each in a different file, and nobody owns the picture of who can do what. The setup we build starts centralized, testable, and boring on purpose.
Policies before anything else
Laravel ships the right primitive and teams skip it: the policy. A policy answers one question ("can this user update this post?") in one place, and every caller uses the same sentence:
// app/Policies/PostPolicy.php
public function update(User $user, Post $post): bool
{
return $user->ownsWorkspace($post->workspace)
|| $post->author_id === $user->id;
}
Controllers call $this->authorize('update', $post), Blade templates check @can, API resources read the same policy. When the rule changes, it changes once. Scattered role === 'editor' checks mean the same rule lives in five places, and one of them is the bug you find during a security review.
There's a legitimate place for a super-admin shortcut (Gate::before() returning true for staff accounts), and a trap next to it: if staff bypasses leak into business policies, your permission tests start passing for the wrong reason, and the day someone scopes the bypass wrong, nothing catches it.
Roles are data, not literals
Roles belong in tables when they can be created, renamed, or assigned per workspace. Whether you roll your own pivot or install spatie/laravel-permission matters less than one rule: check abilities, not role names. $user->hasRole('editor') is a lookup. $user->can('posts.update') is a decision. Role names churn, because clients rename "viewer" to "guest" and back; abilities don't.
Cache the permission lookup too. Fresh checks against three tables on every request will show up in your query logs, and a versioned cache invalidates cleanly the moment an assignment changes.
The workspace layer everyone forgets
Multi-tenant apps have two permission systems whether you designed them or not: a platform layer (is this account staff, suspended, billing-active) and a workspace layer (owner, admin, editor, viewer of this workspace). Mixing them is how a support person with platform access ends up editing a customer's data "just this once", and the audit trail can't say who else did the same.
The rules that hold up:
- Role assignments are scoped to a workspace row, never global.
- Platform staff permissions are explicit and logged, not an invisible bypass.
- Tenant scoping (the global-scope pattern from our multi-tenancy article) and policies solve different problems. Tenants keep data apart; policies decide what a user may do inside their own tenant. You need both, and neither substitutes for the other.
The leak checklist
Permission bugs hide at the boundaries, and the boundaries are known:
- The UI hides the button and the server doesn't check. Hiding is presentation. The policy is the control. Test the endpoint without the UI in front of it.
- The route checks, the queued job doesn't (or the reverse). Authorization inside a job running an hour later is a separate decision, not inherited context.
- Sessions outlive the revocation. A banned user with a valid session is still logged in until you invalidate sessions and revoke tokens. Banning a user or changing a role should trigger both.
- No tests. One feature test per role on the sensitive endpoint: owner passes, viewer gets 403. Minutes of work, and it's how you find out the permission model works before a user finds out it doesn't.
Keep it boring
One policy per model, abilities instead of role names, workspace-scoped assignments, a 403 in the test suite for every role you support. Nothing here needs a vendor dashboard, and the payoff is the kind that looks like nothing: features ship without anyone re-verifying by hand who's allowed to do what.