Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
access-rules-development
access-rules-development
wnikk/laravel-access-rules
claude-code
authorization
access-control
permissions
roles
inheritance
multi-tenancy
xacml

Build authorization with wnikk/laravel-access-rules - roles and inheritance (RBAC), permissions with conditions on model data, relations and aggregates (ABAC), lists filtered by the same conditions, tenants, tree functions, explain/debug/lint tooling and XACML export/import.

Install
php artisan boost:add-skill wnikk/laravel-access-rules

Save this content to: .claude/skills/access-rules-development/SKILL.md

# Laravel Access Rules Development

## When to use this skill

Use it when the task involves who may do what in an application that has `wnikk/laravel-access-rules` installed: creating rules, granting or prohibiting, roles and inheritance, "only own / only my department / only cheap" kinds of access, filtering a list by what the user may see, finding out why access was refused, multi-tenant access, or exchanging policies as XACML.

Version 3 needs PHP 8.4 and Laravel 13. Everything below is version 3.

## Mental model

- A **rule** is a name of something that can be permitted: `orders.view`. It lives in the database and is created by code (migration, seeder).
- An **owner** holds permissions: a model with the trait `HasPermissions` (users), or a plain name without a model (`Role`, `Group`, `Team`). Owner types are listed in `config/access.php` under `owner_types`.
- A **permission** or a **prohibition** links an owner to a rule, optionally with an **option** (`profile.update.email`) and a **condition**.
- Owners **inherit** from each other to any depth. That is how roles work.
- The package plugs into `Gate::before` and answers "yes" or nothing. Everything else in Laravel authorization keeps working next to it.

Priority, from the weakest to the strongest:

1. nothing is said: not permitted
2. inherited permission
3. inherited prohibition
4. own permission
5. own prohibition

So a role can hide something from everybody and one user still gets it back with a permission of their own.

## Setup checklist

```php
// config/access.php
'owner_types' => [App\Models\User::class, 'Role', 'Team'],

'resources' => [                       // whitelist of models conditions may read
    'order'  => App\Models\Order::class,
    'client' => App\Models\Client::class,
    'item'   => App\Models\Item::class,
],

'tenant_types' => ['Team'],            // optional: owners of these types become user.tenant
'guest' => ['type' => 'Role', 'id' => 'guest'],   // optional: whom unauthenticated requests are checked as
```

```php
class User extends Authenticatable { use \Wnikk\LaravelAccessRules\Traits\HasPermissions; }
class Order extends Model          { use \Wnikk\LaravelAccessRules\Traits\HasAccessScope; }   // adds allowedTo()
```

Relations used in conditions **must declare their return type**: `public function client(): BelongsTo`. The package never calls a method to find out what it returns.

## Rules

```php
use Wnikk\LaravelAccessRules\Facades\Access;
use Wnikk\LaravelAccessRules\Models\RuleOrigin;

Access::newRule('orders.view', 'View orders', resource: 'order');
Access::newRule('orders.export', 'Export', options: 'required|in:csv,pdf', resource: 'order');
Access::newRule('orders.update.self', 'Update own orders', resource: 'order');       // suffix .self = only the author
Access::newRule('orders.archive', 'Archive', resource: 'order', when: 'not order.locked');   // condition for every holder

// names that code builds at run time, e.g. can('news.edit.'.$category->slug), are created as custom:
Access::newRule('news.edit.sport', 'Edit sport news', origin: RuleOrigin::Custom);

Access::delRule('orders.view');         // throws RULE_IN_USE while somebody holds the rule
Access::delRule('orders.view', true);   // deletes it together with its permissions (rollbacks, tests)
```

Abilities can be a backed enum everywhere a name is accepted.

## Granting

```php
use Wnikk\LaravelAccessRules\Facades\Access;

$user->addPermission('orders.view');
$user->addProhibition('orders.delete');
$user->addPermission('orders.export', 'csv');                       // option
$user->remPermission('orders.view');
$user->inheritPermissionFrom('Role', 'manager');

Access::for('Role', 'manager')->create('Managers');                 // owners without a model
Access::for('Role', 'manager')->allow('orders.view', when: '...');
Access::for('Role', 'manager')->deny('orders.view', when: '...');
Access::for('Role', 'chief')->inheritFrom('Role', 'manager');

Access::batch(fn () => /* hundreds of changes */ null);            // one drop of the cache
```

