Route-based RBAC for Laravel with auto-synced permissions, wildcard support, and ownership control.
"Jaga" is Malay for "to guard / watch over".
Most RBAC packages require you to define permissions twice — once in code, once in the database. Jaga eliminates that redundancy:
- Permissions are derived from your named routes — run
jaga:syncand you're done - Custom permissions for any action that has no route —
jaga:define export-reportsand it's in the same table, assignable and checkable the same way - Auto-generated human-readable descriptions and groupings (e.g.
posts.store→ "Create a post", groupPosts) - Wildcard permissions (
posts.*,*) for role-level flexibility - Ownership enforcement at the model level — no extra policy boilerplate for simple "you must own this resource" checks
- First-class caching with cache tag support and a non-tagged fallback
- PHP 8.1+
- Laravel 10 / 11 / 12 / 13
composer require laraditz/jagaPublish and run the migrations:
php artisan vendor:publish --tag=jaga-migrations
php artisan migrateOptionally publish the config:
php artisan vendor:publish --tag=jaga-config1. Add HasRoles to your authenticatable model:
use Laraditz\Jaga\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}2. Protect your routes with the jaga middleware:
// routes/api.php
Route::middleware(['auth:sanctum', 'jaga'])->group(function () {
Route::apiResource('posts', PostController::class);
});3. Sync your routes to the permissions table:
php artisan jaga:sync4. Create roles and assign permissions:
use Laraditz\Jaga\Models\Role;
use Laraditz\Jaga\Models\Permission;
$editor = Role::create(['name' => 'Editor', 'slug' => 'editor', 'guard_name' => 'web']);
// Assign specific permissions
$editor->assignPermission('posts.index');
$editor->assignPermission('posts.store');
// Or use a wildcard to cover all posts.* at once
$editor->assignWildcard('posts.*');5. Assign roles to users:
// Single role
$user->assignRole('editor');
// Multiple roles at once
$user->assignRole(['editor', 'moderator']);That's it. Users with the editor role will only be able to access routes their role has been granted.
Permissions come from your named routes — not from hand-written definitions. Running jaga:sync reads Laravel's route collection and upserts the permissions table automatically. It also auto-assigns a group (derived from the route name, e.g. posts.index → group Posts) so admin UIs can organise permissions without extra configuration. If a route disappears, its permission is soft-deleted. Run jaga:clean to permanently remove stale records after review.
Not every permission maps to a route. Use jaga:define to create permissions for arbitrary application actions — they live in the same permissions table and work through the same assignment and checking APIs.
php artisan jaga:define export-reports --description="Export reports" --group="Reporting"
php artisan jaga:define manage-billing --description="Manage billing" --group="Billing"
php artisan jaga:define webhook-receive --public # sets access_level=public (no auth required)Or create them programmatically in a seeder or migration:
Permission::create([
'name' => 'export-reports',
'description' => 'Export reports',
'group' => 'Reporting',
'is_custom' => true,
'methods' => [],
'uri' => '',
]);Once created, custom permissions are assigned and checked exactly like route-based permissions:
$role->assignPermission('export-reports');
$user->grantPermission('export-reports');
$user->hasPermission('export-reports'); // true
$user->can('export-reports'); // true via Gate::before()
$role->assignWildcard('reporting.*'); // covers all reporting.* permissions
$role->assignWildcard('*'); // covers everything including custom permissionsjaga:sync will never soft-delete a custom permission, and jaga:clean will never force-delete one — even if it is soft-deleted. They are permanently protected by the is_custom flag.
Roles are the primary way to group permissions and assign them to users. Create roles directly via Eloquent or in a seeder:
use Laraditz\Jaga\Models\Role;
$editor = Role::create([
'name' => 'Editor',
'slug' => 'editor', // used for assignment — $user->assignRole('editor')
'guard_name' => 'web',
]);
$admin = Role::create([
'name' => 'Admin',
'slug' => 'admin',
'guard_name' => 'web',
]);Assign permissions to a role by name or model:
// By permission name (resolves from DB)
$editor->assignPermission('posts.index');
$editor->assignPermission('posts.show');
$editor->assignPermission('posts.store');
// By Permission model
$perm = Permission::where('name', 'posts.update')->first();
$editor->assignPermission($perm);
// Wildcard — covers all posts.* permissions (including ones added later)
$admin->assignWildcard('posts.*');
// Global wildcard — covers everything
$admin->assignWildcard('*');Assign roles to users:
// By slug
$user->assignRole('editor');
// Multiple at once
$user->assignRole(['editor', 'moderator']);
// Remove a role
$user->removeRole('editor');Check access:
$user->hasRole('editor'); // true/false
$user->hasPermission('posts.store'); // true if user has it via any role or direct grantRoles and individual users can hold exact permissions (posts.update) or wildcard permissions (posts.*, *). Wildcards are resolved at check time.
Resolution order:
- Exact match —
posts.update - Resource wildcard —
posts.* - Global wildcard —
*
Every permission has an access_level that controls how the middleware gates the route:
| Value | Behaviour |
|---|---|
restricted |
Default. User must be authenticated and have the permission (via role or direct grant). |
auth |
Any authenticated user is allowed through — no permission check. Useful for profile pages, dashboards, and other routes that every logged-in user should reach. |
public |
No authentication required. Anyone can access the route. |
jaga:sync auto-detects the initial value:
- Routes with an
authmiddleware →restricted - Routes without an
authmiddleware →public
You can pin a value via config so it is applied (or re-applied) on every sync:
// config/jaga.php
'permissions' => [
'dashboard' => ['access_level' => 'auth'],
'home' => ['access_level' => 'public'],
],When no config pin is set, re-syncing preserves whatever value is in the database — so manually setting access_level to auth from an admin UI will survive subsequent syncs.
Only routes inside a jaga middleware group are protected. All other routes remain publicly accessible regardless of role.
| Method | Description |
|---|---|
assignRole(string|int|Role|array $role) |
Assign one or more roles by slug, ID, model, or array of any |
removeRole(string|int|Role|array $role) |
Remove one or more roles |
grantPermission(string|int|Permission $perm) |
Grant a direct exact permission |
revokePermission(string|int|Permission $perm) |
Revoke a direct exact permission |
grantWildcard(string $pattern) |
Grant a direct wildcard (e.g. posts.*) |
revokeWildcard(string $pattern) |
Revoke a wildcard |
hasPermission(string $routeName) |
Authoritative access check (uses cache) |
roles() |
Eloquent MorphToMany relationship |
permissions() |
Returns exact Permission models for display only — not for access checks |
Add to any Eloquent model that needs ownership enforcement. The jaga middleware will automatically check that the authenticated user owns the resource.
// Default: owner_key=user_id, owner_model=config('jaga.ownership.owner_model')
class Post extends Model
{
use HasOwnership;
}
// Custom owner key and model
class Article extends Model
{
use HasOwnership;
protected string $ownerModel = Author::class;
protected string $ownerKey = 'author_id';
}
// Opt out of ownership check while still using the trait
class TeamPost extends Model
{
use HasOwnership;
protected bool $ownershipRequired = false;
}On routes with multiple bound models (e.g. {team}/{post}), ownership is checked on every model where $ownershipRequired = true. All checks must pass (AND logic).
Override checkOwnership() on the model for custom conditions. The $routeName is available so you can branch per route without needing separate methods.
use Illuminate\Contracts\Auth\Authenticatable;
class Post extends Model
{
use HasOwnership;
public function checkOwnership(Authenticatable $user, string $routeName): bool
{
// Custom condition for a specific route
if ($routeName === 'posts.publish') {
return $this->author_id === $user->getKey() && $user->can_publish;
}
// Fall back to default (ownerKey match + instanceof ownerModel) for all other routes
return parent::checkOwnership($user, $routeName);
}
}When no policy or model override is registered, the default implementation checks that $user is an instance of the configured owner model and that $model->{ownerKey} matches $user->getKey().
The jaga middleware alias is registered automatically by the service provider.
// Works with any guard
Route::middleware(['auth:sanctum', 'jaga'])->group(...);
Route::middleware(['auth:api', 'jaga'])->group(...);
Route::middleware(['auth', 'jaga'])->group(...); // uses web guardFlow:
Request arrives
→ Permission access_level = public? → allow (no auth required)
→ Not authenticated? → 401
→ Permission access_level = auth? → allow (any authenticated user, no permission check)
→ Route has no name? → allow (unnamed routes are never restricted)
→ Jaga::policy registered for this route? → run callback → deny if false, allow if true
→ Check permission (direct grants, then roles, exact then wildcard)
→ No match? → 403
→ Has route model parameters with HasOwnership?
→ Laravel Policy registered for the model? → run policy method → deny if false
→ Jaga::ownershipPolicy registered for this route? → run callback → deny if false
→ Call $model->checkOwnership($user, $routeName) → deny if false
→ Allow
Register a callback for a named route to completely replace the built-in permission and ownership checks. If a policy is registered for a route, it is the sole gate — role/permission and ownership checks are skipped.
// AppServiceProvider::boot()
// Only allow the post's author to view it
Jaga::policy('posts.show', function ($user, $request) {
$post = $request->route('post'); // requires implicit model binding
return $post->user_id === $user->id;
});
// Allow admin users through regardless of ownership
Jaga::policy('posts.edit', function ($user, $request) {
return $user->hasRole('admin') || $request->route('post')->user_id === $user->id;
});
// Register the same policy for multiple routes at once
Jaga::policy(['posts.update', 'posts.destroy'], function ($user, $request) {
return $request->route('post')->user_id === $user->id;
});The callback receives ($user, $request) and must return bool. It runs inside the jaga middleware after authentication but before any permission or ownership check.
Register a callback for a named route to override ownership checking without replacing the permission check. Unlike Jaga::policy(), the permission check still runs normally — only the ownership step is replaced.
// AppServiceProvider::boot()
// Custom condition for a single route
Jaga::ownershipPolicy('posts.update', function ($user, Post $post) {
return $post->user_id === $user->id || $post->team_id === $user->team_id;
});
// Same callback for multiple routes
Jaga::ownershipPolicy(['posts.update', 'posts.destroy'], function ($user, Post $post) {
return $post->user_id === $user->id;
});The callback receives ($user, $model) and must return bool. It takes priority over the model's checkOwnership() method.
Priority order for the ownership step:
1. Jaga::ownershipPolicy('route.name', fn($user, $model) => bool) ← highest priority
2. $model->checkOwnership(Authenticatable $user, string $routeName) ← default or model override
| Command | Description |
|---|---|
jaga:sync |
Sync named routes → permissions table, flush all caches |
jaga:define |
Create or update a custom permission not tied to any route |
jaga:seeder |
Export current roles, permissions, and assignments to a PHP seeder file |
jaga:cache |
Pre-warm the jaga.permissions list cache |
jaga:clear |
Flush all jaga caches |
jaga:clean |
Force-delete soft-deleted route-based permissions and orphaned pivot rows |
jaga:sync delegates to SyncPermissionsJob, which you can dispatch from anywhere in your application — not just the CLI.
use Laraditz\Jaga\Jobs\SyncPermissionsJob;
// Queued (async) — runs on your configured queue worker
SyncPermissionsJob::dispatch();
// Synchronous — runs inline, blocks until complete
SyncPermissionsJob::dispatchSync();After the job completes, it fires a PermissionsSynced event carrying the result:
use Laraditz\Jaga\Events\PermissionsSynced;
use Illuminate\Support\Facades\Event;
Event::listen(PermissionsSynced::class, function (PermissionsSynced $event) {
// $event->newCount — permissions created
// $event->updatedCount — permissions updated
// $event->deprecatedCount — permissions soft-deleted (route removed)
// $event->collisions — custom permission names that clashed with a route
Log::info('Permissions synced', [
'new' => $event->newCount,
'updated' => $event->updatedCount,
'deprecated' => $event->deprecatedCount,
]);
});Recommended deployment workflow:
php artisan jaga:sync # sync new/changed routes, soft-delete removed ones
php artisan jaga:cache # warm the permissions cache
php artisan jaga:clean # (optional) permanently remove stale route-based permissions after reviewExport current state as a seeder:
php artisan jaga:seeder # write to the path configured in jaga.seeder.path
php artisan jaga:seeder --force # overwrite if the file already existsThe generated seeder truncates all three tables and re-inserts the current data, making it safe to run multiple times. Share it with teammates or include it in your deployment pipeline so every environment starts with identical roles and permissions.
jaga:sync generates human-readable descriptions for standard RESTful route names:
| Route name | Description |
|---|---|
posts.index |
List all posts |
posts.show |
View a post |
posts.store |
Create a post |
posts.update |
Update a post |
posts.destroy |
Delete a post |
admin.users.index |
List all users |
Descriptions are only overwritten if is_auto_description is true. Once you manually edit a description in the database and set is_auto_description = false, jaga:sync will never touch it again.
Jaga caches resolved permissions per user. Cache tags are used when available (Redis, Memcached); a key-index fallback is used for non-tagged drivers (file, database, array).
| Key | Contents |
|---|---|
jaga.permissions |
Full permission collection |
jaga.access_levels |
Map of name → access_level used by middleware |
jaga.user.{type}.{id}.permissions |
Resolved permissions for one model |
Default TTL: 3600 seconds (configurable).
Cache invalidation happens automatically via the built-in PermissionObserver — any time a Permission record is created, updated, soft-deleted, restored, or force-deleted (through any path: admin UI, Tinker, seeder, Artisan commands), all Jaga caches are flushed immediately. You do not need to run jaga:clear manually after permission changes.
In tests, use the provided trait to flush caches between test cases:
use Laraditz\Jaga\Testing\RefreshJagaCache;
class MyTest extends TestCase
{
use RefreshJagaCache;
}// config/jaga.php
return [
// Default guard when no auth middleware is present on the route
'guard' => 'web',
'cache' => [
'enabled' => true,
'ttl' => 3600,
'key_prefix' => 'jaga',
],
'sync' => [
// URI prefixes to exclude from jaga:sync
'exclude_uri_prefixes' => ['telescope', '_debugbar', 'horizon'],
// Route name prefixes to exclude from jaga:sync
'exclude_name_prefixes' => ['telescope.', 'debugbar.', 'horizon.'],
],
'ownership' => [
// Default foreign key checked by HasOwnership middleware
'owner_key' => 'user_id',
// Default authenticatable model type for ownership comparison
'owner_model' => \App\Models\User::class,
],
'tables' => [
'roles' => 'roles',
'permissions' => 'permissions',
'model_role' => 'model_role',
'role_permission' => 'role_permission',
'model_permission' => 'model_permission',
],
];Jaga registers two Blade directives for use in templates.
Show content only to users with a specific role. Accepts a slug, ID, model, or array (any match).
By default checks the authenticated user. Pass a $record as the second argument to check against any model that uses HasRoles.
{{-- Authenticated user --}}
@role('editor')
<button>Edit post</button>
@endrole
{{-- Explicit record --}}
@role('editor', $admin)
<button>Edit post</button>
@endrole
{{-- With else --}}
@role('admin')
<a href="/admin">Admin panel</a>
@else
<p>Access restricted.</p>
@endrole
{{-- Any of multiple roles --}}
@role(['editor', 'moderator'])
<button>Moderate</button>
@endroleShow content only to records that have access to a specific route permission.
By default checks the authenticated user. Pass a $record as the second argument to check against any model that uses HasRoles.
{{-- Authenticated user --}}
@permission('posts.index')
<a href="{{ route('posts.index') }}">View all posts</a>
@endpermission
{{-- Explicit record --}}
@permission('posts.index', $admin)
<a href="{{ route('posts.index') }}">View all posts</a>
@endpermission
{{-- With else --}}
@permission('posts.store')
<a href="{{ route('posts.store') }}">Create post</a>
@else
<p>You don't have permission to create posts.</p>
@endpermissionBoth directives silently hide content when the subject is null. Both also support an @elserole / @elsepermission variant for chaining conditional checks.
Jaga is self-contained — you do not need Gate or Policies to manage permissions. Everything covered above works purely through Jaga's own APIs.
If your team already uses Laravel's Gate or Policies and wants unified behaviour, Jaga integrates cleanly with both.
Jaga automatically hooks into Laravel's Gate via Gate::before(). This means Jaga permissions work anywhere Gate is used with no extra setup.
// $user->can() / $user->cannot()
$user->can('posts.index'); // true if user has the permission via Jaga
$user->cannot('posts.show'); // true if user lacks the permission
// Gate facade
Gate::allows('posts.store');
Gate::denies('posts.destroy');
// Controller authorize()
$this->authorize('posts.update'); // throws AuthorizationException if denied
// Blade @can / @cannot
@can('posts.store')
<a href="{{ route('posts.create') }}">New post</a>
@endcan
// Works for custom permissions too
Gate::allows('export-reports'); // true if user has the custom permissionHow it works: Jaga registers a Gate::before() hook that returns true when the user has the Jaga permission (exact, wildcard, or via role), and null otherwise. Returning null means Jaga steps aside — any Gate::define() or Policy you register will still run normally for that ability.
This means Jaga and Laravel Policies coexist cleanly:
- Jaga grants access → Gate short-circuits with
true - Jaga denies → Gate falls through to your
Gate::define()or Policy - Non-Jaga abilities (e.g.
update-settings) are completely unaffected
Custom Jaga policies (registered via Jaga::policy()) are route/request-level concerns and run inside the jaga middleware — they are not reflected in Gate::allows() checks.
If you register a Laravel Policy for a model that appears as a route parameter, the jaga middleware automatically invokes the appropriate Policy method — no extra wiring needed.
// App\Policies\PostPolicy
class PostPolicy
{
public function view(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
// Custom methods are picked up automatically if they match the route action name
public function publish(User $user, Post $post): bool
{
return $user->id === $post->user_id && $post->isDraft();
}
}Register the Policy as normal in AuthServiceProvider (or AppServiceProvider):
Gate::policy(Post::class, PostPolicy::class);That's all. The jaga middleware will call the right Policy method for each request:
| Route name | Policy method called |
|---|---|
posts.show |
view($user, $post) |
posts.edit |
update($user, $post) |
posts.update |
update($user, $post) |
posts.destroy |
delete($user, $post) |
posts.restore |
restore($user, $post) |
posts.publish |
publish($user, $post) (custom — matched by method name) |
posts.stats |
(no match — check skipped) |
Policy takes precedence over HasOwnership. If a Policy is registered for a model, Jaga uses it and ignores HasOwnership. If no Policy is registered, Jaga falls back to HasOwnership.
Policy before() works as expected. If your Policy defines a before() method (e.g., to grant superadmins unrestricted access), it is invoked automatically via Gate and will short-circuit the model-level check.
- Not a UI for managing roles and permissions (that's your app's responsibility)
- Not an OAuth or token-based auth system (use Sanctum or Passport)
- Not a replacement for Laravel Policies for complex business-logic authorization
- Not a solution for field-level or attribute-level access control
MIT