apavliukov/laravel-authorization

Reusable, Spatie-permission-based authorization layer for Laravel: policies, abilities, roles, a permission registry, and a pluggable admin bypass.

Maintainers

Package info

github.com/apavliukov/laravel-authorization

pkg:composer/apavliukov/laravel-authorization

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-06-28 15:59 UTC

This package is auto-updated.

Last update: 2026-06-28 16:08:25 UTC


README

A reusable, Spatie-permission-based authorization layer for Laravel: resource policies, ability enums, role semantics, a permission registry, idempotent seeding, and a pluggable 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

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, the Gate::before bypass hook, the make:authorization-policy command, and (when Spatie teams are enabled) the team middleware.

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.

use AlexPavliukov\Authorization\Contracts\AuthorizationRole;

enum Role: string implements AuthorizationRole
{
    case ADMIN = 'admin';
    case MEMBER = '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;
    }
}

Scaffold one with the generator:

php artisan make:authorization-policy Post

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.

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 and gates in your provider; the package ships no SystemAbility enum.
  • 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".

Admin bypass

Gate::before is wired through a pluggable BypassStrategy, resolved from the container lazily on each check.

  • Support\RoleBypass (default) — holders of a super-admin role bypass every check. It accepts an optional list of protected abilities that always fall through to policies:

    use AlexPavliukov\Authorization\Authorization;
    use AlexPavliukov\Authorization\Enums\Ability;
    use AlexPavliukov\Authorization\Support\RoleBypass;
    
    Authorization::bypassUsing(new RoleBypass(
        app(\AlexPavliukov\Authorization\AuthorizationManager::class),
        protected: [Ability::FORCE_DELETE],
    ));
  • Support\NoBypass — no god-mode; every check goes through Spatie/policies:

    Authorization::bypassUsing(\AlexPavliukov\Authorization\Support\NoBypass::class);

You can also override the strategy by rebinding the contract in the container:

$this->app->bind(
    \AlexPavliukov\Authorization\Contracts\BypassStrategy::class,
    \App\Authorization\YourStrategy::class,
);

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. protected / NoBypass exist only for genuine authorization-level carve-outs (separation of duties, break-glass).

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,
    ]);
}

Teams

When Spatie native teams are enabled (config('permission.teams') === true), the core provider registers SetPermissionsTeam on the web middleware group. It resolves the current team id via the bound TeamResolver (default: DefaultTeamResolver, which reads the user's team_foreign_key attribute) and calls setPermissionsTeamId(). With teams off, none of this is wired.

Provide a custom resolver with Authorization::resolveTeamsUsing(YourResolver::class).

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