apavliukov / laravel-authorization
Reusable, Spatie-permission-based authorization layer for Laravel: policies, abilities, roles, a permission registry, tenancy, and a super-admin bypass.
Package info
github.com/apavliukov/laravel-authorization
pkg:composer/apavliukov/laravel-authorization
Requires
- php: ^8.4
- illuminate/auth: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- spatie/laravel-permission: ^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.13
- livewire/livewire: ^4.4
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A reusable, Spatie-permission-based authorization layer for Laravel: resource policies, ability enums, role semantics, a permission registry, idempotent seeding, and a super-admin bypass.
The package owns the generic core. Your application keeps only what is genuinely app-specific: the role enum, concrete policies, the user model, and the declarations wiring them together.
Requirements
- PHP
^8.4 - Laravel
^13.0 spatie/laravel-permission^8.0
Installation
The package is published on Packagist:
composer require apavliukov/laravel-authorization
Make sure Spatie's permission tables are migrated (publish and run its migrations if you have not already):
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
The package's core AuthorizationServiceProvider is auto-discovered. It registers
the bindings and the Gate::before super-admin bypass.
Setup
1. Publish and register the app provider
php artisan vendor:publish --tag=authorization-provider
This writes app/Providers/AuthorizationServiceProvider.php — the one place where
your application declares its role enum, authorizable models, and system
abilities. Register it in bootstrap/providers.php:
return [ App\Providers\AppServiceProvider::class, App\Providers\AuthorizationServiceProvider::class, ];
The published provider looks like this:
use AlexPavliukov\Authorization\Authorization; use App\Enums\Policies\Role; use App\Models\User; use Illuminate\Support\ServiceProvider; final class AuthorizationServiceProvider extends ServiceProvider { public function boot(): void { Authorization::useRoleEnum(Role::class); Authorization::authorizableModels([ User::class, ]); // Define your app's system (model-less) abilities here, e.g.: // Gate::define(\App\Enums\SystemAbility::ACCESS_PLATFORM_ADMIN, static fn (): bool => false); } }
2. Implement the role enum
Your role enum implements AuthorizationRole. isSuperAdmin() drives the bypass;
permissions() is consumed by the seeder to grant per-role permissions;
byPriority() ranks the cases for primaryRole() and
must list every case exactly once — the seeder throws otherwise.
use AlexPavliukov\Authorization\Contracts\AuthorizationRole; enum Role: string implements AuthorizationRole { case ADMIN = 'admin'; case MEMBER = 'member'; /** @return list<self> */ public static function byPriority(): array { return [self::ADMIN, self::MEMBER]; } public function isSuperAdmin(): bool { return $this === self::ADMIN; } /** @return array<int, string> */ public function permissions(): array { return match ($this) { self::ADMIN, self::MEMBER => [], }; } }
Role presentation (labels, colors, layouts) is app-specific and stays out of the package — keep it on the enum or in a dedicated trait of your own.
3. Prepare the user model
The package relies on Spatie's HasRoles. Add HasPolicy so the model declares
which abilities generate permissions for it.
use AlexPavliukov\Authorization\Concerns\HasPolicy; use Illuminate\Foundation\Auth\User as Authenticatable; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { use HasPolicy; use HasRoles; }
Policies
Concrete policies extend AbstractPolicy and declare their model. The seven CRUD
methods map each ability to a permission string and check it against the user.
use AlexPavliukov\Authorization\AbstractPolicy; final readonly class PostPolicy extends AbstractPolicy { protected function getModelClass(): string { return Post::class; } }
Ownership / tenancy scoping (ownsModel())
The model-bound methods (view, update, delete, restore, forceDelete)
resolve to ownsModel($user, $model) && userCan(...). By default ownsModel()
returns true (no fencing). Override it to scope a model to the user — a
company_id / team_id match, a relation walk, etc. Model-less checks
(viewAny, create) never consult it.
abstract readonly class CompanyScopedPolicy extends AbstractPolicy { protected function ownsModel(Authenticatable $user, Model $model): bool { return $user->company?->id === $this->companyId($model); } protected function companyId(Model $model): ?int { return $model->company_id; } }
The CRUD methods are not final, so a policy that needs different logic (e.g.
"manage across the tenant OR own it") can override the method directly and call
parent::view(...) for the owns-and-can branch.
Attribute tenancy (TenantScopedPolicy)
For the common case — a model fenced to the current tenant by an owning column —
extend TenantScopedPolicy instead of hand-writing ownsModel(). It compares
Tenancy::id() (the tenancy layer must be configured) with the model's
tenancy.foreign_key column — nothing to declare:
use AlexPavliukov\Authorization\TenantScopedPolicy; // Reached by the default column — only getModelClass() needed: final readonly class LocationPolicy extends TenantScopedPolicy { protected function getModelClass(): string { return Location::class; } } // Reached through a relation — override tenantKey(): final readonly class ReviewPolicy extends TenantScopedPolicy { protected function getModelClass(): string { return Review::class; } protected function tenantKey(Model $model): int|string|null { return $model->location?->organization_id; } }
Abilities and permission names
-
Enums\Ability— the seven standard resource abilities (1:1 with policy methods). Values are camelCase so Gate routes them straight to policy methods. -
System abilities (model-less
Gate::define()checks, e.g. "access platform admin") are app-defined — declare your own enum; the package ships noSystemAbilityenum. Define a deny-by-default gate per case in your provider (only the super-admin bypass then grants it), and check with@can(SystemAbility::X->value):Gate::define(SystemAbility::ACCESS_PLATFORM_ADMIN->value, static fn (): bool => false);
-
Model-specific abilities are added by overriding
HasPolicy::getCustomAbilities():
public static function getCustomAbilities(): array { return PostAbility::cases(); }
PermissionRegistry converts an ability + model into a permission string, e.g.
Ability::VIEW_ANY + User → "view any users".
Building a role's permission set
AuthorizationRole::permissions() returns permission-name strings. Build them
fluently with Permissions instead of hand-assembling through the registry:
use AlexPavliukov\Authorization\Support\Permissions; public function permissions(): array { return match ($this) { self::SUPER_ADMIN => [], // bypass covers it self::OWNER => Permissions::make() ->for(Company::class)->only(Ability::VIEW, Ability::UPDATE) ->forAll(Location::class, Form::class, Review::class) ->all(), }; }
Primary role for routing
Authorization::primaryRole($user) (and $user->app_role) returns the
highest-priority role the user holds — the first case in the enum's byPriority()
that the user holds globally or in any team — so it suits login redirects and "home"
links regardless of the active team. Declaration order plays no part.
The order is a product decision, not a permission count: it answers "which area does
a user with several roles land in". An app where a candidate who is also an
organization admin must land in the candidate area lists CANDIDATE before
ORGANIZATION_ADMIN, although the admin holds more permissions.
$role = Authorization::primaryRole($user) ?? Role::default(); return $role->landingUrl(); // landingUrl() is your app's business method
Admin bypass
The provider registers one Gate::before: holders of a super-admin role (per the
role enum's isSuperAdmin()) pass every gate check; everyone else falls through
(null, never false). With Spatie teams on, only the global assignment
(team_id IS NULL) counts, regardless of the active team: a platform admin keeps
bypassing inside a tenant, and a tenant-scoped copy of a super-admin role never
bypasses.
Guiding principle: a super-admin has the right to do everything. Real "can't"s are business invariants enforced in the Action/domain layer, not authorization.
Seeding
Database\AuthorizationSeeder syncs permissions (from your authorizable models)
and roles (from each enum case's permissions()). It is idempotent — call it from
your own seeder:
public function run(): void { $this->call([ \AlexPavliukov\Authorization\Database\AuthorizationSeeder::class, ]); }
Permissions the registry no longer declares are left in place — delete them by hand if they bother you.
Tenancy
One tenancy layer, switched on by config. Publish it with
artisan vendor:publish --tag=authorization-config and set the model:
// config/authorization.php 'tenancy' => [ 'model' => App\Models\Organization::class, // null = no tenancy 'foreign_key' => 'organization_id', 'session_key' => 'current_organization_id', 'strict' => env('APP_ENV') !== 'production', 'missing_tenant_redirect' => null, // route name, e.g. 'organizations.pick' ],
The tenant is the Spatie team: turn permission.teams on and make
model_has_roles.team_id nullable (see Teams). The app owns the tenant
model; the package needs nothing from it beyond a primary key.
Membership = a role row in model_has_roles with team_id. Nothing else — no
tenant column on users, no pivot table. A user may hold roles in any number of
tenants plus global roles (team_id NULL). One resolver (Tenancy\TenantResolver):
- the
session_keyvalue, honoured only while the user is a member of that tenant (a global super-admin may enter any tenant that exists); a stale key is dropped; - otherwise the user's sole existing tenant;
- otherwise none — several tenants and no session choice never pick an arbitrary
row (
EnsureTenantSelectedthen 403s / redirects tomissing_tenant_redirect).
A tenant whose row is gone (soft-deleted included) is never entered, for members and
super-admins alike. A tenant switcher writes TenantResolver::sessionKey() into the
session and calls Tenancy::set(); the resolver re-validates the key on the next
request. Anything narrower (only active tenants, a default among several) is the
app's picker logic, not the package's.
use AlexPavliukov\Authorization\Tenancy\Concerns\HasMembers; // tenant model use AlexPavliukov\Authorization\Tenancy\Concerns\HasTenantMemberships; // user model use AlexPavliukov\Authorization\Concerns\HasAppRole; // user model $organization->members(); // MorphToMany users through model_has_roles $user->tenants(); // MorphToMany tenants through model_has_roles $user->app_role; // highest-priority role held anywhere (enum case) or null $user->is_platform_admin; // global super-admin (team-agnostic)
Writes go through TenantMembership (see Membership).
Wiring the middleware
The package registers no middleware on any group — a push from a provider is
silently dropped once the app configures its own groups. Declare it in
bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [SetCurrentTenant::class]); $middleware->api(append: [SetCurrentTenant::class]); // if the API is tenant-aware })
The package only fixes its priority (before SubstituteBindings, so bindings are
fenced). The middleware sets CurrentTenant + setPermissionsTeamId().
Livewire: /livewire/update replays only persistent middleware. When
Livewire is installed the package registers SetCurrentTenant,
EnsureTenantSelected and EnsurePlatformArea as persistent; without that a
platform component's actions would run unbypassed. If you register your own
persistent list, include these three.
config:cache: tenancy.strict reads env() — publish the config (or set
strict to a literal) in apps that cache configuration.
Roles live inside the tenant
With tenancy on, Spatie answers hasRole() / can() against the active team.
So every tenant-level role (owner, member, editor …) must be assigned within the
tenant — TenantMembership::add($user, $tenant, Role::ADMIN) — also when each user
has exactly one tenant. A role assigned globally (team_id IS NULL) is invisible
while a tenant is active; only super-admin roles belong there
(TenantMembership::assignGlobal($user, Role::PLATFORM_ADMIN)). Seeders and
registration flows that call $user->assignRole() directly must run inside
Tenancy::forTenant() or use TenantMembership.
Tenant-owned models
use AlexPavliukov\Authorization\Tenancy\BelongsToTenant; final class Post extends Model { use BelongsToTenant; // global scope + autofill on create + tenant() relation } // Reaches the tenant through another model: final class Comment extends Model { use BelongsToTenant; protected static function tenantRelationPath(): ?string { return 'post'; // or 'campaign.position' } }
On a one-hop path ('post') tenant() is a HasOneThrough built from the hop's
BelongsTo and the hop model's own tenant(), so $comment->tenant and
with('tenant') work. On a longer path ('campaign.position') it throws a
LogicException — walk the relation instead.
The path fences by tenant only: each hop is joined with its global scopes
dropped — app scopes and soft deletes alike — and only the last hop's own
TenantScope decides. A comment under a soft-deleted post, or an application in a
campaign an app scope hides from the user, stays visible inside its tenant; filter
it explicitly where that matters. The last hop must use BelongsToTenant
(LogicException otherwise).
The scope is fail-closed: no current tenant and no bypass ⇒ no rows, and a
MissingTenantContextException when strict is on. Creating with no tenant and no
explicit key always throws, bypass or not — a bypass never produces orphans.
A model whose NULL-tenant rows are by design (platform-wide templates) opts in with
protected static function allowsNullTenant(): bool { return true; }: under a
bypass it then stores NULL (even with a tenant active); inside a tenant it is still
autofilled, and with neither it still throws. NULL rows are visible only under bypass.
forTenant() suspends an outer bypass, so a platform admin reading "as" a tenant
sees only that tenant. Cross-tenant route bindings
404, and so does restoration: Livewire model props and SerializesModels jobs
keep TenantScope (only it — a soft-deleted model still restores), so a replayed
snapshot carrying another tenant's key fails to hydrate. TenantScopedPolicy reads the same CurrentTenant and stays as defence-in-depth.
Bypass and switching
use AlexPavliukov\Authorization\Tenancy\Tenancy; Tenancy::id(); Tenancy::get(); Tenancy::set($organization); Tenancy::forget(); Tenancy::withoutTenant(fn () => Post::query()->count()); // all tenants Tenancy::forTenant($other, fn () => Post::query()->get()); // another tenant
Never call withoutGlobalScope(TenantScope::class) at call sites — the bypass is
the one named API, and it is what EnsurePlatformArea uses.
Middleware
SetCurrentTenant— resolve + activate (wire it yourself, see above).EnsureTenantSelected— 403, or redirect tomissing_tenant_redirect.EnsurePlatformArea—Gate::authorize('accessPlatformAdmin')(a deny-all gate the package registers; only the super-admin bypass grants it), then enters the tenant bypass for the rest of the request (entered, not wrapped — Livewire's replay pipeline returns before the component action runs).
Queues
The tenant id and bypass flag live in Laravel's hidden Context, so they ride
along with every queued job automatically; the worker re-applies the Spatie team
when the context is hydrated. A job dispatched inside withoutTenant() runs
bypassed. A job holding a tenant model that is dispatched with no tenant
(public pages, commands) fails to restore it — MissingTenantContextException
under strict, ModelNotFoundException otherwise; dispatch it inside
forTenant() or withoutTenant().
Membership
Tenancy\TenantMembership is the Spatie plumbing for "user X has role R in tenant T":
$membership->add($user, $organization, Role::MEMBER); $membership->changeRole($user, $organization, Role::ADMIN); $membership->remove($user, $organization); $membership->roleFor($user, $organization); // ?string $membership->isMember($user, $organization);
Writes run under the target team, restore the active one, and flush the memoized role reads.
assignGlobal / removeGlobal($user, $role) handle platform roles; tenantIdsOf($user)
returns the memoized id list. tenancy.memberships => 'single' makes add / changeRole
/ transferOwnership throw Tenancy\MembershipPolicyException for a user who already
holds a role in another tenant (global roles don't count; default multiple).
Ownership (on when tenancy.owner_role is set): the tenant's owner_key column
(default owner_id; the app's migration — NOT NULL, FK users, restrictOnDelete)
is the only record of who owns it. The owner's role row is derived and written only
together with the column:
'tenancy' => [ 'owner_key' => 'owner_id', 'owner_role' => Role::OWNER, 'memberships' => 'multiple', // or 'single' ], $organization = Organization::create(['name' => 'Acme', 'owner_id' => $user->id]); $membership->transferOwnership($organization, $user); // writes the owner row; idempotent $membership->transferOwnership($organization, $next, Role::ADMIN); // previous owner demoted (or dropped when null) $membership->ownerOf($organization); // via the column $membership->isOwner($user, $organization); $organization->owner(); // BelongsTo<Foundation\Auth\User> (HasMembers)
add / changeRole / removeon the owner, and granting the owner role to anyone, throwTenancy\OwnershipException(aDomainException) — transfer ownership instead.- Deleting a user who owns a tenant (archived ones included) throws
OwnershipExceptionfrom adeletinglistener — soft and hard Eloquent deletes alike; query-builder deletes bypass it. - The column never grants access: policies, the resolver and
members()read roles only. AuthorizationSeederre-derives every owner row from the column and removes any other owner-role row in the team. Tenants with aNULLowner are skipped; a missing column is reported and skipped instead of failing.- The package never deletes tenants or cascades — archive (soft delete) them.
Testing
use AlexPavliukov\Authorization\Testing\InteractsWithTenancy; $this->actingAsInTenant($user, $organization, Role::MEMBER); $this->withoutTenant(fn () => Post::factory()->count(3)->create()); $this->forTenant($other, fn () => /* … */); $this->assertFailsClosed(Post::class); // the per-model fail-closed contract
Teams
With Spatie native teams enabled (config('permission.teams') === true) the
tenancy layer's SetCurrentTenant sets the permissions team per request. Teams
without tenancy are not a package concern: call setPermissionsTeamId() from your
own middleware.
Temporary team context
Authorization::withTeam() runs a callback under a given permissions team and
restores the previous one afterwards — even if the callback throws. Useful for
acting on another tenant's data without leaking team state:
Authorization::withTeam($organizationId, fn () => $user->assignRole('organization_admin'));
Team-aware role reads
Spatie's hasRole() and the role query scope are bound to the active team.
To ask about role membership in a specific team — or globally — without
switching the active team, add the HasTeamAwareRoles trait to the model:
use AlexPavliukov\Authorization\Concerns\HasTeamAwareRoles; class User extends Authenticatable { use HasRoles; use HasTeamAwareRoles; }
// facade reads Authorization::userHasRoleInTeam($user, Role::ORGANIZATION_ADMIN, $organizationId); Authorization::userHasGlobalRole($user, Role::PLATFORM_ADMIN); Authorization::userHasRole($user, Role::ORGANIZATION_ADMIN); // in any team (or global) Authorization::userRolesInTeam($user, $organizationId); // ['organization_admin', ...] (null = global) // query scopes User::query()->whereHasRoleInTeam(Role::ORGANIZATION_ADMIN, $organizationId)->get(); User::query()->whereHasGlobalRole(Role::PLATFORM_ADMIN)->get(); User::query()->whereHasRole(Role::ORGANIZATION_ADMIN)->get(); // holds it in any team
A global role is one assigned with a NULL pivot team_id — effective when
no team is active (e.g. a platform-level admin). Storing it requires a nullable
model_has_roles.team_id: Spatie's stock teams migration makes that column
NOT NULL and part of the primary key, so to use global assignments make it
nullable and replace the primary key with a unique index that includes team_id.
These reads are memoized per request (the underlying service is bound
scoped, so the memo is flushed on each Octane request / queue job). The cache is
keyed by model identity, so it never leaks across users. If you mutate a user's
roles and read them again within the same request, drop the memo first:
$user->assignRole($role); Authorization::forgetUserRoles($user);
Testing
Testing\InteractsWithAuthorization ships team-aware test primitives so your
suite does not re-implement the Spatie teams plumbing:
use AlexPavliukov\Authorization\Testing\InteractsWithAuthorization; final class ExampleTest extends TestCase { use InteractsWithAuthorization; public function test_example(): void { $this->assignRoleInTeam($member, Role::ORGANIZATION_ADMIN, $organizationId); $this->assignRoleInTeam($admin, Role::PLATFORM_ADMIN, null); // global assignment $roleId = $this->roleModelId(Role::ORGANIZATION_ADMIN); $this->withPermissionsTeam($organizationId, fn () => /* act within the team */); $this->resetPermissionsTeam(); } }
Development
composer install vendor/bin/phpunit # test suite (Orchestra Testbench) vendor/bin/phpstan analyse # static analysis, level 9 vendor/bin/pint # code style
License
MIT