Index cached permissions by name/key so findByName()/findById() are O(1)
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_methodon, theGate::beforecallback callscheckPermissionTo($ability)→findByName()for every ability, including policy abilities likeview/updatethat 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 → permissionand{keyName} → permissionindex, per guard, when the collection is hydrated inloadPermissions(). getPermissions($params, onlyOne: true)short-circuits to the index for the two shapesfindByName()/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 inforgetCachedPermissions()andclearPermissionsCollection()(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):
| main | this PR | |
|---|---|---|
findByName() hit | 108 µs | 13 µs |
findByName() miss | 288 µs | 21 µ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 afterPermission::create()and afterclearPermissionsCollection(). - Full suite green (698 passed), pint clean, phpstan clean.