Skip to content

Add support for configurable role identifier key (e.g., slug) #2846

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion config/permission.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [

Expand Down
30 changes: 27 additions & 3 deletions src/Models/Role.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
50 changes: 39 additions & 11 deletions src/Traits/HasRoles.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -435,4 +457,10 @@ protected function convertPipeToArray(string $pipeString)

return explode('|', trim($pipeString, $quoteCharacter));
}

protected function getRoleIdentifier(): string
{
return config('permission.role_identifier', 'name');
}

}
44 changes: 44 additions & 0 deletions tests/HasRolesWithCustomModelsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
}
}
1 change: 1 addition & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading