Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Permissions Laravel Package

cartalyst/permissions

Cartalyst Permissions is a PHP package for role-based access control, letting you define roles and permissions and check user access in Laravel applications. It provides a simple API for assigning, inheriting, and validating permissions.

View on GitHub
Deep Wiki
Context7

Getting Started

Resolve the license before anything else. composer.json reports NOASSERTION, and Cartalyst's historical distribution model has been commercial/proprietary. This is a gate, not paperwork — get legal sign-off or vendor the code under a decision you can defend at audit. It is the single most common reason this package stalls a Laravel sprint.

Install and pin.

composer require cartalyst/permissions:^2.1
php -v   # confirm the actual runtime, not the Dockerfile

v2.1.0 is the first release declaring a PHP 8 constraint, and the only release note is "PHP 8 support." Install against every PHP version you actually ship (8.1/8.2/8.3), commit composer.lock, and run your suite with error_reporting(E_ALL) so deprecations surface in CI rather than production. Because the package has zero Laravel coupling, Composer will happily install it alongside any framework version and give you no compatibility signal — verify on your own matrix.

Where to look first. There is no service provider, no facade, no publishable config, and no migrations. That means vendor/cartalyst/permissions/src/ is the documentation, and it is small enough to read in an afternoon. Read it before designing anything. The one thing you must understand precisely is how a permission string like users.create is parsed and matched against wildcard definitions (users.*, *.view, *) — that resolution logic is the entire value proposition. Confirm from source whether the package ships Role/Permission value objects at all or only the permission-string evaluator; the answer changes how much of a schema you own. Treat any method signature from a blog post as unverified until you've seen it in the source.

First use case — start with the boundary, not the database. Create one gateway class and bind it, then route every call site through it:

// app/Authorization/PermissionGateway.php
namespace App\Authorization;

class PermissionGateway
{
    public function __construct(private array $definitions) {}

    public function allows(array $grantedPermissions, string $ability): bool
    {
        // delegate to the container; keep the package type out of the signature
        return $this->containerFor($grantedPermissions)->hasAccess($ability);
    }
}
$this->app->singleton(PermissionGateway::class, fn () => new PermissionGateway(
    config('permissions.definitions')
));

Only after that boundary exists should you add persistence. That ordering means swapping in spatie/laravel-permission or native Gate/Policy later is one class, not a grep across 200 views.

Day-one deliverable: an ability registry. Abilities become constants, never string literals:

// app/Authorization/Ability.php
final class Ability
{
    public const EXPORT_REPORTS = 'export.reports';
    public const USERS_CREATE   = 'users.create';
}

Implementation Patterns

You own persistence; the package will not. Expect roughly this shape, end-to-end yours:

roles (id, name, slug, tenant_id nullable)
permissions (id, slug, description)
permission_role (permission_id, role_id)
role_user (role_id, user_id)

Hydrate a user's granted permission slugs once per request, hand the array to the container, and ask it questions. That is the whole job the package does — resolving wildcards and implication (users.create implying users.*, or the reverse, depending on configuration) — and it is the genuinely fiddly part worth borrowing.

Cache the resolved array, not the query, and bust it deliberately.

// app/Authorization/PermissionRepository.php
public function grantedFor(User $user): array
{
    return Cache::rememberForever(
        "perms:user:{$user->id}",
        fn () => $user->roles()
            ->with('permissions:id,slug')
            ->get()
            ->flatMap(fn ($role) => $role->permissions->pluck('slug'))
            ->unique()
            ->values()
            ->all()
    );
}

Invalidate on Role/Permission save, on pivot attach/detach, and on User role changes, via model observers. Nothing in the package invalidates anything for you.

Wire it into Gate — that is how Laravel developers actually consume this. Use Gate::before and let real policies win:

// app/Providers/AuthServiceProvider.php
Gate::before(function (User $user, string $ability) {
    if ($user->isSuperAdmin()) return true;

    $gateway = app(PermissionGateway::class);

    if (! $gateway->isDefinedAbility($ability)) {
        return null; // let a policy handle it
    }

    return $gateway->allows(
        app(PermissionRepository::class)->grantedFor($user),
        $ability
    );
});

