Index cached permissions by name/key so findByName()/findById() are O(1)

Merged
CH
1d ago
+492-0

Problem

PermissionRegistrar::getPermissions() answers every single-record lookup by filtering the whole cached permission collection, evaluating $permission->getAttribute($attr) == $value on each item. #1799 made this stop at the first hit, but it is still a linear scan with per-model attribute access.

Every Gate check goes through this path:

  • with register_permission_check_method on, the Gate::before callback calls checkPermissionTo($ability) → findByName() for every ability, including policy abilities like view/update that are never permission names (guaranteed full scan + PermissionDoesNotExist);
  • policies that then call $user->can('some permission') do it again.

Profiling a Filament admin list page (~200 gate checks, ~216 permissions) with Xdebug: ~54% of request CPU was inside getPermissions() — ~43k closure invocations / ~45k Eloquent attribute reads per request just to locate permissions.

Change

  • Build a name → permission and {keyName} → permission index, per guard, when the collection is hydrated in loadPermissions().
  • getPermissions($params, onlyOne: true) short-circuits to the index for the two shapes findByName() / findById() use (['name'|keyName => …, 'guard_name' => …], both scalar). Any other shape (extra params, onlyOne: false, non-scalar values, custom attributes) falls back to the existing filter unchanged.
  • The index lives and dies with the collection: rebuilt in loadPermissions(), cleared in forgetCachedPermissions() and clearPermissionsCollection() (Octane listener path included).
  • No public API change; returns the same model instances the filter would.

One behavioural nuance worth calling out: the index compares (string) values exactly, where the filter used loose ==. For id/guard_name/name as used by findByName()/findById() this is equivalent (int id vs numeric string still matches); only numeric-string permission names like "01" vs "1" would differ — and exact is arguably the correct behaviour there.

Numbers

Micro-benchmark in the package test suite (305 permissions, 500 lookups, PHP 8.4):

mainthis PR
findByName() hit108 µs13 µs
findByName() miss288 µs21 µs

Real app (Laravel 13 / Filament 5, ~216 permissions), in-process warm-kernel medians: list pages 25–35% faster (/admin/customers 126→85 ms, /admin/rewards 112→76 ms).

Tests

  • 5 new tests in tests/Integration/PermissionRegistrarTest.php: index parity with the filter for every fixture permission by name and by key; same instance returned by indexed vs filtered lookup; no cross-guard resolution; non-indexable shapes still filter; index rebuilt after Permission::create() and after clearPermissionsCollection().
  • Full suite green (698 passed), pint clean, phpstan clean.