halfshellstudios / permissions
Laravel permission gating on top of Spatie, with config-defined roles and per-user resource overrides.
Requires
- php: ^8.5
- illuminate/auth: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/support: ^13.0
- spatie/laravel-permission: ^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
- phpstan/phpstan: ^2.1
- rector/rector: ^2.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-09-15 23:28:55 UTC
README
Laravel permission gating on top of Spatie Laravel Permission. Define system roles and permissions in config, check them against a user (optionally scoped to a form, van, or any other model), and store per-user allow/deny overrides that win before Spatie is consulted.
Requirements
- PHP 8.5+
- Laravel 13
spatie/laravel-permission^8.0
Install
composer require halfshellstudios/permissions
Publish Spatie's migrations, then this package's config. Override-table migrations load automatically from the package.
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan vendor:publish --tag=permissions-config
php artisan migrate
That writes config/permissions.php. Edit it — resources, standalone permissions, and system roles — then sync into Spatie:
php artisan permissions:sync
Re-run sync whenever the published catalog changes. It creates missing permissions, creates or updates the system roles listed in config, and sets those roles' permissions to match. Custom Spatie roles you create yourself are left alone.
Add the trait to your user model. It includes Spatie's HasRoles:
use HalfShellStudios\Permissions\Concerns\ChecksPermissions; use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable { use ChecksPermissions; }
Assign a system role as you would any Spatie role:
$user->assignRole('editor');
Configure
config/permissions.php is the catalog. Publish it and override anything — including the shipped admin role.
return [ 'on_denied' => env('PERMISSIONS_ON_DENIED', 'exception'), 'guard' => 'web', 'resources' => [ 'forms' => ['view', 'create', 'edit', 'delete'], 'vans' => [ 'abilities' => ['view', 'create', 'edit', 'delete'], 'model' => App\Models\Van::class, // optional when the key is the plural snake name ], ], 'permissions' => [ 'settings.manage', ], 'roles' => [ 'admin' => ['*'], 'editor' => ['forms.view', 'forms.create', 'forms.edit', 'vans.view'], 'viewer' => ['forms.view', 'vans.view'], ], ];
That catalog produces forms.view, forms.create, forms.edit, forms.delete, vans.*, and settings.manage.
| Key | What it does |
|---|---|
on_denied |
exception (default) throws UnauthorizedException from check(). boolean returns false. allows() is always a boolean; authorize() always throws. |
guard |
Spatie guard used when syncing and checking. |
gate.enabled |
When true, $user->can() runs through this package so deny overrides are honoured. |
resources |
Resource key + abilities become forms.view, forms.edit, … |
permissions |
Standalone names that are not tied to a model. |
roles |
System roles. * means every permission in the catalog. |
driver |
user (default) or membership. Leave unset for current behaviour. |
roles_as_templates |
When true (default under membership), synced Spatie roles are templates — do not assignRole() tenant members. |
super_only |
Permission names only a platform super-admin may pass. Stripped from template roles. |
impersonation.unassume |
Permission that always checks the actor, not the assumed user. |
Checking
The entry point is Permissions::check(). By default a failed check throws UnauthorizedException (Laravel turns that into HTTP 403). Set permissions.on_denied to boolean to get false instead.
use HalfShellStudios\Permissions\Facades\Permissions; use HalfShellStudios\Permissions\Enums\Ability; Permissions::check($user, 'forms.edit'); // type-level Permissions::check($user, 'forms.edit', $form); // this form only Permissions::check($user, 'edit', $form); // same, short ability Permissions::check($user, Ability::Edit, $form); // same, enum Permissions::check($user, 'forms.create', Form::class);
allows() / denies() always return a boolean. authorize() always throws.
if (Permissions::allows($user, 'forms.delete', $form)) { $form->delete(); } Permissions::authorize($user, 'settings.manage');
Same helpers exist on the user when you use the trait:
$user->allows('edit', $form); $user->authorize('settings.manage'); $user->checkPermission('forms.view'); // honours on_denied
Gate
$user->can('forms.edit', $form) and Gate::allows(...) go through the same resolver. Set permissions.gate.enabled to false if you want Spatie to own Gate instead.
Middleware
Route::middleware('permissions:settings.manage')->group(function () { Route::get('/settings', [SettingsController::class, 'edit']); });
Guests get 401. Authenticated users without the permission get 403. Use Permissions::check() in the controller when the check is for a specific record. Middleware has no tenant id — in membership mode a named check without account context is denied (unless the subject is a super-admin).
Multi-tenant / membership mode
Default permissions.driver is user. That is today's path: record override → type override → global override → Spatie hasPermissionTo on the user. Apps that never set the driver do not migrate.
Set permissions.driver to membership for SaaS-style tenants. Roles in config become templates: permissions:sync still writes Spatie role rows and permissions, but hosts must not assignRole() those templates onto the user. A membership lookup names the template instead.
Permissions::allows($user, 'users.invite', TenantContext::account($account)); Permissions::allows($user, 'users.invite', TenantContext::group($accountId, $groupId)); Permissions::allows($user, 'users.invite', ['account' => $account, 'group' => $group]);
Resolution:
- Qualify the permission (unknown names still throw).
- Resolve the subject (
SubjectResolver— impersonation).users.unassumestays on the actor. - Super-admin (
SuperAdminChecker) allows. - Super-only permissions deny for everyone else.
- Require tenant context and a membership (
MembershipLookup). - Group override, then account override (
permission_tenant_overrides). - Else the Spatie role named like the membership role, not roles on the user.
The legacy permission_overrides table is unchanged. Membership mode uses a second table, permission_tenant_overrides (loaded automatically with the package migrations). Resource short-form ('edit', $form) stays on the user driver.
Bind host adapters — the package does not know App\Models\Account:
use HalfShellStudios\Permissions\Contracts\MembershipLookup; use HalfShellStudios\Permissions\Contracts\SuperAdminChecker; use HalfShellStudios\Permissions\Support\SessionSubjectResolver; $this->app->singleton(MembershipLookup::class, AccountMembershipLookup::class); $this->app->singleton(SuperAdminChecker::class, UserSuperAdminChecker::class); $this->app->singleton(\HalfShellStudios\Permissions\Contracts\SubjectResolver::class, SessionSubjectResolver::class);
Optional CatalogContributor implementations tagged permissions.catalog merge extra names (and super-only flags) into the config catalog. Do not depend on a module-contract package inside this one.
Modules
There is no separate Module type. A module is a standalone catalog permission you put on a route group, then keep resource checks underneath it.
'permissions' => [ 'fleet.access', 'forms.access', 'settings.manage', ], 'resources' => [ 'vans' => [ 'abilities' => ['view', 'create', 'edit', 'delete'], 'model' => App\Models\Van::class, ], 'forms' => ['view', 'create', 'edit', 'delete'], ], 'roles' => [ 'admin' => ['*'], 'dispatcher' => ['fleet.access', 'vans.view', 'vans.edit'], 'clerk' => ['forms.access', 'forms.view', 'forms.create'], ],
Run php artisan permissions:sync after changing that.
Module gate — wrap the whole module’s routes:
Route::middleware('permissions:fleet.access')->prefix('fleet')->group(function () { Route::get('/vans', [VanController::class, 'index']); Route::get('/vans/{van}', [VanController::class, 'show']); });
Record gate — still the resource, not the module:
$user->allows('edit', $van); // vans.edit $user->authorize(Ability::Edit, $van);
fleet.access means “may open Fleet”. vans.edit means “may edit this van”. A dispatcher without fleet.access never reaches the van page; one with access still needs vans.edit for a given record.
If you want the module in the permission name itself, use a dotted resource key:
'resources' => [ 'fleet.vans' => [ 'abilities' => ['view', 'create', 'edit', 'delete'], 'model' => App\Models\Van::class, ], ],
That becomes fleet.vans.view. $user->allows('view', $van) still qualifies via the model mapping.
Overrides work the same way: grant($user, 'fleet.access') or deny($user, 'edit', $van).
Resolution order
For a given user, permission, and optional resource:
- Record override — this user, this permission, this id
- Type override — this user, this permission, this model type
- Global override — this user, this permission
- Spatie — role (or direct) permissions from
spatie/laravel-permission
The most specific matching override wins. If nothing is stored, Spatie decides. If Spatie has not been synced yet, the check fails closed.
Overrides
Grant or remove a permission for one user without changing their role. Optionally scope it to a record or a model class.
Permissions::grant($user, 'forms.edit', $form); // this form only Permissions::deny($user, 'forms.edit', $form); // block this form Permissions::deny($user, 'forms.edit'); // block every form Permissions::clear($user, 'forms.edit', $form); // back to the role
Or via the user:
$user->grantPermission('edit', $form); $user->denyPermission('forms.delete'); $user->clearPermissionOverride('forms.delete');
Overrides live in permission_overrides. They are not Spatie permissions; they sit in front of Spatie.
Scenarios
Each one assumes the package is installed, config/permissions.php is published, and the user model uses ChecksPermissions. After any catalog change, run php artisan permissions:sync.
1. Add a module and let specific users in, view only
fleet.access opens the module. vans.view is the only van ability they get.
- Publish and edit
config/permissions.php:
'permissions' => [ 'fleet.access', 'settings.manage', ], 'resources' => [ 'vans' => [ 'abilities' => ['view', 'create', 'edit', 'delete'], 'model' => App\Models\Van::class, ], ], 'roles' => [ 'admin' => ['*'], 'fleet-viewer' => ['fleet.access', 'vans.view'], ],
- Sync:
php artisan vendor:publish --tag=permissions-config php artisan permissions:sync
- Wrap Fleet routes:
Route::middleware('permissions:fleet.access')->prefix('fleet')->group(function () { Route::get('/vans', [VanController::class, 'index']); Route::get('/vans/{van}', [VanController::class, 'show']); });
- Grant the people. A named group gets the role:
$user->assignRole('fleet-viewer');
A handful of users, no new role:
$user->grantPermission('fleet.access'); $user->grantPermission('vans.view');
One van only:
$user->grantPermission('fleet.access'); $user->grantPermission('view', $van);
- Still authorize the record:
$user->authorize('view', $van);
They can open Fleet and see vans. $user->allows('edit', $van) is false.
2. A viewer should edit one form, not every form
Keep them on viewer (forms.view only). Grant the record with a short ability or an Ability enum:
$user->assignRole('viewer'); $user->grantPermission('edit', $inspection); $user->grantPermission(Ability::Edit, $inspection); // same thing
$user->allows('edit', $inspection) is true. $user->allows('edit', $incident) is false. $user->allows('forms.edit') is still false.
3. An editor must not touch one form
Keep them on editor. Deny the record, then clear it when the block is over:
$user->assignRole('editor'); $user->denyPermission('edit', $incident); $user->clearPermissionOverride('edit', $incident);
They can still edit every other form while the deny is in place.
4. Take away a permission without changing the role
Global deny beats any role, including admin:
$user->denyPermission('settings.manage');
$user->allows('settings.manage') is false. $user->denies('settings.manage') is true. Remove it with clearPermissionOverride('settings.manage').
5. 403 a settings page for anyone who is not allowed
Route::middleware('permissions:settings.manage')->group(function () { Route::get('/settings', [SettingsController::class, 'edit']); });
Guests get 401. Authenticated users without the permission get 403. Assign admin, or grantPermission('settings.manage'), to let someone through. Middleware is for named permissions only — it has no form id.
6. Hide a button without throwing, but still 403 the action
allows() never throws. authorize() always does. check() / checkPermission() follow permissions.on_denied (exception by default, or boolean).
@if (auth()->user()->allows('edit', $form)) <a href="{{ route('forms.edit', $form) }}">Edit</a> @endif
public function update(Form $form): RedirectResponse { auth()->user()->authorize(Ability::Edit, $form); $form->update(/* ... */); return redirect()->route('forms.show', $form); }
config(['permissions.on_denied' => 'boolean']); auth()->user()->checkPermission('forms.edit'); // false, no exception
Blade conditions should use allows() either way. Set PERMISSIONS_ON_DENIED=boolean only when you want check() to return false.
7. Ship a new resource and refresh system roles
Add the resource (and any new role lines) in the published config. admin with * picks up every catalog permission. Other roles only get what you list.
'resources' => [ 'forms' => ['view', 'create', 'edit', 'delete'], 'vans' => ['view', 'create', 'edit', 'delete'], ], 'roles' => [ 'admin' => ['*'], 'viewer' => ['forms.view', 'vans.view'], ],
php artisan permissions:sync
Same command from code or the facade:
use HalfShellStudios\Permissions\Facades\Permissions; $result = Permissions::sync(); // $result->permissions, $result->roles
Custom Spatie roles you created yourself (for example contractor) are left alone.
8. Use Gate, Blade @can, and create-against-a-class
With permissions.gate.enabled left true (the default), $user->can() and @can use this package — overrides included.
@can('edit', $form) <button>Edit</button> @endcan @can('forms.create', App\Models\Form::class) <a href="{{ route('forms.create') }}">New form</a> @endcan
Gate::forUser($user)->allows('edit', $form); $user->can('forms.create', Form::class); Permissions::allows($user, 'create', Form::class);
Set permissions.gate.enabled to false only if you want Spatie to own Gate instead.
9. Allow every form of a type, then block one record
Resolution is record → type → global → Spatie. A type-level grant plus a record deny is the usual “all except this one”:
$user->grantPermission('forms.edit', Form::class); $user->denyPermission('edit', $incident); $user->allows('edit', $incident); // false — record deny $user->allows('edit', $inspection); // true — type grant $user->allows('forms.edit'); // false — no global grant
The other way around: a global deny plus a record grant is “nothing except this one”:
$user->denyPermission('forms.edit'); $user->grantPermission('edit', $inspection);
10. Call the package from the facade, or notice it fail closed
Same API as the trait, with an explicit user:
use HalfShellStudios\Permissions\Facades\Permissions; Permissions::allows($user, 'forms.view'); Permissions::denies($user, Ability::Delete, $form); Permissions::authorize($user, 'settings.manage'); Permissions::check($user, 'edit', $form); Permissions::grant($user, 'forms.edit', $form); Permissions::deny($user, 'forms.edit', $form); Permissions::clear($user, 'forms.edit', $form); Permissions::catalog()->has('forms.view');
Unknown names throw instead of failing open:
Permissions::allows($user, 'forms.publish'); // UnknownPermissionException: Unknown permission [forms.publish].
If you have not run permissions:sync yet, Spatie has no rows and every role check is denied. Sync first, then assign roles.
How it fits together
config/permissions.php Spatie tables permission_overrides
────────────────────── ──────────── ────────────────────
resources + permissions → permissions
roles + permission sets → roles + role_has_permissions
model_has_roles user + permission
+ optional resource
+ allow | deny
permissions:sync writes the left column into Spatie. Runtime checks read overrides first, then Spatie.
Design
The public config file stays a normal Laravel array so publishing and deploying it is boring on purpose. Inside the package that array is parsed once into DTOs and then every check, override, and sync step is an action. That split is the main design choice.
Spatie stores roles. This package gates. Spatie already does roles, direct permissions, and cache well. Rebuilding that would be wasted work. What Spatie does not do is “this user may edit this one form, but not the others” with a deny that beats the role. That lives in permission_overrides and is resolved before Spatie is asked.
Config is the source of truth for system roles. Editors and viewers should not be hand-built in a seeder that drifts from production. permissions:sync makes the catalog real in Spatie’s tables. Roles you create yourself in Spatie are left alone, so you can still have one-off contractor roles.
DTOs, not array soup. BuildCatalog turns config into Catalog, ResourceDefinition, RoleDefinition, and PermissionName. A check is a PermissionQuery (user + permission + ResourceRef + the raw resource used to qualify short abilities). Once those objects exist, later code does not re-parse 'forms' => ['view', 'edit']. Invalid config throws InvalidConfigException instead of skipping a key and failing open.
Actions do one thing. QualifyPermission, FindOverride, CheckPermission, AuthorizePermission, EnforcePermission, PutOverride, ClearOverride, and SyncRoles are small handle() classes. CheckPermission dispatches to a PermissionResolver: UserPermissionResolver is the default; MembershipPermissionResolver is opt-in. Permissions is a thin facade over them.
Enums name the vocabulary. Ability (view / create / edit / delete), Wildcard (*), OverrideEffect (allow / deny), DeniedBehavior (exception / boolean), GateOwner (this package / Spatie), and ResolutionDriver (user / membership) replace magic strings in PHP. The published config still uses strings because that is what env() and config() return; fromConfig() maps them.
Fail closed. An unknown permission throws. An unsynced Spatie table denies. A user that cannot answer hasPermissionTo denies. The catalog is the allow-list; anything else is not a permission.
This package owns Gate when enabled. Spatie’s Gate hook would ignore our deny overrides. When gate.enabled is true we disable Spatie’s registration and run $user->can() through the same resolver as allows(). Turn it off if you already have your own Gate story.
Middleware is for named permissions. permissions:settings.manage or permissions:fleet.access is a route gate. A module is that kind of name, not a first-class type. Record-scoped checks stay in the controller — middleware has no form id.
Teams and Filament are out of v1. Spatie teams did not have a clear fit for a config-defined catalog, and Filament can sit on allows() / Gate later without changing the core.
SaaS Engine integration
Host adoption is a follow-up. Required bindings when PERMISSIONS_DRIVER=membership:
| Binding | Purpose |
|---|---|
MembershipLookup |
find(UserId, TenantId): ?MembershipRoleName from memberships.role |
SuperAdminChecker |
users.is_super_admin (package default is never) |
SubjectResolver |
Impersonation. SessionSubjectResolver reads session('impersonator_id') and keeps users.unassume on the actor |
NormalizesTenantContext |
Optional. Default accepts TenantContext and ['account' => …, 'group' => …]. Bind your own to map Account / Group models |
permissions.catalog tag |
Optional CatalogContributor list for module permissions / super-only metadata |
Config sketch:
'driver' => 'membership', 'roles_as_templates' => true, 'super_only' => [ 'users.assume', 'users.unassume', 'setup.view', ], 'roles' => [ 'owner' => ['*'], 'admin' => [/* template names, never super-only */], 'member' => [/* … */], 'billing' => [/* … */], ],
Keep the host architecture rule: application code must not call hasRole / hasPermissionTo. Those stay inside this package. The host PermissionAuthorizer can thin out to Permissions::allows($user, $name, $account) once call sites move.
Local demo
composer demo boots a Testbench Laravel app with this package already installed. It is the fastest way to see checks, overrides, Gate, and middleware without creating a new Laravel project. CI does not run it.
The workbench does the install steps for you: Spatie tables, override migrations, a catalog equivalent to a published config/permissions.php, permissions:sync, then seeded users and forms. You do not publish config to use the demo. To change the catalog, edit workbench/app/Providers/WorkbenchServiceProvider.php and run composer demo again (it rebuilds and reseeds).
Spin it up
git clone git@github.com:Half-Shell-Studios/permissions.git
cd permissions
composer install
composer demo
The package needs PHP 8.5. If your host PHP is older (for example 8.3), composer demo starts Docker and runs the workbench there. First time on a machine without the image:
docker compose build docker compose up -d
Then composer demo again.
The command creates sqlite, migrates, seeds, then serves the app. It starts at http://localhost:8000. If that port is busy it binds the next free one (up to 8019) and prints the URL. Docker maps 8000-8019.
What you get
| User | Role | What to notice |
|---|---|---|
| Ada Admin | admin (*) |
Every catalog and form check is allowed. /settings opens. |
| Ed Editor | editor |
Can view/create/edit forms, except Incident edit — that is a seeded deny override. |
| Vi Viewer | viewer |
Can view forms. Inspection edit is a seeded grant override. /settings is 403. |
| Olly Outsider | none | Everything denied until you grant an override on the page. |
Two forms are seeded: Inspection and Incident.
How to use the page
- Open the URL printed by
composer demo. - Pick a person at the top. Each card says what their role is for.
- Use Four ways to ask on Inspection: hide a button (
allows()), ask true/false (check()), ask Laravel Gate, or stop the action (authorize()). Results open in a modal with the code. - Under What they can do and On each form, click yes/no to grant or deny just for that person. Clear a row under Overrides to fall back to the role.
- Open the settings page to see middleware (
permissions:settings.manage). Ada gets through; everyone else gets 403. - Open a Scenarios accordion for the ten README walkthroughs, with the snippet and a live try where this demo can run it.
The demo starts with on_denied as boolean so the tables can show check() as yes/no. Switch it to exception on the page to watch check() throw. authorize() and middleware always throw.
Ada has the admin wildcard, so every catalog and form check is allowed:
Vi only has forms.view from her role. The Inspection edit grant shows up as a record override:
Ada can open /settings. Vi gets UnauthorizedException (HTTP 403):
Demo code lives in workbench/ (routes/web.php, views, seeder, WorkbenchServiceProvider). That is also where you would look if you want to copy the “publish config → sync → assign roles → grant override” flow into a real app.
Local development
docker compose build docker compose up -d docker exec permissions composer install docker exec permissions composer check docker exec permissions composer test:mutate
| Script | What it runs |
|---|---|
composer check |
Pint, PHPStan, Rector dry-run, Pest |
composer test |
Pest 5 |
composer test:mutate |
Pest mutation testing (minimum 100%) |
composer pint |
Laravel Pint |
composer phpstan |
PHPStan + Larastan, level max |
composer rector |
Rector (PHP 8.5 sets) |
composer demo |
Local-only Laravel workbench (port 8000, or the next free port) |
CI on main runs the same quality job, then mutation testing.
Testing your app
Use Orchestra Testbench conventions, or call Permissions::sync() in your test setup after you point config/permissions.php at the catalog you want.
permissions()->sync(); $user = User::factory()->create(); $user->assignRole('editor'); expect($user->allows('edit', $form))->toBeTrue();