Avoid Gate::define('*', ...) in a mature app: the wildcard definition interacts poorly with policies and authorize() in controllers. With Gate::before returning null for unknown abilities, everything Laravel-native works unchanged — @can('export.reports') in Blade, $this->authorize('users.create') in controllers, ->can() on the model, and can: route middleware:

Route::post('/users', StoreUserController::class)
    ->middleware('can:users.create');

Model permissions as data, not conditionals. This is the reason to use the package. Plan tiers, add-on entitlements, and admin-delegated access become rows:

plan:pro      -> export.reports, users.create
plan:starter  -> export.reports
tenant:acme   -> plan:pro + custom.gradebook

"Does Acme get report exports" becomes a query; a pricing change becomes a migration and seeder, not a release.

Seed definitions from config so the ability namespace lives in one reviewable file.

// config/permissions.php
return [
    'definitions' => [
        'users.create'   => 'Create users',
        'users.delete'   => 'Delete users',
        'export.reports' => 'Export reports',
    ],
];

Seed the permissions table from this on deploy, and add a test asserting every Ability::* constant exists as a row. That kills the silent-deny-from-a-typo failure mode.

Multi-tenancy: decide at schema time, in the pivot. Roles are global or tenant-scoped. If scoped, put tenant_id on roles and in the cache key (perms:{$tenant->id}:user:{$user->id}). Retrofitting scope into a live permission graph is among the more expensive migrations you can undertake.

Test the matrix, not the internals. Because the gateway is injected, fake it in feature tests and assert outcomes:

it('denies report export without the ability', function () {
    $user = User::factory()->withRole('starter')->create();
    $this->actingAs($user)->get('/reports/export')->assertForbidden();
});

One test per ability per role. Tedious, and the only thing that keeps wildcard resolution honest.

Gotchas and Tips

It stores; it does not enforce. There is no authenticated-user concept, no middleware, no Gate bridge, no service provider or facade to rely on — confirm in source before believing otherwise. Assume you write all Laravel glue; be pleasantly surprised if you don't.

Wildcards are the whole API and the whole risk. Nail precedence in a test before writing a single role: does * grant everything, does users.* cover users.create, is users.*.edit supported, and does an unknown ability deny or throw? A permissive default here is a security bug. Write a PermissionResolutionTest as a table of [granted[], asked, expected] cases and treat it as the spec.

Never let an unregistered permission string into a controller or view. Typos yield a silent false, indistinguishable from a correct denial in tests and from "the user complained" in production. Log denials at debug level with the ability name during rollout.

Stale cache is the failure mode. Invalidate on write, not read. If role_user is touched anywhere — seeder, admin panel, tinker, queued import — bust the cache. Centralize it in PermissionCache::flushFor(User|Role) enforced by observers so ad-hoc paths can't skip it. Watch transaction ordering: forget after commit, or use DB::afterCommit().

Pin and plan to vendor. One star, zero dependents, and one release after a multi-year gap means no CVE feed, no Stack Overflow surface, and no upstream fix when a future PHP version breaks it. Committed composer.lock is the minimum; plan to fork into packages/ inside your monorepo within a quarter and name an owner in the runbook.

Build explain() early. When someone says "I can't export reports," you want one command that prints roles, raw slugs, the resolved array, and the container's verdict for that ability:

php artisan permissions:explain [email protected] export.reports

Without it you will be flushing caches and guessing.

Don't subclass or monkey-patch the container. Extension points are thin — no container binding, no manager, no driver system in the Laravel sense. Compose around it: your gateway owns the instance, your config owns definitions, your observers own invalidation.

Keep the exit cheap. Every call site goes through Gate/@can or the gateway — never app(Permissions::class) directly in a controller. That discipline makes a swap to spatie/laravel-permission or native policies a one-class change when this dependency blocks a future Laravel upgrade.

Read the source before trusting a docblock. For a package this small and this stale, reading all of it is cheaper than one debugging session. Do it in the spike, not in the incident.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager