sinemacula / laravel-authorization
Role-based access control and IAM-style policy evaluation for Laravel
Package info
github.com/sinemacula/laravel-authorization
pkg:composer/sinemacula/laravel-authorization
Requires
- php: ^8.3
- ext-mbstring: *
- laravel/framework: ^12.40|^13.3
Requires (Dev)
- brianium/paratest: ^7.20
- doctrine/coding-standard: ^14.0
- friendsofphp/php-cs-fixer: ^3.95.19
- infection/infection: ^0.33.2
- larastan/larastan: ^3.9
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.2|^11.0
- phpbench/phpbench: ^1.6.1
- phpstan/extension-installer: ^1.4
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-mockery: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.5
- sinemacula/coding-standards-laravel: ^1.6.3
- slevomat/coding-standard: ^8.28
- squizlabs/php_codesniffer: ^3.13.6 || ^4.0.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- dev-feature/full-decision-middleware
- dev-feature/subject-resolver
- dev-feature/permission-sync
- dev-feature/schema-1-0
- dev-release-please--branches--master
- dev-bugfix/log-to-the-configured-channel
- dev-bugfix/request-scoped-state
- dev-bugfix/fail-closed-policy-evaluation
- dev-bugfix/configured-models-everywhere
- dev-bugfix/names-allow-dots
- dev-bugfix/system-records-stay-protected
- dev-bugfix/authorization-exception-is-laravels
- dev-chore/match-package-description
- dev-docs/align-repository-documents
- dev-chore/align-tooling-with-suite-standards
- dev-chore/pre-release-prep
This package is auto-updated.
Last update: 2026-11-05 08:57:43 UTC
README
Role-based access control and IAM-style policy evaluation for Laravel. Decides what a principal may do: roles and permissions granted to any Eloquent model, policy documents that allow or deny actions on resources under conditions, and one evaluator that combines them.
Drops into Laravel's existing authorisation machinery: permission enums become Gate abilities, so Gate::allows(),
@can, can: middleware and $user->can() work unchanged. Adds role and permission route middleware, Blade
directives, console commands and test assertions.
Core Concept
| Concept | What it is |
|---|---|
| Principal | Who a decision is about: any Eloquent model that implements AuthorizableIdentity, usually your user model. |
| Role | A named set of permissions, granted as one. Roles can inherit from a parent role. |
| Permission | A named action, such as posts:update or posts.update, or a wildcard pattern of them, such as posts:*. |
| Policy | A JSON document of statements, each allowing or denying actions on resources, optionally under conditions. |
| Decision | The answer to one check: allowed or not, with the reason and a trace of the statements that decided it. |
Every check runs the same four steps: an explicit deny in any attached policy wins, then an explicit allow, then the principal's role and permission grants, and anything left is denied.
Features
- Roles and permissions as Eloquent models with UUID keys, granted to any model through polymorphic pivots (integer, UUID and ULID keys all work), each grant with an optional expiry.
- Role and permission names of letters, digits and
_ - : . *, soposts:edit,posts.editandsso.connection.trustare all valid; anything else is refused when the name is saved. - Wildcard permissions (
posts:*,posts.*,*:create, and*for everything;*:*matches only names with a:) and role hierarchy: a role inherits its parent's permissions, with cycle checks on write. - Policy documents with
effect,actions,resourcesandconditions, eighteen condition operators (equality, set membership, prefix, suffix and glob, IPv4 and IPv6 CIDR, numeric and time comparisons, booleans and nulls) and${…}interpolation from the check's context. Malformed documents are refused when they are saved. - Gate auto-wiring: every case of a configured permission enum is registered as a Gate ability, with a choice of what happens when an ability of the same name already exists.
- Route middleware and Blade directives for roles and permissions, with
|or,between alternatives. - System records: roles, permissions and policies flagged
is_systemcannot be edited, deleted or have the flag cleared unlessforceSystem()unlocks the next change. - Explanations:
Authorization::explain(), the last decision of the request, andauthorization:why-canon the command line. - Events for every grant, revocation, expiry change and model change, for each decision, and when
authorize()refuses. - Resolution cache for role and permission lookups, in memory and optionally in a cache store.
- Swappable everything: models, table and pivot column names, the principal resolver, the tenant resolver and the policy store.
- Spatie import:
authorization:migrate-spatiecopies roles, permissions and grants fromspatie/laravel-permission, reporting and skipping any role or permission whose name the package refuses, and any permission whose name contains*, along with its grants.
Installation
composer require sinemacula/laravel-authorization
Configuration
Publish the config and the migrations, then migrate:
php artisan vendor:publish --tag=authorization-config php artisan vendor:publish --tag=authorization-migrations php artisan migrate
The package decides nothing about the current user until you tell it where to find one. The shipped principal
resolver returns null, so checks made without a principal are denied. To check the user your auth guard
authenticated, bind the shipped guard resolver in config/authorization.php:
use SineMacula\Laravel\Authorization\Resolvers\AuthGuardPrincipalResolver; 'principal_resolver' => AuthGuardPrincipalResolver::class,
List the enums whose cases should become Gate abilities:
'permission_enums' => [ App\Authorization\PostPermission::class, ],
The package writes its warnings (a Gate conflict under the log mode, a discarded cache entry, a policy whose document
cannot be read) to your default log channel. To send them elsewhere, name a channel from config/logging.php:
AUTHORIZATION_LOG_CHANNEL=stderr
config/authorization.php documents every other option: models, tables and pivot columns, the default guard, permission
providers, the Gate conflict mode, the policy store, the tenant resolver, the cache, and the role hierarchy and rank.
Usage
Give your user model the contract and the trait:
namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use SineMacula\Laravel\Authorization\Concerns\HasAuthorization; use SineMacula\Laravel\Authorization\Contracts\AuthorizableIdentity; class User extends Authenticatable implements AuthorizableIdentity { use HasAuthorization; }
Define permissions as an enum:
namespace App\Authorization; use SineMacula\Laravel\Authorization\Contracts\PermissionEnum; enum PostPermission: string implements PermissionEnum { case UPDATE = 'posts:update'; case DELETE = 'posts:delete'; public function toString(): string { return $this->value; } }
Grant roles and permissions, then check them as with any Laravel ability:
use Illuminate\Support\Facades\Gate; use SineMacula\Laravel\Authorization\Models\Permission; use SineMacula\Laravel\Authorization\Models\Role; Permission::create(['name' => 'posts:update']); Permission::create(['name' => 'posts:delete']); Role::create(['name' => 'editor'])->givePermission('posts:update'); $user->assignRole('editor'); $user->givePermission('posts:delete', now()->addDay()); $user->hasRole('editor'); // true $user->hasPermission('posts:update'); // true, through the role Gate::forUser($user)->allows('posts:update');
Route::put('/posts/{post}', UpdatePost::class)->middleware('permission:posts:update'); Route::get('/admin', AdminDashboard::class)->middleware('role:admin|editor');
@can('posts:update') <button>Edit</button> @endcan @role('admin') <a href="/admin">Admin</a> @endrole
Attach a policy to refine what the grants allow. An explicit deny wins over any role:
use SineMacula\Laravel\Authorization\Facades\Authorization; use SineMacula\Laravel\Authorization\Models\Policy; $user->attachPolicy(Policy::create([ 'name' => 'no-deletes-off-network', 'document' => [ 'statements' => [ [ 'effect' => 'deny', 'actions' => ['posts:delete'], 'resources' => ['*'], 'conditions' => ['ip' => ['not_in' => ['10.0.0.1', '10.0.0.2']]], ], ], ], ])); Authorization::for($user)->can('posts:delete', 'posts/42', ['ip' => request()->ip()]); Authorization::for($user)->explain('posts:delete', 'posts/42', ['ip' => request()->ip()]);
Policies fail closed. A deny whose condition cannot be decided, such as a check made without ip in its context,
still applies, while an allow in the same position does not. A check without a resource matches only statements whose
resources include *. A stored policy that cannot be read denies every check for its holder, with the reason
invalid_policy, until it is fixed.
Authorization::authorize() throws SineMacula\Laravel\Authorization\Exceptions\AuthorizationException when the check
is denied. It extends Laravel's Illuminate\Auth\Access\AuthorizationException, so Laravel's handler answers 403. It
carries the decision's reason code (getReason()) and nothing else: its message is the translated
authorization::authorization.denied line, and the action, the resource and the policies stay out of it. The full
decision is on the AuthorizationFailed event and Authorization::lastDecision(). To reword the message or add a
locale, publish the language files:
php artisan vendor:publish --tag=authorization-lang
From the command line, naming an identity as {morph type}:{key}, where the type is a morph map alias or a class:
php artisan authorization:grant user:1 editor php artisan authorization:revoke user:1 editor php artisan authorization:list-roles php artisan authorization:list-permissions php artisan authorization:why-can user:1 posts:delete posts/42
The listing commands are administrative views: they list every role or permission, platform-wide and tenant-owned
alike, and show each one's owner. grant and revoke look the role up within the tenant scope, so without a resolved
tenant they find platform-wide roles only.
In your tests, SineMacula\Laravel\Authorization\Testing\Concerns\AuthorizationAssertions adds assertCan(),
assertCannot(), assertHasRole() and assertHasPermission().
Design notes
The contracts behind the evaluator, for maintainers and reviewers, are in docs/design:
| Note | Covers |
|---|---|
| Evaluation order and deny precedence | The four-step decision order and why an explicit deny wins |
| Wildcard and condition semantics | Pattern matching, undecidable tests and condition operators |
| Polymorphic identity pivots | Why grants work for integer, UUID and ULID keys |
| Principal resolver contract | The default resolver and how an application binds its own |
| Model extension | Swapping the shipped models for your own subclasses |
| Impersonation | Checking as another principal |
| Spatie compatibility | The import from spatie/laravel-permission and its aliases |
Requirements
- PHP ^8.3 (extension: mbstring)
- Laravel ^12.40 || ^13.3
Testing
composer test # all suites in parallel (Paratest) composer test:coverage # all suites with clover coverage composer test:unit # unit suite only composer test:feature # feature suite only composer test:integration # integration suite only composer test:performance # performance budget suite (serial) composer test:mutation # scoped mutation gate composer test:mutation:full # full mutation suite (no thresholds) composer bench # PHPBench hot-path benchmarks composer check # static analysis + style composer format # format the codebase composer smells # advisory code smells (duplication, complexity)
Changelog
See CHANGELOG.md for a list of notable changes.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md for guidelines on branching, commits, code quality, and pull requests.
Security
If you discover a security vulnerability, please report it responsibly. See SECURITY.md for the disclosure policy and contact details.
License
Licensed under the Apache License, Version 2.0.