artesaos/defender
Artesaos Defender is a Laravel ACL package for role- and permission-based access control. It provides middleware, Blade directives, caching, and a simple API for assigning roles and abilities to users so you can protect routes and UI consistently.
Install and publish in one pass, then inspect three files before writing any code:
composer require artesaos/defender
php artisan vendor:publish --provider="Artesaos\Defender\Providers\DefenderServiceProvider"
php artisan migrate
If the provider string doesn't resolve (it moved between releases), run php artisan vendor:publish interactively and pick the Defender entry, or check vendor/artesaos/defender/composer.json for extra.laravel.providers.
Where to look first
config/defender.php — model bindings (role, permission, user), pivot table names, and the pretend block. This is the file you will actually edit in a real project.vendor/artesaos/defender/src/Traits/ — the traits you put on your User (DefenderRoleTrait) and on any custom Role model (DefenderPermissionTrait).vendor/artesaos/defender/src/DefenderManager.php — the object behind the Defender facade / defender() helper. Read the public methods; that's your real API surface.vendor/artesaos/defender/database/migrations/ — confirms the user_roles and permission_role schema you'll be querying directly from time to time.Wire the trait onto your user model
use Artesaos\Defender\Traits\DefenderRoleTrait;
class User extends Authenticatable
{
use DefenderRoleTrait;
}
First use case: gate an admin area. Seed one role and one permission, attach them, then check on the user:
$role = Role::firstOrCreate(['name' => 'admin'], ['readable_name' => 'Administrator']);
$perm = Permission::firstOrCreate(['name' => 'dashboard.view'], ['readable_name' => 'View dashboard']);
$role->attachPermission($perm);
$user->attachRole($role);
// anywhere with an authenticated user
if ($user->canDo('dashboard.view')) { /* ... */ }
Resolve Role/Permission through config('defender.model.role') rather than importing the concrete class if you plan to swap in your own models later.
Seed roles/permissions as data, not code. A dedicated seeder using firstOrCreate keyed on name makes re-runs safe and lets you add readable_name for UI display without touching checks. Never create permissions lazily inside controllers — slugs drift and you end up with edit-post and posts.edit both in production.
Centralise slugs. canDo() returns false on a typo, silently. Use class constants or an enum (Permission::EDIT_POST = 'posts.edit') so a misspelled ability fails at review time, not at 3am.
Blade directives for UI, checks for enforcement. The package registers @canDo / @endCanDo, @canDoRole / @endCanDoRole, and @isRole / @endIsRole. Use them for showing/hiding buttons, but always re-check server-side in the controller or a form request:
public function authorize(): bool
{
return $this->user()->canDo('posts.edit');
}
Bridge to Laravel's Gate so @can, policies, and Gate::authorize() all see Defender permissions. Register once in AuthServiceProvider::boot():
Gate::before(function ($user, $ability) {
if (method_exists($user, 'canDo')) {
return $user->canDo($ability) ? true : null; // null = abstain
}
});
Returning null (not false) when the check fails is important — it lets policies and other gates still run. This is the single highest-leverage integration: after it, @can('posts.edit') and $this->authorize('posts.edit') just work.
Super-admin shortcut is the same hook with a role check, but scope it tightly:
Gate::before(fn ($user, $ability) => $user->isRole('super-admin') ? true : null);
Route middleware for coarse grouping, if your version ships NeedsRole / NeedsPermission (they are sometimes left commented out in the service provider — uncomment and register the aliases yourself):
Route::middleware('needsPermission:posts.edit')->group(fn () => Route::resource('posts', PostController::class));
Eager load roles and their permissions on list pages and dashboards: User::with('roles.permissions')->paginate(). Without it, canDo() inside a Blade loop fires a query per row — the classic N+1 in this package.
Testing. RefreshDatabase + a seeder, then actingAs($user). Note that Defender::canDo('x') without an argument reads the current auth user; in queued jobs and console commands there is no auth user, so pass the user explicitly or check on $user->canDo(...) directly.
@can and $this->authorize() know nothing about Defender permissions. The Gate::before bridge above is effectively required in any Laravel ≥ 5.5 app.Defender::canDo('posts.edit') in a console command, queue job, or before Auth::login() returns false — not an error. If a background job behaves as if the user has no permissions, this is why.pretend mode. config/defender.php has a pretend block that lets listed users bypass checks. Verify it is enabled => false in every non-local environment and treat it as a deploy checklist item.attachRole() in the same request, the trait's already-resolved relation may not reflect the change. $user->load('roles.permissions') or re-fetch the user before asserting in tests.user_roles / permission_role in config, you must edit the published migration, or the queries break at runtime with a confusing "table not found" error rather than at boot.onDelete('cascade') when you publish, or clean up in the deleting model's deleting event.canDo vs canDoRole. canDo is "user has this permission through any role"; canDoRole($role, $perm) scopes the check to one role. Reach for the second only when a user legitimately holds both a broad and a narrow role and you need the narrower test.Traits\DefenderRoleTrait vs older root-level placement), provider FQCN, and middleware aliases have all shifted across releases. Trust vendor/ over README snippets — the README lags the tagged releases.illuminate/* constraints now clear L9. Laravel 10/11 is still unclaimed — check the constraints in composer.json before assuming it works. If it's blocked, a thin fork bumping constraints usually works because the package is small, but budget for that. Note the gap pattern: the previous release was March 2022 (L8 era) and L9 support arrived only in this release, so expect support to land a major version behind. Fine for an existing codebase, a deliberate choice for a new one.composer update blocker — lock it alongside the framework bump and re-run your permission seeder plus the auth-related feature tests.resource.action slugs (posts.edit, billing.view) map cleanly onto both Defender permissions and future Gate abilities, so the bridge keeps working if you ever migrate to policy-only authorization.How can I help you explore Laravel packages today?