vaened / php-sentinel
A framework-agnostic authorization Core for PHP. Roles and permissions with deny-overrides-grant precedence built in.
Requires
- php: ^8.4
- psr/simple-cache: ^3.0
- vaened/support: ^4.2
Requires (Dev)
- phpunit/phpunit: ^11.5
README
Framework-agnostic authorization core for PHP 8.4+.
// Registry: handled by a seeder or your admin UI $admin = $roleRegistry->create('admin', 'Administrator'); $edit = $permissionRegistry->create('posts.edit', 'Edit Posts'); // Assignment $granter->grant($admin, $edit); $granter->grant($user, $admin); // Evaluation $authorizer->can($user, ['posts.edit']); // true $authorizer->is($user, ['admin']); // true // Deny overrides inherited grant $denier->deny($user, $edit); $authorizer->can($user, ['posts.edit']); // false // Revoke clears any denial or previous assignment $revoker->revoke($user, $edit); $authorizer->can($user, ['posts.edit']); // true again
Installation
composer require vaened/php-sentinel
Requires PHP 8.4 or higher. Its only dependency is vaened/support.
The example assumes concrete model and persistence implementations are already wired in your application bootstrap.
Integration surface
Sentinel provides the Authorizer, the operators, and the Registry
services, including the concrete PermissionEntryProvider and
RoleEntryProvider.
The package consumer implements the domain and repository contracts.
Foundations
These contracts define the minimum model Sentinel needs to evaluate authorization.
-
Subject
- Contract:
Subject - Represents the subject requesting permissions.
- Sentinel only requires:
id(): int|string|IdentifierIdentifier:Identifier
- Contract:
-
Authorization
- Contract:
Authorization - Defines:
code(): string— the canonical identity Sentinel works with.
- Metadata fields live on the entity contracts that need them.
- Derived contracts:
- Role
- Contract:
Role - Represents a composite authorization. A role groups permissions.
- Provides
id(),name(),description()for catalog use.
- Contract:
- Permission
- Contract:
Permission - Represents an atomic authorization.
- Provides
id(),name(),description()for catalog use.
- Contract:
- SubjectPermission
- Contract:
SubjectPermission - Represents a
subject ↔ permissionlink. - Provides
state(): SubjectPermissionState. - State enum:
SubjectPermissionState - The state distinguishes:
Denied— direct deny on the subjectDirect— direct grant on the subjectInherited— grant inherited through a role
- Contract:
- Role
- Contract:
Repositories
Repositories persist both the catalog and the relationships between subjects, roles, and permissions.
-
RoleRepository
- Contract:
RoleRepository - Stores role records with
id,code,name, anddescription. lookup(...)returns the typedRolescollection (concreteRoleinstances).
- Contract:
-
PermissionRepository
- Contract:
PermissionRepository - Stores permission records with
id,code,name, anddescription. lookup(...)returns the typedPermissionscollection (concretePermissioninstances).
- Contract:
-
SubjectRoleRepository
- Contract:
SubjectRoleRepository - Persists
subject ↔ rolelinks. grants($subject, $codes)resolves permissions inherited through the subject’s roles:nullresolves every inherited permission.- A populated array resolves only matching permission codes.
- Contract:
-
SubjectPermissionRepository
- Contract:
SubjectPermissionRepository - Persists
subject ↔ permissionlinks. - Persisted assignments resolve to
DeniedorDirect.Inheritedis derived at runtime by Sentinel when a permission comes from a role.- Writes receive
SubjectPermissionSnapshotvalue objects.
- Writes receive
- Contract:
-
RolePermissionRepository
- Contract:
RolePermissionRepository - Persists
role ↔ permissionlinks. - Roles only grant permissions; they do not support explicit denials.
- Contract:
Each repository exposes the combination of lookup, grants, exists, allOf, create, update, and remove that belongs to its
own contract.
Entry providers
The entry providers are concrete core services. They compose repositories and return the entries consumed by the Authorizer.
-
PermissionEntryProvider
- Service:
PermissionEntryProvider - Encodes the effective precedence of permissions for a subject.
- Invariants:
- it only returns codes that were requested;
- direct subject permissions are resolved first, and missing codes are completed through role grants;
- deny overrides inherited grant: a direct denial on the subject overrides any permission inherited from a role.
- Service:
-
RoleEntryProvider
- Service:
RoleEntryProvider - Resolves which requested role codes are effectively assigned to the subject.
- It does not define precedence rules.
- Service:
Caching the authorization checks
Calls to can, cannot, is, and isnt are the hot path of any authorization system. Sentinel provides an optional cache layer
that avoids recomputing a subject's effective authorization projection on every check.
What gets cached
Sentinel caches the effective authorization projection of a subject: the assigned roles and the state of each applicable permission,
whether direct or inherited through a role. That projection is built once per subject and then reused on every subsequent can / is
check.
For cached permissions, Sentinel preserves three states:
Denied— direct deny on the subjectDirect— direct grant on the subjectInherited— grant inherited through a role
What the cache does NOT do:
- It does not cache the role or permission catalog. Those entities are cold data. If you remove roles or permissions from the catalog, global invalidation causes projections to be rebuilt on the next access.
- It does not cache catalog-to-catalog relations (
role → permission) for the same reason. - It does not cache repository
exists()calls. Those are point validation operations, not part of the hot path.
Decisions you have to make
Before wiring the layer, there are three operational decisions you need to make:
-
Provide a PSR-16 driver. The cache layer is built on top of any
Psr\SimpleCache\CacheInterfaceimplementation — Redis, Memcached, APCu, file, whatever you already use. Without a driver, there is no cache. If your framework already gives you a PSR-16 driver (Laravel, Symfony, etc.), use it. -
Choose a unique
prefixper application. Keys are stored asprefix:...in the driver. If two applications share the same PSR-16 driver (common in multi-tenant setups), they need different prefixes. Suggested convention:app_name:sentinel. -
Understand what the default
ttldoes. Sentinel applies a defaultttlof 12 hours (CacheSettings::DEFAULT_TTL_IN_SECONDS). A projection lives for exactly that long from the moment it is first captured — it is not renewed on every read: it is only renewed when it is rebuilt. In practice:- If the projection is queried within 12 hours of being captured, it is served from cache.
- If more than 12 hours pass, the next query rebuilds the projection (a single database hit) and starts a new 12-hour window.
- An inactive subject leaves its projection orphaned; the driver releases it after the TTL. This keeps cache memory from growing without bound if the driver does not have its own eviction policy.
Pass
ttl: nullif you want projections to never expire by time and prefer to manage cleanup through an external mechanism.
How to wire it up
First, choose how to construct the factory. Then use that factory to wrap your five base repositories.
Using the bundled PSR-16 store
If you already have a driver compatible with Psr\SimpleCache\CacheInterface, this is the direct
path:
use Vaened\Sentinel\Cache\CacheSettings; use Vaened\Sentinel\Cache\SentinelCacheFactory; $factory = SentinelCacheFactory::from( driver: $psr16Driver, settings: new CacheSettings(prefix: 'my_app:sentinel'), );
Using your own authorization cache store
If you need a different storage or invalidation strategy, you can implement
AuthorizationCacheStore and hand it to the factory:
use Vaened\Sentinel\Cache\AuthorizationCacheStore; use Vaened\Sentinel\Cache\SentinelCacheFactory; $factory = SentinelCacheFactory::as(new MyCustomCacheStore(...));
Building the cached repositories
Once you have the factory, wrap your five base repositories and get a
CachedRepositories:
$cached = $factory->build( roles: $myRoleRepo, permissions: $myPermissionRepo, rolePermissions: $myRolePermissionRepo, subjectRoles: $mySubjectRoleRepo, subjectPermissions: $mySubjectPermissionRepo, ); $cached->roleRepository(); $cached->permissionRepository(); $cached->rolePermissionRepository(); $cached->subjectRoleRepository(); $cached->subjectPermissionRepository();
The returned repositories implement the same interfaces as the base ones. Your consumer code does not change: you pass
$cached->subjectRoleRepository() where you used to pass $mySubjectRoleRepo.
Authorization cache store
AuthorizationCacheStore is the contract that encapsulates projection storage, global
invalidation, per-subject invalidation, and construction of the effective cache key.
Stores receive a typed SubjectAuthorizationProjection. Use toArray() when
crossing a serialization boundary and SubjectAuthorizationProjection::fromArray() when restoring a payload into a projection.
Sentinel includes a default implementation based on PSR-16:
Psr16AuthorizationCacheStore.
You can create your own implementation if you need:
- a different invalidation policy;
- a different namespace or versioning strategy;
- integration with tags, events, or framework-specific mechanisms;
- finer control over how orphaned entries are cleaned up.
The main limitation of the default PSR-16 implementation is that, when you call invalidate(), previous projections stop being read
immediately, but remain orphaned and continue occupying space in the driver until their TTL expires.
Invalidating manually
The factory does not expose the internal invalidation services. If you need to invalidate manually (for example, in an artisan command,
after a large migration, or when a specific subject changed roles), instantiate Psr16AuthorizationCacheStore separately with the same
driver and the same settings:
use Vaened\Sentinel\Cache\CacheSettings; use Vaened\Sentinel\Cache\Stores\Psr16AuthorizationCacheStore; $store = new Psr16AuthorizationCacheStore( cache: $psr16Driver, settings: new CacheSettings(prefix: 'my_app:sentinel'), ); $store->invalidate(); // bumps the global version $store->forget($subject); // invalidates one specific subject
Important: database mutations performed outside Sentinel operators (direct SQL queries, other processes, improvised seeds) do not invalidate the cache automatically. If that happens, call
invalidate()orforget()manually.
Inspecting the cache
If you inspect your PSR-16 driver with an external tool (Redis Commander, APCu GUI, etc.), you will see keys in this format:
{prefix}:version # global version counter
{prefix}:v{N}:subject:{class}:{id}:projection # one subject projection
{N} is the active namespace version. Every time you call invalidate(), all projections move to v{N+1} and the previous ones become
orphaned (they are no longer read, and the driver eventually clears them).
Implementation references
- Reference wiring:
tests/Integration/AuthorizerFlowTest.php - Reference in-memory implementations:
tests/Runtime/ - In-memory repositories:
tests/Runtime/Repositories/ - Core
PermissionEntryProvider:src/Authorization/PermissionEntryProvider.php - Core
RoleEntryProvider:src/Authorization/RoleEntryProvider.php
Authorizer
Authorizer is the read gate. It combines a PermissionEntryProvider and a RoleEntryProvider and
answers boolean questions about a Subject. It is constructed once with both providers and is then ready to answer can, cannot,
is, and isnt at any time.
can()
Returns true when the subject has at least one of the requested permissions, or all of them when you pass Junction::And.
$subject:Subject— the subject being evaluated.$permissions:array<string>— the permission codes to evaluate. Always an array, never a bare string.$junction:Junction(default:Junction::Or) — the combinator.
cannot()
Inverse of can(). Same signature.
is()
Returns true when the subject has at least one of the requested roles, or all of them when you pass Junction::And.
$subject:Subject— the subject being evaluated.$roles:array<string>— the role codes to evaluate.$junction:Junction(default:Junction::Or) — the combinator.
isnt()
Inverse of is(). Same signature.
Junction
Enum with two cases:
Junction::Or(default) — one match is enough.Junction::And— every code must be allowed.
Operators
The three operators mutate state through your repositories. All of them are variadic, so they can attach or detach multiple items in a single call.
Granter
Granter grants assignments.
$granter->grant($user, $admin); // user has the admin role $granter->grant($admin, $edit, $delete); // admin role has two permissions $granter->grant($user, $admin, $edit, $delete); // everything in one call
It accepts Subject or Role as owner. If it receives a Role as owner and another Role as authorization, it throws
InvalidAuthorization: roles cannot contain other roles.
Denier
Denier explicitly denies permissions to a subject. It applies the central rule: a direct denial overrides any
inherited permission.
$denier->deny($user, $edit); $denier->deny($user, $edit, $delete); // variadic
It only accepts Subject as an owner. Roles do not support denials.
Revoker
Revoker removes any previous assignment, whether it was a grant or a denial. It is idempotent: if the
assignment does not exist, it makes no changes.
$revoker->revoke($user, $admin); $revoker->revoke($user, $edit, $delete);
It accepts Subject or Role as owner.
Registry
PermissionRegistry and RoleRegistry manage the registration
operations for each entity: create, update, remove, plus read helpers for administration (lookup, find).
$roleRegistry = new RoleRegistry($roleRepository, $subjectRoleRepository); $permissionRegistry = new PermissionRegistry($permissionRepository, $subjectPermissionRepository, $rolePermissionRepository); $admin = $roleRegistry->create('admin', 'Administrator'); $roleRegistry->update($admin->id(), 'Administrator', 'Full access'); $roleRegistry->remove($admin->id());
create()returns the entity with its assigned id. It throws*AlreadyExistswhen the code already exists.update()operates by id. Passingnullas description clears it.remove()is idempotent: if the id does not exist, it makes no changes. If the entity is in use, it throws*InUse.lookup(array $codes)returns the typed collection (Roles/Permissions) of the entities whose codes match.find(string $code)returns the single entity matching the code, ornullif no entity has that code.
Errors
All exceptions thrown by Sentinel extend AuthorizationError, which itself extends RuntimeException.
To catch any Sentinel error:
try { $roleRegistry->create('admin', 'Administrator'); } catch (AuthorizationError $e) { // covers PermissionAlreadyExists, RoleNotFound, PermissionInUse, etc. }
Specific exceptions:
| Exception | Thrown when |
|---|---|
PermissionAlreadyExists |
PermissionRegistry::create receives a code that already exists. |
RoleAlreadyExists |
RoleRegistry::create receives a code that already exists. |
PermissionNotFound |
PermissionRegistry::update or an operator cannot find the id/code. |
RoleNotFound |
RoleRegistry::update or an operator cannot find the id/code. |
PermissionInUse |
PermissionRegistry::remove detects a linked subject or role. |
RoleInUse |
RoleRegistry::remove detects a subject linked to that role. |
InvalidAuthorization |
Granter receives roles as targets from an owner that is also a role. |
Development
make composer-install
make test
Additional documentation
You can find more details in the source code as well as in the tests located in tests/.
The tests cover different usage scenarios and can serve as additional reference for understanding the library’s behavior.