laraditz/jaga

Route-based RBAC for Laravel with auto-synced permissions and wildcard support

Maintainers

Package info

github.com/laraditz/jaga

pkg:composer/laraditz/jaga

Statistics

Installs: 40

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

1.1.2 2026-04-23 03:25 UTC

This package is auto-updated.

Last update: 2026-04-23 03:25:14 UTC


README

Latest Version on Packagist Total Downloads License

Route-based RBAC for Laravel with auto-synced permissions, wildcard support, and ownership control.

"Jaga" is Malay for "to guard / watch over".

Why Jaga?

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:sync and you're done
  • Custom permissions for any action that has no route — jaga:define export-reports and 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", group Posts)
  • 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

Requirements

  • PHP 8.1+
  • Laravel 10 / 11 / 12 / 13

Installation

composer require laraditz/jaga

Publish and run the migrations:

php artisan vendor:publish --tag=jaga-migrations
php artisan migrate

Optionally publish the config:

php artisan vendor:publish --tag=jaga-config

Quick Start

1. 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:sync

4. 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.

Core Concepts

Route-Based Permissions

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.

Custom Permissions

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 permissions

jaga: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

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 grant

Wildcard Permissions

Roles and individual users can hold exact permissions (posts.update) or wildcard permissions (posts.*, *). Wildcards are resolved at check time.

Resolution order:

  1. Exact match — posts.update
  2. Resource wildcard — posts.*
  3. Global wildcard — *

Access Levels

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 auth middleware → restricted
  • Routes without an auth middleware → 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.

Opt-In Middleware

Only routes inside a jaga middleware group are protected. All other routes remain publicly accessible regardless of role.

Traits

HasRoles — on authenticatable models

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

HasOwnership — on resource models

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).

Custom ownership 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().

Middleware

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 guard

Flow:

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

Custom Route Policies

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.

Custom Ownership Policies

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

Artisan Commands

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

Programmatic Sync

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 review

Export 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 exists

The 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.

Auto-Generated Descriptions

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.

Caching

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;
}

Configuration

// 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',
    ],
];

Blade Directives

Jaga registers two Blade directives for use in templates.

@role / @endrole

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>
@endrole

@permission / @endpermission

Show 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>
@endpermission

Both directives silently hide content when the subject is null. Both also support an @elserole / @elsepermission variant for chaining conditional checks.

Optional: Laravel Gate & Policy Integration

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.

Gate Integration

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 permission

How 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.

Policy Integration

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.

What Jaga Is Not

  • 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

License

MIT