From d13b2a650c97d12a843298afd5bdf98194745842 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 20 Sep 2026 10:16:38 +0100 Subject: [PATCH 1/2] fix: Honour a morph class in role lookups Role lookups compared a polymorphic column against the configured class name, so a host that points the package at a subclass has to store that subclass name in every row. A host with rows already written under the base name, or one that registers a morph map, matches nothing and sees no workspaces and no members. The comparisons now ask the model through getMorphClass, which answers with the class name unless the host says otherwise, so what is stored today keeps working and an override or a morph map is honoured as well. --- composer.json | 2 +- src/Http/Controllers/WorkspaceController.php | 24 +++++++++--- src/Models/Workspace.php | 8 ++-- src/Traits/HasWorkspaces.php | 22 +++++++++-- tests/Feature/SubclassedWorkspaceTest.php | 40 ++++++++++++++++++++ 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 20d6c3a..01c93fa 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Workspace management package for Laravel applications", "type": "library", "license": "MIT", - "version": "1.1.1", + "version": "1.2.0", "authors": [ { "name": "WhileSmart", diff --git a/src/Http/Controllers/WorkspaceController.php b/src/Http/Controllers/WorkspaceController.php index 0fa93c4..e3fb2f4 100644 --- a/src/Http/Controllers/WorkspaceController.php +++ b/src/Http/Controllers/WorkspaceController.php @@ -169,7 +169,7 @@ public function members(Workspace $workspace): JsonResponse $members = $workspace->members() ->with(['roleAssignments' => function ($query) use ($workspace) { - $query->where('context_type', config('workspaces.workspace_model', Workspace::class)) + $query->where('context_type', $this->workspaceMorphClass()) ->where('context_id', $workspace->id) ->with('role'); }]) @@ -446,7 +446,7 @@ protected function userCanAccess(Workspace $workspace): bool } foreach (Role::cases() as $role) { - if ($role->canAccess() && $user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) { + if ($role->canAccess() && $user->hasRole($role->value, $this->workspaceMorphClass(), $workspace->id)) { return true; } } @@ -463,7 +463,7 @@ protected function userCanManage(Workspace $workspace): bool } foreach (Role::cases() as $role) { - if ($role->canManage() && $user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) { + if ($role->canManage() && $user->hasRole($role->value, $this->workspaceMorphClass(), $workspace->id)) { return true; } } @@ -479,7 +479,7 @@ protected function userIsOwner(Workspace $workspace): bool return false; } - return $user->hasRole(Role::OWNER->value, config('workspaces.workspace_model', Workspace::class), $workspace->id); + return $user->hasRole(Role::OWNER->value, $this->workspaceMorphClass(), $workspace->id); } protected function getUserRole(Workspace $workspace): ?string @@ -491,11 +491,25 @@ protected function getUserRole(Workspace $workspace): ?string } foreach (Role::byPrecedence() as $role) { - if ($user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) { + if ($user->hasRole($role->value, $this->workspaceMorphClass(), $workspace->id)) { return $role->value; } } return null; } + + /** + * The name a workspace is stored under in a polymorphic column. + * + * Not the class name. A model may answer with something else, which is how + * a morph map works and how a subclass keeps the name its table already + * holds, and a comparison against the class name misses both. + */ + private function workspaceMorphClass(): string + { + $model = config('workspaces.workspace_model', Workspace::class); + + return (new $model)->getMorphClass(); + } } diff --git a/src/Models/Workspace.php b/src/Models/Workspace.php index 80e866e..072e729 100644 --- a/src/Models/Workspace.php +++ b/src/Models/Workspace.php @@ -70,7 +70,7 @@ public function members() 'id', 'id', 'assignable_id' - )->where('role_assignments.context_type', static::class) + )->where('role_assignments.context_type', $this->getMorphClass()) ->where('role_assignments.assignable_type', config('workspaces.user_model', 'App\\Models\\User')); } @@ -93,7 +93,7 @@ public function pendingInvitations(): HasMany public function roleAssignments() { return $this->hasMany('Whilesmart\\Roles\\Models\\RoleAssignment', 'context_id') - ->where('context_type', static::class); + ->where('context_type', $this->getMorphClass()); } public function getOwnersAttribute() @@ -103,7 +103,7 @@ public function getOwnersAttribute() $query->whereHas('role', function ($q) { $q->where('slug', 'workspace-owner'); }) - ->where('context_type', static::class) + ->where('context_type', $this->getMorphClass()) ->where('context_id', $this->id); }) ->get(); @@ -116,7 +116,7 @@ public function getAdminsAttribute() $query->whereHas('role', function ($q) { $q->whereIn('slug', ['workspace-owner', 'workspace-admin']); }) - ->where('context_type', static::class) + ->where('context_type', $this->getMorphClass()) ->where('context_id', $this->id); }) ->get(); diff --git a/src/Traits/HasWorkspaces.php b/src/Traits/HasWorkspaces.php index c98bb7a..bec67fe 100644 --- a/src/Traits/HasWorkspaces.php +++ b/src/Traits/HasWorkspaces.php @@ -17,6 +17,20 @@ public static function workspaceModel(): string return config('workspaces.workspace_model', Workspace::class); } + /** + * The name a workspace is stored under in a polymorphic column. + * + * Not the class name. A model may answer with something else, which is how + * a morph map works and how a subclass keeps the name its table already + * holds, and a comparison against the class name misses both. + */ + public static function workspaceMorphClass(): string + { + $model = static::workspaceModel(); + + return (new $model)->getMorphClass(); + } + public function ownedWorkspaces(): MorphMany { return $this->morphMany(static::workspaceModel(), 'owner'); @@ -31,8 +45,8 @@ public function workspaces() 'id', 'id', 'context_id' - )->where('role_assignments.assignable_type', static::class) - ->where('role_assignments.context_type', static::workspaceModel()); + )->where('role_assignments.assignable_type', $this->getMorphClass()) + ->where('role_assignments.context_type', static::workspaceMorphClass()); } public function pendingWorkspaceInvitations() @@ -90,7 +104,7 @@ public function joinWorkspace(Workspace $workspace, ?string $role = null): void $role = $role ?? Role::default()->value; if (method_exists($this, 'assignRole')) { - $this->assignRole($role, static::workspaceModel(), $workspace->id); + $this->assignRole($role, static::workspaceMorphClass(), $workspace->id); } } @@ -98,7 +112,7 @@ public function leaveWorkspace(Workspace $workspace): void { if (method_exists($this, 'removeRole')) { foreach (Role::values() as $role) { - $this->removeRole($role, static::workspaceModel(), $workspace->id); + $this->removeRole($role, static::workspaceMorphClass(), $workspace->id); } } } diff --git a/tests/Feature/SubclassedWorkspaceTest.php b/tests/Feature/SubclassedWorkspaceTest.php index 29c4761..0355633 100644 --- a/tests/Feature/SubclassedWorkspaceTest.php +++ b/tests/Feature/SubclassedWorkspaceTest.php @@ -15,6 +15,16 @@ class SubWorkspace extends Workspace protected $table = 'workspaces'; } +class RenamedWorkspace extends Workspace +{ + protected $table = 'workspaces'; + + public function getMorphClass(): string + { + return Workspace::class; + } +} + class SubclassedWorkspaceTest extends TestCase { #[Test] @@ -47,4 +57,34 @@ public function members_resolve_when_the_workspace_model_is_subclassed(): void // base Workspace class while the assignment stores the subclass. $this->assertCount(1, $workspace->members()->get()); } + + #[Test] + public function members_resolve_when_a_subclass_keeps_the_name_already_stored(): void + { + config(['workspaces.workspace_model' => RenamedWorkspace::class]); + + $user = User::create([ + 'name' => 'Renamed Owner', + 'email' => 'renamed-'.uniqid().'@example.com', + 'password' => Hash::make('password'), + ]); + + $workspace = RenamedWorkspace::create([ + 'name' => 'Renamed Workspace', + 'type' => 'team', + 'owner_type' => User::class, + 'owner_id' => $user->id, + ]); + + RoleAssignment::create([ + 'assignable_type' => User::class, + 'assignable_id' => $user->id, + 'role_id' => Role::where('slug', 'owner')->first()->id, + 'context_type' => Workspace::class, + 'context_id' => $workspace->id, + ]); + + $this->assertCount(1, $workspace->members()->get()); + $this->assertContains($workspace->id, $user->workspaces()->pluck('workspaces.id')->all()); + } } From aab998edcc523bb81ac7f8744f935c0319771794 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 20 Sep 2026 11:06:14 +0100 Subject: [PATCH 2/2] fix: Match a member on the name their role was stored under A role assignment is written through the user's own morph relation, so the user side of the member lookup holds whatever that answers. Comparing it against the configured class instead returned no members for a host that maps its user model, while the same host's inverse lookup worked. Both sides of the lookup now ask the model, and the controller uses the workspace it already holds rather than resolving the name a second time. --- src/Http/Controllers/WorkspaceController.php | 24 +++---------- src/Models/Workspace.php | 15 +++++++- tests/Feature/SubclassedWorkspaceTest.php | 38 ++++++++++++++++++++ 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/Http/Controllers/WorkspaceController.php b/src/Http/Controllers/WorkspaceController.php index e3fb2f4..e3831a7 100644 --- a/src/Http/Controllers/WorkspaceController.php +++ b/src/Http/Controllers/WorkspaceController.php @@ -169,7 +169,7 @@ public function members(Workspace $workspace): JsonResponse $members = $workspace->members() ->with(['roleAssignments' => function ($query) use ($workspace) { - $query->where('context_type', $this->workspaceMorphClass()) + $query->where('context_type', $workspace->getMorphClass()) ->where('context_id', $workspace->id) ->with('role'); }]) @@ -446,7 +446,7 @@ protected function userCanAccess(Workspace $workspace): bool } foreach (Role::cases() as $role) { - if ($role->canAccess() && $user->hasRole($role->value, $this->workspaceMorphClass(), $workspace->id)) { + if ($role->canAccess() && $user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) { return true; } } @@ -463,7 +463,7 @@ protected function userCanManage(Workspace $workspace): bool } foreach (Role::cases() as $role) { - if ($role->canManage() && $user->hasRole($role->value, $this->workspaceMorphClass(), $workspace->id)) { + if ($role->canManage() && $user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) { return true; } } @@ -479,7 +479,7 @@ protected function userIsOwner(Workspace $workspace): bool return false; } - return $user->hasRole(Role::OWNER->value, $this->workspaceMorphClass(), $workspace->id); + return $user->hasRole(Role::OWNER->value, $workspace->getMorphClass(), $workspace->id); } protected function getUserRole(Workspace $workspace): ?string @@ -491,25 +491,11 @@ protected function getUserRole(Workspace $workspace): ?string } foreach (Role::byPrecedence() as $role) { - if ($user->hasRole($role->value, $this->workspaceMorphClass(), $workspace->id)) { + if ($user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) { return $role->value; } } return null; } - - /** - * The name a workspace is stored under in a polymorphic column. - * - * Not the class name. A model may answer with something else, which is how - * a morph map works and how a subclass keeps the name its table already - * holds, and a comparison against the class name misses both. - */ - private function workspaceMorphClass(): string - { - $model = config('workspaces.workspace_model', Workspace::class); - - return (new $model)->getMorphClass(); - } } diff --git a/src/Models/Workspace.php b/src/Models/Workspace.php index 072e729..b9139ed 100644 --- a/src/Models/Workspace.php +++ b/src/Models/Workspace.php @@ -71,7 +71,7 @@ public function members() 'id', 'assignable_id' )->where('role_assignments.context_type', $this->getMorphClass()) - ->where('role_assignments.assignable_type', config('workspaces.user_model', 'App\\Models\\User')); + ->where('role_assignments.assignable_type', $this->userMorphClass()); } public function invitations(): HasMany @@ -148,4 +148,17 @@ public function setSetting(string $key, mixed $value): self return $this; } + + /** + * The name a user is stored under in a polymorphic column. + * + * A role assignment is written through the user's own morph relation, so + * this is what was stored, which is not always the configured class. + */ + private function userMorphClass(): string + { + $model = config('workspaces.user_model', 'App\\Models\\User'); + + return (new $model)->getMorphClass(); + } } diff --git a/tests/Feature/SubclassedWorkspaceTest.php b/tests/Feature/SubclassedWorkspaceTest.php index 0355633..258d2b8 100644 --- a/tests/Feature/SubclassedWorkspaceTest.php +++ b/tests/Feature/SubclassedWorkspaceTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Hash; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -27,6 +28,18 @@ public function getMorphClass(): string class SubclassedWorkspaceTest extends TestCase { + /** + * A morph map is global and outlives the test that set one, so anything + * after it would resolve against a map it never asked for. + */ + protected function tearDown(): void + { + Relation::morphMap([], false); + Relation::requireMorphMap(false); + + parent::tearDown(); + } + #[Test] public function members_resolve_when_the_workspace_model_is_subclassed(): void { @@ -87,4 +100,29 @@ public function members_resolve_when_a_subclass_keeps_the_name_already_stored(): $this->assertCount(1, $workspace->members()->get()); $this->assertContains($workspace->id, $user->workspaces()->pluck('workspaces.id')->all()); } + + #[Test] + public function members_resolve_when_the_user_model_is_behind_a_morph_map(): void + { + Relation::morphMap(['user' => User::class]); + + $user = User::create([ + 'name' => 'Mapped Owner', + 'email' => 'mapped-'.uniqid().'@example.com', + 'password' => Hash::make('password'), + ]); + + $workspace = Workspace::create([ + 'name' => 'Mapped Workspace', + 'type' => 'team', + 'owner_type' => 'user', + 'owner_id' => $user->id, + ]); + + // Written the way a role assignment is written, through the user's own + // morph relation, which stores the alias rather than the class. + $user->assignRole('owner', $workspace->getMorphClass(), $workspace->id); + + $this->assertCount(1, $workspace->members()->get()); + } }