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.
| Dependency | Versions |
|---|---|
| PHP | 8.2, 8.3, 8.4 |
| Laravel | 12.x / 13.x |
| MongoDB server | 7.x |
mongodb/laravel-mongodb |
^5.0 |
PHP mongodb extension |
required |
CI runs every release against the full PHP × Laravel matrix above. Laravel 10 and 11 are no longer supported (both are past their security-support window and can no longer be installed cleanly).
composer require webrek/laravel-mongo-permission
php artisan vendor:publish --tag=permission-config
php artisan permission:create-indexesuse 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'); // trueForward and inverse relations are exposed as methods returning collections:
// On the user (via the HasRoles / HasPermissions traits)
$user->roles(); // Collection<Role>
$user->permissions(); // Collection<Permission> direct grants
// On a role
$role->permissions(); // Collection<Permission>
$role->users(); // users holding the role
// (matches both flat ["id"] and structured role_ids)
// On a permission
$permission->roles(); // Collection<Role> that grant itThese return plain collections (not query builders) and are resolved on call, so reach for them in views and reports rather than in tight loops.
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.
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_permissionsceremony. - Multi-tenant from day one. Every read and write threads
team_idthrough 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,PermissionAttachedand their counterparts carryteam_idandguardin 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 onpermission_idsfor reverse queries. Generated bypermission:create-indexes.
The public API mimics Spatie's on purpose so existing knowledge transfers. The internals do not.
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-resetConfigure the cache store and key namespace in config/permission.php
under the cache key.
Changing a role or permission — including a raw model save (e.g. from an
admin panel) or a deletion — invalidates every cached slug array at once by
bumping a cache generation that is folded into the cache keys. Per-user
changes (assignRole, removeRole, givePermissionTo, …) still invalidate
only the affected user via events. So edits made anywhere take effect on the
next request without a manual reset.
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:
- Explicit argument:
$user->hasRole('admin', 'api') protected string $guard_nameproperty on the user modelauth.defaults.guardconfig('permission.default_guard')
Mismatched guards on assignRole / givePermissionTo calls with model
instances throw GuardDoesNotMatch.
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.
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'); // falseExpired 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.
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.
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'); // trueRoute::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.
@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 --}}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.
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.
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]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 --forceThe 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.
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) |
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=1GThe 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');
}
}MIT