diff --git a/config/permission.php b/config/permission.php index f39f6b5bf..d9f152e3e 100644 --- a/config/permission.php +++ b/config/permission.php @@ -26,7 +26,14 @@ 'role' => Spatie\Permission\Models\Role::class, - ], + ], + + /* + * This option defines which column on the roles table is used to identify a role + * when checking things like $user->hasRole('admin'). + * By default, it uses 'name', but you can change this to 'slug' or any other unique column. + */ + 'role_identifier' => 'name', 'table_names' => [ diff --git a/src/Models/Role.php b/src/Models/Role.php index 5bab4878e..df0cc5c05 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -126,19 +126,43 @@ public static function findById(int|string $id, ?string $guardName = null): Role return $role; } + /** + * Find a role by its configured identifier (e.g. name or slug) and guard name. + * + * @return RoleContract|Role + * + * @throws RoleDoesNotExist + */ + public static function findByIdentifier(string $value, ?string $guardName = null): RoleContract + { + $guardName = $guardName ?? Guard::getDefaultName(static::class); + $identifier = config('permission.role_identifier', 'name'); + + $role = static::findByParam([$identifier => $value, 'guard_name' => $guardName]); + + if (! $role) { + throw RoleDoesNotExist::named($value, $guardName); + } + + return $role; + } + + /** * Find or create role by its name (and optionally guardName). * * @return RoleContract|Role */ - public static function findOrCreate(string $name, ?string $guardName = null): RoleContract + public static function findOrCreate(string $value, ?string $guardName = null): RoleContract { $guardName = $guardName ?? Guard::getDefaultName(static::class); - $role = static::findByParam(['name' => $name, 'guard_name' => $guardName]); + $identifier = config('permission.role_identifier', 'name'); + + $role = static::findByParam([$identifier => $value, 'guard_name' => $guardName]); if (! $role) { - return static::query()->create(['name' => $name, 'guard_name' => $guardName] + (app(PermissionRegistrar::class)->teams ? [app(PermissionRegistrar::class)->teamsKey => getPermissionsTeamId()] : [])); + return static::query()->create([$identifier => $value, 'guard_name' => $guardName] + (app(PermissionRegistrar::class)->teams ? [app(PermissionRegistrar::class)->teamsKey => getPermissionsTeamId()] : [])); } return $role; diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index d10dd77e3..0a8a6248d 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -91,7 +91,17 @@ public function scopeRole(Builder $query, $roles, $guard = null, $without = fals $role = $role->value; } - $method = is_int($role) || PermissionRegistrar::isUid($role) ? 'findById' : 'findByName'; + $roleIdentifier = $this->getRoleIdentifier(); + + $method = ''; + + if (is_int($role) || PermissionRegistrar::isUid($role)) { + $method = 'findById'; + } else if ($roleIdentifier !== 'name') { + $method = 'findByIdentifier'; + } else { + $method = 'findByName'; + } return $this->getRoleClass()::{$method}($role, $guard ?: $this->getDefaultGuardName()); }, Arr::wrap($roles)); @@ -238,6 +248,8 @@ public function syncRoles(...$roles) */ public function hasRole($roles, ?string $guard = null): bool { + $roleKey = $this->getRoleIdentifier(); + $this->loadMissing('roles'); if (is_string($roles) && strpos($roles, '|') !== false) { @@ -249,7 +261,7 @@ public function hasRole($roles, ?string $guard = null): bool return $this->roles ->when($guard, fn ($q) => $q->where('guard_name', $guard)) - ->pluck('name') + ->pluck($roleKey) ->contains(function ($name) use ($roles) { /** @var string|\BackedEnum $name */ if ($name instanceof \BackedEnum) { @@ -270,8 +282,8 @@ public function hasRole($roles, ?string $guard = null): bool if (is_string($roles)) { return $guard - ? $this->roles->where('guard_name', $guard)->contains('name', $roles) - : $this->roles->contains('name', $roles); + ? $this->roles->where('guard_name', $guard)->contains($roleKey, $roles) + : $this->roles->contains($roleKey, $roles); } if ($roles instanceof Role) { @@ -314,6 +326,8 @@ public function hasAnyRole(...$roles): bool */ public function hasAllRoles($roles, ?string $guard = null): bool { + $roleKey = $this->getRoleIdentifier(); + $this->loadMissing('roles'); if ($roles instanceof \BackedEnum) { @@ -332,16 +346,16 @@ public function hasAllRoles($roles, ?string $guard = null): bool return $this->roles->contains($roles->getKeyName(), $roles->getKey()); } - $roles = collect()->make($roles)->map(function ($role) { + $roles = collect()->make($roles)->map(function ($role) use ($roleKey) { if ($role instanceof \BackedEnum) { return $role->value; } - return $role instanceof Role ? $role->name : $role; + return $role instanceof Role ? $role->{$roleKey} : $role; }); $roleNames = $guard - ? $this->roles->where('guard_name', $guard)->pluck('name') + ? $this->roles->where('guard_name', $guard)->pluck($roleKey) : $this->getRoleNames(); $roleNames = $roleNames->transform(function ($roleName) { @@ -362,6 +376,8 @@ public function hasAllRoles($roles, ?string $guard = null): bool */ public function hasExactRoles($roles, ?string $guard = null): bool { + $roleKey = $this->getRoleIdentifier(); + $this->loadMissing('roles'); if (is_string($roles) && strpos($roles, '|') !== false) { @@ -373,10 +389,10 @@ public function hasExactRoles($roles, ?string $guard = null): bool } if ($roles instanceof Role) { - $roles = [$roles->name]; + $roles = [$roles->{$roleKey}]; } - $roles = collect()->make($roles)->map(fn ($role) => $role instanceof Role ? $role->name : $role + $roles = collect()->make($roles)->map(fn ($role) => $role instanceof Role ? $role->{$roleKey} : $role ); return $this->roles->count() == $roles->count() && $this->hasAllRoles($roles, $guard); @@ -390,11 +406,17 @@ public function getDirectPermissions(): Collection return $this->permissions; } + /** + * Return all role identifiers for the model. + * + * Note: This respects the configured 'role_identifier' column (e.g., 'name' or 'slug'), + * even though the method name is 'getRoleNames' for backward compatibility. + */ public function getRoleNames(): Collection { $this->loadMissing('roles'); - return $this->roles->pluck('name'); + return $this->roles->pluck($this->getRoleIdentifier()); } protected function getStoredRole($role): Role @@ -408,7 +430,7 @@ protected function getStoredRole($role): Role } if (is_string($role)) { - return $this->getRoleClass()::findByName($role, $this->getDefaultGuardName()); + return $this->getRoleClass()::findByIdentifier($role, $this->getDefaultGuardName()); } return $role; @@ -435,4 +457,10 @@ protected function convertPipeToArray(string $pipeString) return explode('|', trim($pipeString, $quoteCharacter)); } + + protected function getRoleIdentifier(): string + { + return config('permission.role_identifier', 'name'); + } + } diff --git a/tests/HasRolesWithCustomModelsTest.php b/tests/HasRolesWithCustomModelsTest.php index 767ab6752..983f0f3e7 100644 --- a/tests/HasRolesWithCustomModelsTest.php +++ b/tests/HasRolesWithCustomModelsTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use PHPUnit\Framework\Attributes\Test; +use Spatie\Permission\Exceptions\RoleDoesNotExist; use Spatie\Permission\Tests\TestModels\Admin; use Spatie\Permission\Tests\TestModels\Role; @@ -106,4 +107,47 @@ public function it_should_touch_when_assigning_new_roles() $this->assertSame('2021-07-20 19:13:14', $role1->refresh()->updated_at->format('Y-m-d H:i:s')); $this->assertSame('2021-07-20 19:13:14', $role2->refresh()->updated_at->format('Y-m-d H:i:s')); } + + /** @test */ + #[Test] + public function it_can_assign_and_remove_a_role_with_custom_identifier() + { + config(['permission.role_identifier' => 'slug']); + + app(Role::class)->create(['name' => 'Editor', 'slug' => 'editor', 'guard_name' => 'web']); + app(Role::class)->create(['name' => 'Writer', 'slug' => 'writer', 'guard_name' => 'web']); + + $this->assertFalse($this->testUser->hasRole('editor')); + $this->assertFalse($this->testUser->hasRole('Editor')); + + $this->testUser->assignRole('editor'); + $this->testUser->assignRole('writer'); + + $this->assertTrue($this->testUser->hasRole('editor')); + $this->assertFalse($this->testUser->hasRole('Editor')); + + $this->assertTrue($this->testUser->hasAllRoles(['editor'])); + $this->assertTrue($this->testUser->hasAllRoles(['editor', 'writer'])); + $this->assertFalse($this->testUser->hasAllRoles(['editor', 'writer', 'not exist'])); + + $this->assertTrue($this->testUser->hasExactRoles(['editor', 'writer'])); + $this->assertFalse($this->testUser->hasExactRoles(['editor'])); + + $this->testUser->removeRole('editor'); + + $this->assertFalse($this->testUser->hasRole('editor')); + } + + /** @test */ + #[Test] + public function it_throws_an_exception_when_assigning_a_role_by_name_when_identifier_is_not_name() + { + config(['permission.role_identifier' => 'slug']); + + app(Role::class)->create(['name' => 'Editor', 'slug' => 'editor', 'guard_name' => 'web']); + + $this->expectException(RoleDoesNotExist::class); + + $this->testUser->assignRole('Editor'); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 073e7c8fb..5ce007486 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -190,6 +190,7 @@ protected function setUpDatabase($app) $schema->table(config('permission.table_names.roles'), function (Blueprint $table) { $table->softDeletes(); + $table->string("slug")->unique()->nullable(); }); $schema->table(config('permission.table_names.permissions'), function (Blueprint $table) { $table->softDeletes();