webrek/laravel-mongo-permission

Role and permission management for Laravel with a MongoDB backend

Maintainers

Package info

github.com/webrek/laravel-mongo-permission

pkg:composer/webrek/laravel-mongo-permission

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.0 2026-05-18 23:59 UTC

This package is auto-updated.

Last update: 2026-05-19 00:02:28 UTC


README

Role and permission management for Laravel — MongoDB native.

API-compatible with spatie/laravel-permission for the methods most people use day to day, but the data model, queries, and cache strategy are designed around MongoDB.

Requirements

Dependency Versions
PHP 8.1, 8.2, 8.3
Laravel 10.x, 11.x, 12.x
MongoDB server 7.x
mongodb/laravel-mongodb ^4.0 | ^5.0
PHP mongodb extension required

CI runs every release against the full PHP × Laravel matrix above, excluding the combinations Laravel itself does not support (Laravel 11 and 12 require PHP 8.2+).

Install

composer require webrek/laravel-mongo-permission
php artisan vendor:publish --tag=permission-config
php artisan permission:create-indexes

Quick start

use Webrek\MongoPermission\Models\Permission;
use Webrek\MongoPermission\Models\Role;
use Webrek\MongoPermission\Traits\HasRoles;

class User extends Authenticatable {
    use HasRoles;
}

Permission::create(['name' => 'edit articles']);
$role = Role::create(['name' => 'editor']);
$role->givePermissionTo('edit articles');

$user->assignRole('editor');
$user->hasPermissionTo('edit articles'); // true

Status

v1.0 — ready for production. Covers single + multi-tenant Laravel apps, multi-guard auth, request-scoped + persistent caching, eight lifecycle events, wildcard permissions, route middleware, Blade directives, Gate integration, and a full set of Artisan commands.

Why this vs spatie/laravel-permission?

This package targets Laravel apps whose user model and auth surface live in MongoDB. If your stack is SQL, use spatie/laravel-permission — it is mature, widely adopted, and SQL is what it was built for.

If your stack is MongoDB:

  • No pivot tables. Role and permission grants live as embedded subdocuments on the user document. One read per permission check, no joins, no model_has_roles / role_has_permissions ceremony.
  • Multi-tenant from day one. Every read and write threads team_id through models, cache keys, and events. Tenant isolation is a config flag (strict_team_isolation), not a third-party concern.
  • Events that already know your tenant. RoleAttached, PermissionAttached and their counterparts carry team_id and guard in the payload — your audit listener does not have to re-query.
  • Cache shape matches the access pattern. Slug arrays are keyed by (user_id, team_id); catalog by (guard, team_id). Lookups are O(1) against the exact tuple the check uses.
  • Wildcards on by default. Segment-based matching with a configurable separator, greedy trailing *, exact interior *.
  • MongoDB-native indexes. Compound uniques on (name, guard_name, team_id), multikey on permission_ids for reverse queries. Generated by permission:create-indexes.

The public API mimics Spatie's on purpose so existing knowledge transfers. The internals do not.

Caching

hasPermissionTo and hasRole consult an in-memory + Laravel Cache layer keyed by (user_id, team_id). Mutations through assignRole, removeRole, givePermissionTo, revokePermissionTo, and syncRoles/syncPermissions invalidate the affected keys via package events.

Flush manually if needed:

php artisan permission:cache-reset

Configure the cache store and key namespace in config/permission.php under the cache key.

Known limitation: changing the permission catalog of a role (e.g. $role->givePermissionTo(...) / $role->revokePermissionTo(...)) does not automatically invalidate the cached slug arrays of every user holding that role — invalidation is per-user, fired by per-user attach or detach events. Run permission:cache-reset after bulk role-catalog edits, or rebuild the cache user-by-user.

Multi-guard

Every Role and Permission is scoped by guard_name. The same name can exist in multiple guards independently. The guard for an operation resolves in this order:

  1. Explicit argument: $user->hasRole('admin', 'api')
  2. protected string $guard_name property on the user model
  3. auth.defaults.guard
  4. config('permission.default_guard')

Mismatched guards on assignRole / givePermissionTo calls with model instances throw GuardDoesNotMatch.

Multi-tenant teams

Set permission.teams = true (default) and either call setPermissionsTeamId('your-team-id') manually or supply a closure in permission.team_resolver:

