Download the sample code: https://github.com/wnikk/-laravel-access-example
This is the second part. The first one, How to use Access Control Rules and CRUD in Laravel (tutorial step by step), built roles and permissions: RBAC.
This one goes further: permissions that look at the data. That is ABAC, Attribute-Based Access Control, and it arrived in version 3.x of "wnikk/laravel-access-rules".
You do not need the first part to follow this one. Everything is built from scratch.
What we had in 2.x
A short reminder, in case you skipped part one:
- a rule is a name of something that can be permitted,
orders.view; - an owner holds permissions: a user, a role, a group;
- owners inherit from each other, which is how roles work;
- an option splits one rule into several,
profile.update.email; - the magic suffix
.selfmeans "only the author of the record".
All of it still works in 3.x, with the same tables and the same methods.
And here is what it could not say:
- "a manager sees orders over 100 with fewer than 3 items";
- "only orders of my department";
- "everything, except clients from Paris";
- and the hardest one: "show me the list of what I am allowed to see".
In 2.x each of these meant a policy class and a hand-written query, and the two had to agree forever.
In 3.x each of them is one line.
$role->addPermission('orders.view', when: 'order.cost > 100 && order.items.count < 3');
$user->can('orders.view', $order); // one record
Order::allowedTo('orders.view')->paginate(); // the list, filtered by the database
Where will we start?
We build a tiny back office of a shop:
- Orders with items, a client, a category and products.
- Two users, Ann and Bob, a role and two teams.
- Eighteen short examples. Each adds one idea and shows its result on the same six orders.
The data is small on purpose: you can check every result by eye.
Step 1: Create Laravel application
Version 3 works on Laravel 13 and PHP 8.4 or after.
composer create-project laravel/laravel abac-example
Step 2: Install the package
composer require wnikk/laravel-access-rules
php artisan vendor:publish --tag=access-config --tag=access-migrations
Step 3: Tell the package who and what
File config/access.php. Three lists matter today:
// who can hold permissions
'owner_types' => [
App\Models\User::class,
'Role',
'Team',
],
// models that conditions may read, by the name they have in conditions
'resources' => [
'order' => App\Models\Order::class,
'client' => App\Models\Client::class,
'item' => App\Models\Item::class,
'category' => App\Models\Category::class,
'product' => App\Models\Product::class,
],
// owner types that work as a tenant, see Example 9
'tenant_types' => ['Team'],
resources is a whitelist. A condition can come from an admin panel, and it can only touch models listed here.
Step 4: Migrations
php artisan make:migration create_shop_tables
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->integer('department_id')->default(1);
$table->integer('approval_limit')->default(0);
});
Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('city')->nullable();
$table->integer('team_id');
});
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->integer('parent_id')->nullable();
$table->string('name');
});
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->boolean('restricted')->default(false);
});
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('number');
$table->integer('client_id')->nullable();
$table->integer('user_id'); // the author
$table->integer('department_id');
$table->integer('category_id');
$table->integer('cost');
$table->integer('budget')->nullable();
$table->string('status');
$table->boolean('locked')->default(false);
$table->dateTime('created_at');
});
Schema::create('items', function (Blueprint $table) {
$table->id();
$table->integer('order_id');
$table->integer('price');
});
Schema::create('order_product', function (Blueprint $table) {
$table->integer('order_id');
$table->integer('product_id');
$table->integer('quantity')->default(1);
});
}
php artisan migrate
Step 5: Models
The user gets the usual trait:
use Wnikk\LaravelAccessRules\Traits\HasPermissions;
class User extends Authenticatable
{
use HasPermissions;
The order gets a new one. It adds allowedTo(), the filter of lists:
use Wnikk\LaravelAccessRules\Traits\HasAccessScope;
class Order extends Model
{
use HasAccessScope;
public $timestamps = false;
protected $guarded = [];
protected $casts = ['locked' => 'boolean'];
public function client(): BelongsTo { return $this->belongsTo(Client::class); }
public function category(): BelongsTo { return $this->belongsTo(Category::class); }
public function items(): HasMany { return $this->hasMany(Item::class); }
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)->withPivot('quantity');
}
}
Important: relations declare their return type. That is how the package knows client is a relation. It never calls a method to find out, because the method could be delete().
Client, Item, Product are plain models. Category points at itself:
class Category extends Model
{
public function parent(): BelongsTo { return $this->belongsTo(self::class, 'parent_id'); }
}
Step 6: Data
Seed it any way you like. This is what the examples expect. "Today" is a Friday, 12:00.
Users
| id | name | department_id | approval_limit |
|---|---|---|---|
| 1 | Ann | 1 | 500 |
| 2 | Bob | 2 | 100 |
Clients: 1 Acme (Berlin, team 1), 2 Bolt (Paris, team 1), 3 Core (Berlin, team 2), 4 Dune (no city, team 2)
Categories: 1 Electronics → 2 Phones, 3 Laptops; 4 Furniture
Products: 1 Phone, 2 Charger, 3 Battery pack (restricted)
Orders
| id | number | client | author | dept | category | cost | budget | status | locked | created | items | products × quantity |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | EU-1001 | Acme | Ann | 1 | Phones | 150 | 200 | draft | today 08:00 | 100, 50 | Phone ×1, Charger ×2 | |
| 2 | EU-1002 | Bolt | Ann | 1 | Laptops | 900 | 800 | review | 10 days ago | 300, 300, 200, 100 | Battery pack ×5 | |
| 3 | US-2001 | Core | Bob | 2 | Furniture | 50 | 100 | paid | today 11:00 | 50 | ||
| 4 | EU-1003 | Dune | Bob | 1 | Electronics | 450 | 600 | draft | yes | yesterday | 450 | Phone ×1, Battery pack ×1 |
| 5 | US-2002 | Acme | Bob | 2 | Phones | 120 | draft | 20 days ago | ||||
| 6 | EU-1004 | none | Ann | 2 | Furniture | 700 | 700 | review | today 00:30 | 400, 200, 100 | Charger ×10 |
Step 7: Rules, a role and two teams
php artisan make:seeder AccessSeeder
use Wnikk\LaravelAccessRules\Facades\Access;
public function run(): void
{
// "resource" says which model the rule is about
Access::newRule('orders.view', 'View orders', resource: 'order');
Access::newRule('orders.approve', 'Approve orders', resource: 'order');
Access::for('Role', 'manager')->create('Managers');
Access::for('Team', 1)->create('North');
Access::for('Team', 2)->create('South');
User::find(1)->inheritPermissionFrom('Team', 1); // Ann is in team North
User::find(2)->inheritPermissionFrom('Team', 2); // Bob is in team South
}
Access::for(...) is new in 3.x: a short way to work with an owner that has no model.
One helper route, for this tutorial only, to switch users:
Route::get('/login-as/{id}', fn ($id) => Auth::loginUsingId($id) ? redirect('/orders') : abort(404));
Let's move on to the most interesting part ☕
Every example gives Ann or Bob one permission and shows which of the six orders it opens.
To try the next one, take the previous permission away: $user->remPermission('orders.view');
Example 1
The first condition. A value of a column and a count of related records:
$ann->addPermission('orders.view', when: 'order.cost > 100 && order.items.count < 3');
Check one record, the usual Laravel way:
class OrderController extends Controller
{
public function show(Order $order)
{
Gate::authorize('orders.view', $order);
return Response::json($order);
}
}
Result: orders 1, 4, 5 open. Order 2 costs enough but has four items. Order 3 is too cheap.
A mistake in a condition does not wait for a user to find it. It stops you when you save:
$ann->addPermission('orders.view', when: 'order.itms.count < 3');
// InvalidConditionException: "itms" in "order.itms.count" is not a relation of App\Models\Order
// the same for an unknown model (invoice.cost) and an unknown function (yesterday())
A mistyped column is the one thing saving lets through, because a model may have it as an accessor. Example 15 catches those.
Example 2
Now the list. No new rule, no new query, the same permission:
public function index()
{
return Response::json(Order::allowedTo('orders.view')->orderBy('id')->paginate());
}
The condition became a part of WHERE:
select * from "orders" where (
"orders"."cost" > ?
and (select count(*) from "items" where "orders"."id" = "items"."order_id") < ?
)
One query. Pagination, sorting and your own where() work as always.
The list and the single check always agree. You will never show a row that answers 403 when clicked.
Example 3
Compare a record with the user:
$ann->addPermission('orders.view', when: 'order.department_id == user.department_id');
Ann (department 1) sees 1, 2, 4. Give Bob the same line and he sees 3, 5, 6. One permission, a different result for each user. So it belongs on a role:
Access::for('Role', 'manager')->allow('orders.approve',
when: 'order.cost <= user.approval_limit && order.user_id != user.id');
$ann->inheritPermissionFrom('Role', 'manager');
"Approve up to your limit, but never your own order." Ann (limit 500) may approve 3, 4, 5.
user. is any attribute of your User model. Plus user.id, user.tenant (Example 9) and user.guest.
Example 4
Walk through a relation:
$ann->addPermission('orders.view', when: "order.client.city != 'Paris'");
Result: 1, 3, 5. Order 2 is from Paris, and orders 4 and 6 are missing too.
Client of order 4 has no city. Order 6 has no client. A comparison with NULL is unknown, as in SQL, and a permission with an unknown condition does not apply.
Say it out loud when you mean such records too:
$ann->addPermission('orders.view', when: "order.client.city == null || order.client.city != 'Paris'");
Result: 1, 3, 4, 5, 6.
Example 5
Prohibitions, and who wins. The priority has five steps, from weak to strong:
- nothing is said: not permitted
- inherited permission
- inherited prohibition
- own permission
- own prohibition
Watch it work, line by line:
Access::for('Role', 'manager')->allow('orders.view'); // Ann sees 1 2 3 4 5 6
Access::for('Role', 'manager')->deny('orders.view', when: 'order.locked'); // Ann sees 1 2 3 5 6
$ann->addPermission('orders.view', when: 'order.cost > 400'); // Ann sees 1 2 3 4 5 6
$ann->addProhibition('orders.view', when: "order.status == 'paid'"); // Ann sees 1 2 4 5 6
Look at the third line. The role hides locked orders from all its members. Ann's own permission is stronger, so she gets order 4 back. No need to clone the role for one person.
A prohibition takes a permission away and vetoes nothing. Your Gate::before for a super administrator, your policies and other packages keep their word, as in 2.x.
Example 6
Sometimes there is no record yet. A menu item, a "New order" button. There are three ways to ask:
$user->can('orders.view', $order); // this order
$user->can('orders.view', Order::class); // orders in general: is there any order I may see?
$user->can('orders.view'); // nothing to look at: only permissions without a condition count
With the permission of Example 1, the second line is true and the third is false.
@can('orders.view', App\Models\Order::class)
<a href="/orders">Orders</a>
@endcan
authorizeResource() passes the class for index and create by itself.
Example 7
Aggregates over related records: count, sum, min, max, exists. Each can take a filter:
'sum(order.items.price) <= user.approval_limit' // Ann: 1, 3, 4, 5 Bob: 3, 5
'exists(order.items, price >= 300)' // 2, 4, 6
Inside a filter, names without a prefix are columns of the related model.
And time:
"order.created_at >= ago('7 days')" // 1, 3, 4, 6
Also now(), today(), after('3 days'), monthStart(-1), yearStart(). A permission that expires by itself, without a cron job.
Example 8
Arithmetic, ranges and text:
'order.cost * 1.2 > order.budget' // 2, 6 over budget with 20% on top
'order.cost between 100 and 500' // 1, 4, 5 both ends included
"startsWith(order.number, 'EU-')" // 1, 2, 4, 6
"lower(order.client.city) == 'berlin'" // 1, 3, 5
Also endsWith(), contains(), + - * / and brackets. Text functions are exact, letter case included, on every database. Use lower() on both sides to ignore case.
Example 9
Teams, or tenants. Step 3 set 'tenant_types' => ['Team'], Step 7 created two teams.
A user that inherits from teams gets their ids as the list user.tenant:
Access::for('Role', 'manager')->allow('orders.view', when: 'order.client.team_id in user.tenant');
Ann (North) sees 1, 2, 5. Bob (South) sees 3, 4. Move a user to another team and the list follows. There is no role per team and nothing to keep in sync.
Example 10
Trees. "Electronics and everything under it":
$ann->addPermission('orders.view', when: "order.category_id in belowOrSelf('category.name', 'Electronics')");
Result: 1, 2, 4, 5. Phones and Laptops came along, Furniture stayed out.
There are four of them: below, belowOrSelf, above, aboveOrSelf. The tree is walked once per request, and the database receives a plain category_id in (1, 2, 3).
Example 11
Many-to-many, with columns of the pivot table:
'not exists(order.products, restricted)' // 1, 3, 5, 6 no dangerous goods inside
'exists(order.products, pivot.quantity >= 5)' // 2, 6 wholesale
'sum(order.products.pivot.quantity) >= 3' // 1, 2, 6
The relation has to load the column, ->withPivot('quantity'). We did that in Step 5. Forget it, and the condition is refused when you save it, with a hint.
Polymorphic relations (morphMany, morphToMany, morph map) need nothing special. They work the same way, see Example 18.
Example 12
The environment, and guests:
Access::for('Role', 'manager')->allow('orders.approve',
when: 'env.weekday in [1, 2, 3, 4, 5] && env.hour between 9 and 18');
Also env.ip, env.now, env.today, env.app. Such a part is computed before the query: on a Saturday the database gets WHERE 0 = 1 and does not read a single row.
Visitors without a login are checked as one owner, named in config:
'guest' => ['type' => 'Role', 'id' => 'guest'],
Access::for('Role', 'guest')->create('Guests');
Access::for('Role', 'guest')->allow('catalog.view');
Gate::forUser(null)->allows('catalog.view'); // true
Example 13
Two things for people who dislike strings. Names of rules as an enum:
enum Ability: string
{
case OrdersView = 'orders.view';
}
$ann->addPermission(Ability::OrdersView, when: 'order.cost > 100');
$ann->can(Ability::OrdersView, $order);
Order::allowedTo(Ability::OrdersView)->get();
And conditions built in code. The result is the same as from text:
use Wnikk\LaravelAccessRules\Conditions\Cond;
$ann->addPermission('orders.view', when: Cond::all(
Cond::attr('order.cost')->gt(100),
Cond::count('order.items')->lt(3),
));
Example 14
The question every access system hears most: "why can't I see it?"
Take the four lines of Example 5 and ask:
php artisan acr:explain "App\Models\User" 1 orders.view order:3
orders.view for User 1, asked about a record: PROHIBITED
=> prohibit, own: User 1 (Ann), rule orders.view, when order.status == 'paid' -> true
2 permit, own: User 1 (Ann), rule orders.view, when order.cost > 400 -> not reached
3 prohibit, inherited: Role manager (Managers), rule orders.view, when order.locked == true -> not reached
4 permit, inherited: Role manager (Managers), rule orders.view -> not reached
order.status = "paid"
Every permission that takes part, strongest first. The arrow marks the one that decided, and you see the value it read.
On production it is even simpler. An administrator looks at the application as the user who complains, and the application turns debug mode on for that request:
// a middleware of your application
if (session('impersonated_by') && $request->cookie('access_debug')) {
Access::debug();
}
Now every refusal is explained and every filtered list is written down. The mode only watches: it changes no decision and no message.
Access::debugLog();
// 'denials' => every refused check, explained; the text from above is under 'text'
// 'lists' => every allowedTo(): the conditions it used, and the SQL they became
Print the last one on your 403 page:
@if ($denial = last(Access::debugLog()['denials']))
<pre>{{ $denial['text'] }}</pre>
@endif
Keep it off for ordinary users. An explanation shows rules of other people.
Example 15
Conditions are checked when you save them. Then the application changes. Somebody renames cost to total in a migration, and stored conditions still say cost.
php artisan acr:lint
| permission #21 of Role manager for orders.approve | "cost" is not a column of table "orders": ... a list cannot filter by it |
| permission #21 of Role manager for orders.approve | types of columns have changed since the condition was saved ... run "php artisan acr:lint --fix" |
2 problem(s) found.
It exits with code 1. Put it into your CI and after migrate in the deploy script. The second line is the same
column seen from another side: the stored condition remembers the type of cost, and --fix saves it again once the
column is back.
Example 16
Two small things for real projects.
A seeder that grants hundreds of permissions drops the cache once, not hundreds of times:
Access::batch(function () use ($users) {
foreach ($users as $user) {
$user->inheritPermissionFrom('Role', 'manager');
}
});
And every change fires one event. An audit log is a listener:
use Wnikk\LaravelAccessRules\Events\AccessChanged;
Event::listen(AccessChanged::class, function (AccessChanged $event) {
Log::channel('audit')->info($event->action, $event->details + ['by' => auth()->id()]);
});
$event->action is permission.granted, permission.revoked, inheritance.added and so on. $event->details says for whom, which rule and with what condition.
By the way, about speed. After the first check of a request, a check runs no queries and takes a few microseconds. Version 2 ran a query per check.
Example 17
This one is for the world outside of Laravel. XACML 3.0 is the standard language of access policies. Auditors ask for it, other systems speak it.
php artisan acr:xacml:export storage/app/access.xml
One file: a valid XACML policy, and at its end a policy set no request reaches, with what XACML has no place for: titles of rules, names of owners, inheritance.
And back. Look first, import second:
php artisan acr:xacml:import storage/app/access.xml --check
| Kind | Action | What | Document | Database |
| permission | differs | App\Models\User:1 may orders.view | order.cost > 400 | order.cost > 1000 |
rule: 1 same
owner: 2 same
permission: 3 same, 1 differs
inheritance: 1 same
Nothing was written. You see what an import would create, and what differs from the database. Then:
php artisan acr:xacml:import storage/app/access.xml # creates what is missing
php artisan acr:xacml:import storage/app/access.xml --replace # and brings what differs to the document
It also reads policies written by other systems. What it cannot convert, it reports with an address in the document and writes nothing, because a lost prohibition means wider access.
The same from a controller, streamed to the browser:
use Wnikk\LaravelAccessRules\Xacml\Xacml;
public function download(Xacml $xacml)
{
return response()->streamDownload(fn () => $xacml->export('php://output'), 'access.xml', ['Content-Type' => 'application/xml']);
}
public function preview(Request $request, Xacml $xacml)
{
return view('access.import', ['report' => $xacml->check($request->file('policy'))]);
}
Example 18
The last one is about polymorphic relations. A tag hangs on a product and on a category alike: morphToMany
and a morph map. Example 11 said they need nothing special. Two tables, added for this example only:
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name');
});
Schema::create('taggables', function (Blueprint $table) { // a tag on a product or on a category
$table->integer('tag_id');
$table->morphs('taggable');
});
The product gets the relation, with its return type as in Step 5. The morph map goes to AppServiceProvider::boot(),
and Tag is a plain model listed in resources of config/access.php as 'tag' => App\Models\Tag::class:
class Product extends Model
{
use HasAccessScope;
public function tags(): MorphToMany { return $this->morphToMany(Tag::class, 'taggable'); }
}
Relation::enforceMorphMap(['product' => Product::class, 'category' => Category::class]);
Data. Phone is tagged sale and new, Charger sale, Battery pack new. And the category Laptops, id 3, is
tagged sale: it shares its id with the Battery pack, so a subquery that forgot the morph type would let the Battery pack in.
Access::newRule('products.view', 'View products', resource: 'product');
$ann->addPermission('products.view', when: "exists(product.tags, name == 'sale')"); // Phone, Charger
$ann->addPermission('orders.view', when: "exists(order.products, exists(tags, name == 'sale'))"); // orders 1, 4, 6
The second line reads the tags from the other side: orders that hold a product on sale. Order 2 holds only the Battery pack.
select * from "products" where exists (
select 1 from "tags" inner join "taggables" on "tags"."id" = "taggables"."tag_id"
where "products"."id" = "taggables"."taggable_id" and "taggables"."taggable_type" = ? and "tags"."name" = ?)
-- bindings: ["product", "sale"]
The package writes no joins of its own. For every relation in a condition it asks Eloquent for the subquery that
whereHas() builds, so the pivot table, the morph type and the morph map come along. morphMany works the same way.
The one relation the package refuses is morphTo: its far end is a different model for every row, so no single
subquery describes it. Ask from the other side, exists(product.tags, ...) instead of tag.taggable.
Coming from 2.x?
Nothing has to be converted. Tables, names of rules, options, .self, the trait and the console commands stay compatible.
composer require wnikk/laravel-access-rules:^3.0
php artisan vendor:publish --tag=access-migrations-upgrade
php artisan migrate
The migration adds a few columns and renames rules that 2.x had soft deleted. One thing to check by hand: delRule() in down() of your migrations now needs true to delete a rule that is still granted. The details are in the upgrade guide.
From the author: against Spatie and Bouncer, with numbers this time
Part one listed what set 2.x apart, and every point still holds in 3.x: a permission goes to a user, a group, a role or a plain name without a model, so users of an API or of a directory service need no table of their own; inheritance has no depth limit and runs between owners of any kind; a prohibition inherits like a permission and loses to an own permission; ownership is the suffix .self.
What 3.x added, the other two do not have; the table lists the differences only:
| this package | Spatie | Bouncer | |
|---|---|---|---|
| Roles inherit from roles | any depth | no, roles are flat | no |
| Prohibitions | yes, five-step priority | no | yes |
| Owners without a model | yes | no | no |
| Permission depends on data of the record | columns, relations, aggregates, the user, time | no, write a policy | ownership and single instances |
| Lists filtered by the same rules, in SQL | allowedTo() |
no | no |
| Why a check refused |
acr:explain, debug mode |
no | no |
| Stored rules checked against models | acr:lint |
no | no |
| XACML export and import | yes | no | no |
| Teams / tenants | inheritance and user.tenant
|
teams | scopes |
The cost of a check comes from one run on one machine: a Laravel 13 application with the three packages installed, the same data for each, MySQL, 2 000 abilities, 20 roles, a user who holds 500 permissions, 5 000 other users, one request of a signed-in user through Gate::allows(). Three runs, the median:
| this package | Spatie | Bouncer | |
|---|---|---|---|
| first check of a request | 0.2 ms, 0 queries, 51 KB | 5.0 ms, 2 queries, 3.9 MB | 4.2 ms, 0 queries |
| next check, permitted | 4.0 µs | 28.9 µs | 4 169 µs |
| next check, not permitted | 4.1 µs | 1 461 µs | 4 198 µs |
| a page with 50 checks | 0.2 ms | 2.5 ms | 207 ms |
The design explains the numbers. A request loads the permissions of one owner, compiled once; every check after the first reads memory, so the cost does not grow with the size of the application. Spatie loads the whole list of permissions on every request and looks a name up in it, which is the 3.9 MB and the 5 ms. Bouncer reads the abilities of the user from the cache store on every check, a file read or a network round trip each time. The third line of the table matters in every application that also uses policies: Gate::before of a permission package runs for every ability, $user->can('update', $post) included, so a slow "not permitted" is paid on checks that have nothing to do with permissions.
Versions, the method and what these numbers do not say are in Performance, with the way to repeat the run in an empty application. Spatie and Bouncer do RBAC and have years of use behind them; pick this package when access depends on data, when lists must follow the same rules, or when the cost of a check matters.
Final words
We started with roles in part one. Now a permission can look at a column, at the user, at a related record, at a sum over a relation, at a tree, at the clock, through a polymorphic relation. And every one of them filters a list with the same line of text.
Where to go next:
- Conditions: the whole language on one page
- Basic usage: rules, roles, options, cache, console
- XACML: what is exported and how foreign policies convert
The package: wnikk/laravel-access-rules on GitHub and Packagist. An administration panel for it, with the editor of conditions, the explanation of a check and XACML in the browser, is wnikk/laravel-access-ui.
If something in the examples does not answer the way the article says, open an issue: every example here is also a test of the package.











Top comments (2)
The strongest claim in the whole piece is Example 2's: the list and the single check always agree. That's the failure mode I've actually lived through — a policy that answers correctly for one record, and a query scope written six months later by someone else that quietly forgot the new "except Paris" carve-out. If the same line of text produces both the Gate check and the WHERE clause, that drift is structurally impossible, and that's a bigger deal than the syntax sugar.
Sharp question, though: Example 1 says a mistyped column passes validation because it might be an accessor. But an accessor can't become a WHERE clause. So what does allowedTo() do with a permission whose condition leans on an accessor — throw at query time, fall back to filtering in PHP, or something else? "Never disagree" holds only if the failure mode is loud, and that's the edge I'd want to know before betting the list on it.
Good question, and the answer is: it fails loud.
allowedTo() compiles the condition straight into SQL and never falls back to PHP. A column that is not in the table makes the database refuse the query, so you get a QueryException naming the column, not a longer list. The single check reads the attribute from the loaded record: a typo reads as null and the permission is simply not applicable. So for a typo both sides say “no”. A real accessor is the one case where they can differ, and that is exactly why acr:lint flags it: “not a column of table orders: a loaded record may still have it as an accessor, a list cannot filter by it”. Run lint in CI and after migrations and the drift never ships.
Rule of thumb: a condition may read only what a WHERE clause can read. Functions over columns are refused at list time too (UntranslatableConditionException).
And one correction to my own piece: XACML is not only a comparison.