laravel-policies
Installation
SKILL.md
Laravel Policies
A policy answers one question: may this user do this to this kind of record? It answers from the user's permissions and, where it matters, whether they own the record. Whether the record is in a state that allows the operation is a different question. An order can't be cancelled once it has shipped, whoever asks; the action's guard enforces that and throws a domain exception that says why. Routes call policies, and a policy never does anything but answer.
Rules
- Permission and ownership only. No state checks (
$order->isShipped(),canTransitionTo()): they belong in the action's guard (laravel-actions). A state rule in a policy fails with a bare "This action is unauthorized." and duplicates the guard the action needs anyway. - Ask the project's permission system, by enum.
$user->can(Permission::CancelOrders), never a string. Laravel's Gate accepts enum abilities, and a permission system that hooks the Gate (spatie/laravel-permission does) answers them. - Permissions, never role names. Roles are data an admin re-grants.
hasRole('manager')in a policy hard-codes a grant that belongs in the role's permissions. - Ownership is a model method.
$order->notOwnedBy($user), never$order->user_id !== $user->idin the policy. The model knows how it's owned, and every policy reads the same. - Early returns, ending on the permission. A check with more than one condition is a run of
if (…) { return false; }, closing onreturn $user->can(...); not one long&&chain. - Return
bool. A denial needs no message: it's a 403. A rule that needs explaining is a state rule, and its exception explains it. - Abilities are named after what they protect. Resource abilities keep Laravel's names (
viewAny,view,create,update,delete). Any other ability is the verb of the action it guards:cancelforCancelOrderAction. viewAnydoesn't filter. It says whether the user may list orders at all. Which orders they see is a constraint on the listing's query (laravel-query-builders).- Permission cases are
{Verb}{Subject}; values are'{verb} {subject}', subject kebab-cased:CancelOrders = 'cancel orders',ViewAllOrders = 'view all-orders'. The space is the one departure from laravel-enums' kebab-case values. A value is a stored permission name that roles are granted by, so it never changes once deployed. - Wire the policy with
#[UsePolicy]on the model (Laravel 12.18+; laravel-models owns the attribute). Below 12.18, auto-discovery findsApp\Policies\OrderPolicyby name; a policy outside that convention is registered withGate::policy()in a provider'sboot(). - Routes call policies. Every route on a surface with a permission model carries
->can(), and nothing callsauthorize(). laravel-routing owns the rule.