'team_resolver' => fn () =>
    request()->user()?->current_team_id
    ?? request()->header('X-Team-Id'),

Assignments made while a team is active are scoped to that team. Reads honor the active team. Setting permission.strict_team_isolation = true disables the "team_id = null is global" fallback.

Expiring grants

Roles and permissions can be granted with an expiry. The grant stays on the user document but stops counting toward checks the moment now() passes the expires_at timestamp — even if the cache was warmed before the expiry.

$user->assignRoleUntil('admin', now()->addHours(2));
$user->givePermissionToUntil('publish posts', now()->addDays(7));

$user->hasRole('admin');                  // true for two hours
$user->hasPermissionTo('publish posts');  // true for seven days

// After the expiry passes:
$user->hasRole('admin');                  // false

Expired subdocs are not removed automatically. Run the prune command on a schedule (or ad-hoc) to garbage-collect them and free space on user documents:

php artisan permission:prune-expired
php artisan permission:prune-expired --dry-run
php artisan permission:prune-expired --user-model="App\\Models\\User"

A role granted with an expiry propagates that expiry to every permission reached through the role — once the role assignment expires, those permissions stop counting too.

Role hierarchy

Roles can inherit permissions from other roles. A user assigned a role transparently gets every permission attached to that role and to every role in its ancestor chain.

$viewer = Role::create(['name' => 'viewer']);
$viewer->givePermissionTo('view articles');

$editor = Role::create(['name' => 'editor']);
$editor->givePermissionTo('edit articles');
$editor->inheritsFrom($viewer);   // editor now grants view + edit

$admin = Role::create(['name' => 'admin']);
$admin->inheritsFrom($editor);    // admin now grants view + edit transitively

$user->assignRole('admin');
$user->hasPermissionTo('view articles');   // true
$user->hasDirectPermission('view articles'); // false (transitive)

Inheritance is multi-parent: a role can extend several parents at once. Diamonds resolve cleanly — a permission reached through more than one path counts once.

Cycle detection. inheritsFrom throws Webrek\MongoPermission\Exceptions\RoleHierarchyCycle if the new edge would create a loop. RoleHierarchyTooDeep fires when the total chain length would exceed permission.role_hierarchy_max_depth (default 5).

Detaching a parent. $role->stopsInheritingFrom($parent) drops the edge. The package fires RoleParentChanged (with action = 'attached' or 'detached') and flushes the registrar cache so every affected user picks up the change on the next read.

Wildcard permissions

enable_wildcard_permission defaults to true. Patterns use . as the separator (configurable via permission.wildcard_separator). A trailing * is greedy and matches all remaining segments; interior * matches exactly one segment; a sole * matches any non-empty name.

Permission::create(['name' => 'posts.*']);
$user->givePermissionTo('posts.*');
$user->hasPermissionTo('posts.edit');         // true
$user->hasPermissionTo('posts.edit.own');     // true

Middleware

Route::get('/admin', ...)->middleware('role:admin');
Route::get('/edit',  ...)->middleware('permission:edit articles');
Route::get('/x',     ...)->middleware('role_or_permission:admin|edit articles');
Route::get('/teams/{team}/admin', ...)
    ->middleware(['team-context:team', 'role:admin']);

Denied requests throw Webrek\MongoPermission\Exceptions\UnauthorizedException (HTTP 403). Register a custom exception handler if you want a different response shape.

Blade directives

@role('admin') ... @endrole
@hasanyrole('admin|editor') ... @endhasanyrole
@hasallroles('admin|editor') ... @endhasallroles
@unlessrole('guest') ... @endunlessrole

@permission('edit articles') ... @endpermission
@haspermission('edit articles') ... @endhaspermission
@hasanypermission('edit|delete') ... @endhasanypermission

@can('edit articles') ... @endcan   {{-- native Laravel, routed via Gate::before --}}

Gate integration

The package installs a Gate::before hook so $user->can(), @can, and controller authorize() calls consult hasPermissionTo. Unknown permission names return null from the hook so the rest of the Gate stack (Policies, manually-defined gates) still runs.

Events

The package dispatches eight lifecycle events. Subscribe to them for audit logging, cache extensions, or custom side-effects.

