Authorization starts as a role check in a controller and stays manageable for about eighteen months. Then an enterprise customer wants a role that can approve inspections but not delete them, another wants approval limited to properties in a specific region, and the conditionals spread across the codebase until nobody can answer who can do what without reading every controller.
The structural fix is to stop checking roles and start checking abilities. A role is a bundle of abilities assigned to a person; the code asks whether this actor may perform this action on this resource, and never which role they hold.
Policies hold the rules, once
class InspectionPolicy
{
// Runs before every ability. Tenant isolation belongs here, not in
// fifteen individual methods where one will eventually be forgotten.
public function before(User $user, string $ability, $inspection = null): ?bool
{
if ($inspection instanceof Inspection && $inspection->tenant_id !== $user->tenant_id) {
return false; // hard stop, no exceptions
}
return null; // fall through to the ability
}
public function approve(User $user, Inspection $inspection): Response
{
if (! $user->hasAbility('inspection.approve')) {
return Response::deny('You do not have approval permission.');
}
if ($inspection->status !== InspectionStatus::Submitted) {
return Response::deny('Only submitted inspections can be approved.');
}
if ($inspection->created_by === $user->id && ! $user->hasAbility('inspection.approve_own')) {
return Response::deny('You cannot approve your own inspection.');
}
return Response::allow();
}
}
// The controller states intent and nothing else.
$this->authorize('approve', $inspection);Returning a Response with a reason rather than a boolean is a detail worth adopting early. It gives the user an actionable message instead of a generic refusal, and it gives your support team something to work with when a customer insists the system is broken.
Abilities, roles, and the data model underneath
Keep abilities as fine-grained strings owned by the application, and roles as customer-configurable bundles of them. That separation is what lets an enterprise customer define their own role without an engineering change, and it keeps the code stable: adding a role touches data, adding a capability touches code, and the two stop being the same event.
Where it gets genuinely harder is scoped permissions — approve inspections, but only for these regions. Model the scope as data attached to the assignment rather than encoding it in the ability string, and evaluate it in the policy. The moment you find yourself parsing an ability name to extract a region, the model has gone wrong.
- Never check roles in application code. Check abilities; roles are a data-layer convenience for assigning them.
- Put tenant checks in a before hook so isolation cannot be forgotten on a new method.
- Cache the ability set per request, not per call. Permission resolution on every check is a silent N+1.
- Authorize at the query level too — a policy prevents opening the wrong record, but a listing endpoint needs the scope in the query.
- Test the denials. Every endpoint should have a test proving the wrong actor gets a 403, and those are the tests nobody writes.
Check what someone may do, never what they are called. Roles are how customers describe their organisation; abilities are what your code can reason about.
One audit worth running on any codebase that has grown past its original permission model: grep for role name comparisons. Every one is a place where a customer's custom role will behave incorrectly, and they are almost always in the code paths added under time pressure — which tend to be the ones touching the data people care most about.