Mistakes throw `AccessRulesException` with a code: `RULE_NOT_FOUND`, `DUPLICATE_PERMISSION`, `INVALID_OPTION`, `INHERITANCE_LOOP`, `RULE_IN_USE`, `RULE_MANAGED_BY_CODE`. A wrong condition throws `InvalidConditionException` **when it is saved**, not later.

## Conditions

One text, two uses that always agree:

```php
$user->can('orders.view', $order);            // evaluated in memory, no queries for a loaded record
Order::allowedTo('orders.view')->paginate();  // compiled into WHERE, one query; works with your own where(), orderBy(), paginate()
```

| | |
|---|---|
| the record | `order.cost`, `resource.cost` |
| related record (to-one) | `order.client.city`, `order.client.parent.city` |
| the user | `user.id`, `user.department_id`, any attribute; `user.tenant`, `user.guest` |
| environment | `env.now`, `env.today`, `env.hour`, `env.weekday` (1 = Monday), `env.ip`, `env.app` |
| comparison | `== != > >= < <=`, `in [..]`, `not in [..]`, `x in user.tenant`, `between a and b` |
| logic | `&&` `and`, `\|\|` `or`, `!` `not`, brackets |
| arithmetic | `+ - * /`: `order.cost * 1.2 > order.budget` |
| text (exact, case included) | `startsWith(x, 'A-')`, `endsWith()`, `contains()`, `lower(x) == lower(user.email)` |
| time | `now()`, `today()`, `ago('24 hours')`, `after('7 days')`, `monthStart(-1)`, `yearStart()` |
| aggregates over to-many | `order.items.count < 3`, `count()`, `sum()`, `min()`, `max()`, `exists()`, each with an optional filter |
| pivot | `exists(order.products, pivot.quantity > 4)`, `sum(order.products.pivot.quantity)` |
| trees (`parent_id`) | `order.category_id in belowOrSelf('category.slug', 'electronics')`, also `below`, `above`, `aboveOrSelf` |
| author of the record | `isAuthor()`, or the suffix `.self` on the rule |

```php
'order.cost <= user.approval_limit && order.user_id != user.id'                  // approve up to a limit, never your own
"sum(client.payments.amount, paid_at >= monthStart(-1)) > 1000 && client.city != 'Y'"
'order.client.team_id in user.tenant'                                            // multi-tenancy without a role per tenant
"exists(product.tags, name == 'sale') && exists(product.comments, likes.count > 10)"   // polymorphic relations work the same
```

NULL works as in SQL: a comparison with NULL is *unknown* and the permission does not apply. Write `client.city == null || client.city != 'Y'` when such records are meant.

The column decides how a comparison works: a text column compares as text (`'01234'` is not `1234`), a number column as a number.

Conditions in code instead of text, with the same result:

```php
use Wnikk\LaravelAccessRules\Conditions\Cond;

Cond::all(Cond::attr('order.cost')->gt(100), Cond::count('order.items')->lt(3));
```

Own attributes and functions are invokable classes in config `attributes` / `functions` (not closures: `config:cache` cannot store them).

## Performance notes for generated code

- Do not loop over records with `can()` to build a list. Use `allowedTo()`.
- Eager load relations a condition reads when checking many loaded records.
- `count()`, `sum()`, `min()`, `max()` over a relation are correlated subqueries in a list. On big tables with `paginate()` prefer a counter column (`order.items_count < 3`), which is an ordinary attribute for a condition.
- Do not call `Access::flush()` after changes made through the package. It is for rows changed with plain SQL.

## Finding out why

```php
$user->access()->explain('orders.view', $order);   // every permission that took part, strongest first, the one that decided, values it read
```

```bash
php artisan acr:explain "App\Models\User" 7 orders.view order:4
php artisan acr:lint            # exit code 1 when a stored condition no longer matches models or config
php artisan acr:lint --fix      # saves again conditions whose column types changed
php artisan acr:doctor          # exit code 1 for duplicate permissions, rows that point at nothing, loops of inheritance and of the rule tree
php artisan acr:doctor --fix    # deletes the duplicates and the orphans; loops need a person
```