Event Payload
RoleCreated Role $role
RoleDeleted Role $role
PermissionCreated Permission $permission
PermissionDeleted Permission $permission
RoleAttached mixed $user, Role $role, ?string $teamId, string $guard
RoleDetached mixed $user, Role $role, ?string $teamId, string $guard
PermissionAttached mixed $model, Permission $permission, ?string $teamId, string $guard
PermissionDetached mixed $model, Permission $permission, ?string $teamId, string $guard

PermissionAttached / PermissionDetached carry the model that received the change — a User instance or a Role instance — so listeners can branch on the case. All *Attached / *Detached events include the active team_id and guard, enabling per-tenant auditing without re-querying.

All event classes live in Webrek\MongoPermission\Events.

Artisan commands

php artisan permission:create-indexes
php artisan permission:create-role admin [--guard=web] [perm1 perm2 ...]
php artisan permission:create-permission "edit articles" [--guard=web]
php artisan permission:show [--guard=web] [--team=...]
php artisan permission:cache-reset
php artisan permission:prune-expired [--user-model=...] [--dry-run]
php artisan permission:list-users {role} [--permission=...] [--guard=...] [--team=...]
php artisan permission:check {user_id} {permission} [--guard=...] [--team=...]
php artisan permission:migrate-from-spatie [--connection=mysql] [--match-by=email] [--skip-users] [--force] [--dry-run]

Migrating from spatie/laravel-permission

If you are coming from an existing spatie/laravel-permission deployment, permission:migrate-from-spatie reads the canonical spatie tables out of a SQL connection and writes the equivalent documents into the package's Mongo collections.

# Dry-run against your "spatie" SQL connection
php artisan permission:migrate-from-spatie --connection=spatie --dry-run

# Real run, matching SQL users to Mongo users by email
php artisan permission:migrate-from-spatie --connection=spatie

# Roles and permissions only, no user assignments
php artisan permission:migrate-from-spatie --connection=spatie --skip-users

# Overwrite Mongo roles/permissions that already exist with the same (name, guard, team)
php artisan permission:migrate-from-spatie --connection=spatie --force

The command reads these five spatie tables: permissions, roles, role_has_permissions, model_has_roles, model_has_permissions, plus the SQL users table (override with --sql-user-table=) to match SQL user ids to Mongo user documents.

Matching defaults to email (override with --match-by=). Any SQL user without a corresponding Mongo user is reported but does not break the run. The migration is idempotent: a second run skips roles and permissions that already exist with the same (name, guard, team) tuple.

Configuration

Published to config/permission.php:

Key Default Description
models.role Webrek\MongoPermission\Models\Role Concrete Role class — swap to extend
models.permission Webrek\MongoPermission\Models\Permission Concrete Permission class — swap to extend
collection_names.roles 'roles' Mongo collection for roles
collection_names.permissions 'permissions' Mongo collection for permissions
guard_names ['web', 'api'] Guards the package will validate against
default_guard 'web' Fallback when no guard can be resolved
teams true Enable multi-tenant scoping by team_id
team_resolver fn () => null Closure that returns the active team_id
strict_team_isolation false If true, team_id = null no longer matches every team
enable_wildcard_permission true Toggle wildcard matching in hasPermissionTo
wildcard_separator '.' Segment separator for wildcard patterns
throw_on_missing_permission true Throw PermissionDoesNotExist for unknown names instead of returning false
handle_unauthorized true Let middleware throw 403 UnauthorizedException
cache.store 'default' Laravel Cache store for slug/catalog keys
cache.key 'mongo-permission' Namespace prefix for all package cache keys
cache.expiration_time null null = forever (trust event-driven invalidation)

Testing locally

docker compose up -d mongo
docker compose run --rm php composer install
docker compose run --rm php vendor/bin/phpunit
docker compose run --rm php vendor/bin/phpstan analyse --memory-limit=1G

The repository includes a docker-compose.yml that boots MongoDB 7 with a healthcheck so the test suite starts as soon as the database is ready. No PHP or Mongo install on the host is required.

For consumer apps, the package ships an assertion trait you can drop into your TestCase to test role and permission state with expressive assertions:

use Webrek\MongoPermission\Testing\MongoPermissionAssertions;

class FooTest extends TestCase
{
    use MongoPermissionAssertions;

    public function test_admin_can_edit(): void
    {
        $this->assertUserHasRole($user, 'admin');
        $this->assertUserHasPermission($user, 'edit articles');
        $this->assertUserHasDirectPermission($user, 'publish');
        $this->assertRoleHasPermission($role, 'view');
    }
}

License

MIT