nuewire/acl
A Laravel ACL package for managing roles and permissions, letting you define access rules and gate application features through a simple, configurable access control list.
Pull the package in alongside its host package — nuewire/acl is an optional add-on to nuewire/users, so nuewire/users (and therefore its User model/trait) must already be installed and working before ACL means anything:
composer require nuewire/acl
php artisan vendor:publish --tag=acl-config
php artisan vendor:publish --tag=acl-migrations
php artisan migrate
If those tags don't exist verbatim (the release notes for 1.3.0 contain no itemized changelog), list what the provider actually registers:
php artisan vendor:publish --provider="Nuewire\\Acl\\AclServiceProvider"
Then open the published config/acl.php and record three things before writing any code: the pivot/table names, the default guard, and whether the permission cache is enabled. Those three settings cause most of the "it works in tinker but not in the app" reports later.
The fastest end-to-end vertical slice, and the one to do first on any new install:
nuewire/users model may not carry it — add the trait that ships with nuewire/acl to App\Models\User.// tinker
$role = Role::create(['name' => 'admin']);
$role->givePermissionTo('users.view');
$user = User::first();
$user->assignRole('admin');
$user->hasRole('admin'); // true
$user->can('users.view'); // true — permissions flow through Laravel's Gate
// routes/web.php
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get('/admin/users', [UserController::class, 'index']);
});
@role('admin')
<a href="/admin/users">Users</a>
@endrole
Once that slice passes, immediately convert the tinker commands into a seeder — tinker-created roles are the number one reason staging and production diverge.
Make the permission graph a deployable artifact. Roles and permissions are data, and data created by hand drifts. Keep a dedicated RolesAndPermissionsSeeder (or an acl:sync command if the package exposes one) that is idempotent and safe to re-run:
foreach (['users.view', 'users.edit', 'billing.manage'] as $permission) {
Permission::findOrCreate($permission, 'web');
}
$admin = Role::findOrCreate('admin', 'web');
$admin->syncPermissions(['users.view', 'users.edit', 'billing.manage']);
Run it in every environment as part of the release pipeline, not just on a fresh DB. syncPermissions (rather than givePermissionTo) means removing a line from the seeder actually revokes the permission on the next deploy.
Route middleware for coarse access, policies for record-level access. Apply role: / permission: middleware at the route-group level so whole sections are gated in one line, and push "can this user edit this post" into a policy. The policy can still lean on the package for the cheap check:
public function update(User $user, Post $post): bool
{
return $user->hasPermissionTo('posts.edit')
&& ($post->author_id === $user->id || $user->hasRole('editor'));
}
This split keeps ACL vocabulary (admin, editor, posts.edit) out of your domain model and keeps your controllers free of authorization noise.
Centralize the super-admin bypass. One Gate::before in a service provider beats if ($user->hasRole('super-admin')) scattered across twenty controllers — and it makes audits and tests trivial:
Gate::before(fn (User $user) => $user->hasRole('super-admin') ? true : null);
Return null, not false, when the check doesn't apply, or you'll short-circuit every other gate.
Multiple guards: name roles per guard. If you run web and admin guards, a role attached under one guard will silently fail checks under the other. The reliable day-to-day pattern is distinct role sets per guard (admin.web, admin.api) rather than sharing one role across guards, and always passing the guard explicitly when creating roles and permissions.
Enable the permission cache and warm it at deploy. Permission resolution is read-heavy and happens on nearly every request that touches a policy or @can. Turn the cache on in config/acl.php, and add a cache-bust step to your deploy script so a freshly migrated permission set isn't shadowed by a stale cache from the previous release.
Wrap role mutations in a transaction. Assigning roles in a service or an admin controller is a write to a pivot table plus a cache invalidation. Put both inside DB::transaction() so a failed assignment can't leave a half-applied role set with a warm cache.
A repeatable checklist for adding one new permission — this is the actual day-to-day loop, so script it:
@can.Factory states for tests. Add a ->admin() / ->withPermission('posts.edit') state to UserFactory so authorization tests read as intent, not setup. For route protection, a small data-provider matrix asserting assertOk for the allowed role and assertForbidden for the others is the cheapest regression insurance you'll ever write:
it('forbids non-admins from the user index', function () {
$this->actingAs(User::factory()->create())
->get('/admin/users')
->assertForbidden();
});
Eager-load when listing users. Any admin table showing each user's roles will N+1 without User::with('roles', 'permissions').
assignRole / givePermissionTo / syncPermissions, explicitly forget the cache (spatie's app(PermissionRegistrar::class)->forgetCachedPermissions(), or whatever nuewire/acl wraps it with). A role granted in a queued job or an artisan command will not be visible until the cache is dropped.$user->getAllPermissions() at the failure point usually ends the investigation in one step.role:admin rejects a user you know is an admin, check the guard on the role record against the guard on the route before touching any other code.admin to existing staff) before flipping middleware on, or you'll lock out your own team on deploy.config/acl.php. Renaming a table in a published migration means the next package upgrade either conflicts or silently recreates it. Change the config, leave the migration alone.Role class. The first composer update will erase anything you hand-edited.@role / @can in Blade only controls rendering. Every gated route needs a matching server-side middleware or policy check; pair the two in the same pull request so they can't drift.1.2.1 and 1.3.0, pin "nuewire/acl": "^1.3", and run the full suite under strict error reporting to surface deprecations. Do not assume "minor" means "no behavior change" when the changelog is empty.spatie/laravel-permission (which this wraps) is a config change, not a rewrite.How can I help you explore Laravel packages today?