Debug mode for a support session (turn it on per request, never for ordinary users): `Access::debug()` in a middleware, then `Access::debugLog()` holds every refused check with its explanation under `text`, and for every `allowedTo()` the conditions and the SQL that narrowed the list. The mode only observes; it changes no decision. `Access::lastDenied()` names the last ability that was not permitted, in any mode.

`app(\Wnikk\LaravelAccessRules\Administration\Linter::class)->run()` and `Administration\Doctor::class` return the findings of lint and doctor as data, for a health screen.

For a menu or a frontend: `Access::for($user)->abilities()` returns every ability at once as `allowed`, `forbidden` or `conditional` (ask `can()` with the record), from the compiled set a check reads, so it is fine on the path of a request.

What an owner holds, as data for a screen or a console: `Access::for($user)->permissions()` lists every row that reaches the owner with rule, option, effect, condition text, own or inherited and from whom, strongest first inside a rule; `->sources()` and `->heirs()` list inheritance at any depth with the direct link each one came through. `Access::ownerTypes()` maps the numbers of the owner table to the types of config. Never on the path of a request.

## Admin panels

Rules that come with code (`origin = code`) may change their title, description, options and place in the tree (the place stays closed while `rule_tree_inheritance` is on); `custom` and `import` rules are fully editable. Use the two methods that respect this:

```php
use Wnikk\LaravelAccessRules\Administration\RuleCatalog;

$catalog->edit('orders.export', ['title' => 'Export orders', 'options' => 'required|in:csv,pdf']);
$catalog->discard('news.edit.sport');     // refuses rules of code (RULE_MANAGED_BY_CODE) and rules somebody holds (RULE_IN_USE)
```

Check a typed condition before saving with `Cond::compile($text, $rule->resource)` (throws `InvalidConditionException` with the message). Show a stored condition as text with `Cond::describe($permission->condition, $rule->resource)`. Never store a condition tree that came from a browser without passing it through `addPermission()` / `allow()`; they validate it.

Every change fires `Wnikk\LaravelAccessRules\Events\AccessChanged` (`$event->action`, `$event->details`): listen to it for an audit log.

## XACML 3.0

```php
use Wnikk\LaravelAccessRules\Xacml\Xacml;

return response()->streamDownload(fn () => $xacml->export('php://output'), 'access.xml', ['Content-Type' => 'application/xml']);

$plan = $xacml->check($request->file('policy'));                       // what an import would change, writes nothing
$done = $xacml->import($request->file('policy'), options: ['replace' => true]);
```

```bash
php artisan acr:xacml:export storage/app/access.xml
php artisan acr:xacml:import storage/app/access.xml --check
```

## Testing

```php
$user = User::factory()->create();
$user->addPermission('orders.view', when: 'order.cost > 100');

$this->actingAs($user)->get('/orders/'.$cheap->id)->assertForbidden();
$this->assertSame([$dear->id], Order::allowedTo('orders.view', $user)->pluck('id')->all());
```

Create rules in the test or run the seeder that creates them; a permission for a rule that does not exist throws.

Laravel Access Rules Development

When to use this skill

Use it when the task involves who may do what in an application that has wnikk/laravel-access-rules installed: creating rules, granting or prohibiting, roles and inheritance, "only own / only my department / only cheap" kinds of access, filtering a list by what the user may see, finding out why access was refused, multi-tenant access, or exchanging policies as XACML.

Version 3 needs PHP 8.4 and Laravel 13. Everything below is version 3.

Mental model

  • A rule is a name of something that can be permitted: orders.view. It lives in the database and is created by code (migration, seeder).
  • An owner holds permissions: a model with the trait HasPermissions (users), or a plain name without a model (Role, Group, Team). Owner types are listed in config/access.php under owner_types.
  • A permission or a prohibition links an owner to a rule, optionally with an option (profile.update.email) and a condition.
  • Owners inherit from each other to any depth. That is how roles work.
  • The package plugs into Gate::before and answers "yes" or nothing. Everything else in Laravel authorization keeps working next to it.

Priority, from the weakest to the strongest:

  1. nothing is said: not permitted
  2. inherited permission
  3. inherited prohibition
  4. own permission
  5. own prohibition

So a role can hide something from everybody and one user still gets it back with a permission of their own.

Setup checklist

// config/access.php
'owner_types' => [App\Models\User::class, 'Role', 'Team'],

'resources' => [                       // whitelist of models conditions may read
    'order'  => App\Models\Order::class,
    'client' => App\Models\Client::class,
    'item'   => App\Models\Item::class,
],

'tenant_types' => ['Team'],            // optional: owners of these types become user.tenant
'guest' => ['type' => 'Role', 'id' => 'guest'],   // optional: whom unauthenticated requests are checked as
class User extends Authenticatable { use \Wnikk\LaravelAccessRules\Traits\HasPermissions; }
class Order extends Model          { use \Wnikk\LaravelAccessRules\Traits\HasAccessScope; }   // adds allowedTo()

Relations used in conditions must declare their return type: public function client(): BelongsTo. The package never calls a method to find out what it returns.

Rules

use Wnikk\LaravelAccessRules\Facades\Access;
use Wnikk\LaravelAccessRules\Models\RuleOrigin;

Access::newRule('orders.view', 'View orders', resource: 'order');
Access::newRule('orders.export', 'Export', options: 'required|in:csv,pdf', resource: 'order');
Access::newRule('orders.update.self', 'Update own orders', resource: 'order');       // suffix .self = only the author
Access::newRule('orders.archive', 'Archive', resource: 'order', when: 'not order.locked');   // condition for every holder

// names that code builds at run time, e.g. can('news.edit.'.$category->slug), are created as custom:
Access::newRule('news.edit.sport', 'Edit sport news', origin: RuleOrigin::Custom);

Access::delRule('orders.view');         // throws RULE_IN_USE while somebody holds the rule
Access::delRule('orders.view', true);   // deletes it together with its permissions (rollbacks, tests)

Abilities can be a backed enum everywhere a name is accepted.

Granting

use Wnikk\LaravelAccessRules\Facades\Access;

$user->addPermission('orders.view');
$user->addProhibition('orders.delete');
$user->addPermission('orders.export', 'csv');                       // option
$user->remPermission('orders.view');
$user->inheritPermissionFrom('Role', 'manager');

Access::for('Role', 'manager')->create('Managers');                 // owners without a model
Access::for('Role', 'manager')->allow('orders.view', when: '...');
Access::for('Role', 'manager')->deny('orders.view', when: '...');
Access::for('Role', 'chief')->inheritFrom('Role', 'manager');

Access::batch(fn () => /* hundreds of changes */ null);            // one drop of the cache

Mistakes throw AccessRulesException with a code: RULE_NOT_FOUND, DUPLICATE_PERMISSION, INVALID_OPTION, INHERITANCE_LOOP, RULE_IN_USE, RULE_MANAGED_BY_CODE. A wrong condition throws InvalidConditionException when it is saved, not later.

Conditions

One text, two uses that always agree:

$user->can('orders.view', $order);            // evaluated in memory, no queries for a loaded record
Order::allowedTo('orders.view')->paginate();  // compiled into WHERE, one query; works with your own where(), orderBy(), paginate()
the record order.cost, resource.cost
related record (to-one) order.client.city, order.client.parent.city
the user user.id, user.department_id, any attribute; user.tenant, user.guest
environment env.now, env.today, env.hour, env.weekday (1 = Monday), env.ip, env.app
comparison == != > >= < <=, in [..], not in [..], x in user.tenant, between a and b
logic && and, || or, ! not, brackets
arithmetic + - * /: order.cost * 1.2 > order.budget
text (exact, case included) startsWith(x, 'A-'), endsWith(), contains(), lower(x) == lower(user.email)
time now(), today(), ago('24 hours'), after('7 days'), monthStart(-1), yearStart()
aggregates over to-many order.items.count < 3, count(), sum(), min(), max(), exists(), each with an optional filter
pivot exists(order.products, pivot.quantity > 4), sum(order.products.pivot.quantity)
trees (parent_id) order.category_id in belowOrSelf('category.slug', 'electronics'), also below, above, aboveOrSelf
author of the record isAuthor(), or the suffix .self on the rule
'order.cost <= user.approval_limit && order.user_id != user.id'                  // approve up to a limit, never your own
"sum(client.payments.amount, paid_at >= monthStart(-1)) > 1000 && client.city != 'Y'"
'order.client.team_id in user.tenant'                                            // multi-tenancy without a role per tenant
"exists(product.tags, name == 'sale') && exists(product.comments, likes.count > 10)"   // polymorphic relations work the same

NULL works as in SQL: a comparison with NULL is unknown and the permission does not apply. Write client.city == null || client.city != 'Y' when such records are meant.

The column decides how a comparison works: a text column compares as text ('01234' is not 1234), a number column as a number.

Conditions in code instead of text, with the same result:

use Wnikk\LaravelAccessRules\Conditions\Cond;

Cond::all(Cond::attr('order.cost')->gt(100), Cond::count('order.items')->lt(3));

Own attributes and functions are invokable classes in config attributes / functions (not closures: config:cache cannot store them).

Performance notes for generated code

  • Do not loop over records with can() to build a list. Use allowedTo().
  • Eager load relations a condition reads when checking many loaded records.
  • count(), sum(), min(), max() over a relation are correlated subqueries in a list. On big tables with paginate() prefer a counter column (order.items_count < 3), which is an ordinary attribute for a condition.
  • Do not call Access::flush() after changes made through the package. It is for rows changed with plain SQL.

Finding out why

$user->access()->explain('orders.view', $order);   // every permission that took part, strongest first, the one that decided, values it read
php artisan acr:explain "App\Models\User" 7 orders.view order:4
php artisan acr:lint            # exit code 1 when a stored condition no longer matches models or config
php artisan acr:lint --fix      # saves again conditions whose column types changed
php artisan acr:doctor          # exit code 1 for duplicate permissions, rows that point at nothing, loops of inheritance and of the rule tree
php artisan acr:doctor --fix    # deletes the duplicates and the orphans; loops need a person

Debug mode for a support session (turn it on per request, never for ordinary users): Access::debug() in a middleware, then Access::debugLog() holds every refused check with its explanation under text, and for every allowedTo() the conditions and the SQL that narrowed the list. The mode only observes; it changes no decision. Access::lastDenied() names the last ability that was not permitted, in any mode.

app(\Wnikk\LaravelAccessRules\Administration\Linter::class)->run() and Administration\Doctor::class return the findings of lint and doctor as data, for a health screen.

For a menu or a frontend: Access::for($user)->abilities() returns every ability at once as allowed, forbidden or conditional (ask can() with the record), from the compiled set a check reads, so it is fine on the path of a request.

What an owner holds, as data for a screen or a console: Access::for($user)->permissions() lists every row that reaches the owner with rule, option, effect, condition text, own or inherited and from whom, strongest first inside a rule; ->sources() and ->heirs() list inheritance at any depth with the direct link each one came through. Access::ownerTypes() maps the numbers of the owner table to the types of config. Never on the path of a request.

Admin panels

Rules that come with code (origin = code) may change their title, description, options and place in the tree (the place stays closed while rule_tree_inheritance is on); custom and import rules are fully editable. Use the two methods that respect this:

use Wnikk\LaravelAccessRules\Administration\RuleCatalog;

$catalog->edit('orders.export', ['title' => 'Export orders', 'options' => 'required|in:csv,pdf']);
$catalog->discard('news.edit.sport');     // refuses rules of code (RULE_MANAGED_BY_CODE) and rules somebody holds (RULE_IN_USE)

Check a typed condition before saving with Cond::compile($text, $rule->resource) (throws InvalidConditionException with the message). Show a stored condition as text with Cond::describe($permission->condition, $rule->resource). Never store a condition tree that came from a browser without passing it through addPermission() / allow(); they validate it.

Every change fires Wnikk\LaravelAccessRules\Events\AccessChanged ($event->action, $event->details): listen to it for an audit log.

XACML 3.0

use Wnikk\LaravelAccessRules\Xacml\Xacml;

return response()->streamDownload(fn () => $xacml->export('php://output'), 'access.xml', ['Content-Type' => 'application/xml']);

$plan = $xacml->check($request->file('policy'));                       // what an import would change, writes nothing
$done = $xacml->import($request->file('policy'), options: ['replace' => true]);
php artisan acr:xacml:export storage/app/access.xml
php artisan acr:xacml:import storage/app/access.xml --check

Testing

$user = User::factory()->create();
$user->addPermission('orders.view', when: 'order.cost > 100');

$this->actingAs($user)->get('/orders/'.$cheap->id)->assertForbidden();
$this->assertSame([$dear->id], Order::allowedTo('orders.view', $user)->pluck('id')->all());

Create rules in the test or run the seeder that creates them; a permission for a rule that does not exist throws.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource