Context
Tracking issue for the single-role roleId direction recorded in docs/roleid-single-role-direction.md.
Follow-on to #33; relates to #34.
Goal
Force the invariant to a string and keep it there. The array is never an input, never stored, and never mutated — it exists at exactly one point: the outbound getRoles() return.
public function getRoles(): iterable
{
return [$this->roleId];
}
That return is the only array in the role/identity contract surface, because it is the only member of it that deviates from Laminas permissions ACL's shape — getRoleId() and getResourceId() are @return string, getOwnerId() is mixed, and Laminas has no getRoles() at all (it handles multiple roles by inheritance, not arrays). The array is Mezzio's, and getRoles() is its only entry point, so it is wrapped there and nowhere else.
Direction
$roleId is typed hard to string.
getRoles() wraps that string in an array — it exists only to satisfy Mezzio's iterable contract.
- The
roleId column becomes varchar(50).
Webware\Acl\Role\SingleRoleUserProxy is never needed.
getRoles() is only reached at boundaries this codebase never touches, e.g. Laminas permissions calling it.
- No data migration. None of this is released or in the wild, so the schema is updated directly rather than migrated.
- The default role names are owned by
Webware\Core\Role (see below) — the single source of truth.
Why the array-first approach was wrong
The array was treated as the source of truth and the single role as a derived value. That produced, in order: a nullable array|string|null property, an array_shift/index-0 collapsing hook, a withRoleId() that merged and then had to replace, a getRoleId() unable to satisfy Laminas' string contract, and SingleRoleUserProxy existing only to present one role as an iterable.
Prerequisite (blocking, core)
Webware\Core\UserInterface::withRoleId(array|string $roleId): static must narrow to string before any dependent can change. PHP forbids narrowing a parameter type in an implementation, so an implementation that narrows will not load:
PHP Fatal error: Declaration of ...::withRoleId(string $roleId): static must be
compatible with ...UserInterface::withRoleId(array|string $roleId): static
Core is a dependency of everything, so it lands first. The failure is loud at class declaration, so it cannot be missed silently — recorded here only so the ordering is known up front. The @param RoleInterface[]|string[]|string docblock arm narrows with it.
Work
Status
usermanager source side landed on chore/userinterface-contract-conformance (PR #33), commit 4228303: 272 tests / 778 assertions passing, mago format, lint, analyze and guard all clean, 3 lint baseline entries and 1 analysis baseline entry removed as outdated.
Uncommitted on top of 4228303: 11 role fixtures converted from array-shaped values to 'Member' (RegistrationDataFilterTest 5, UpdateUserDataFilterTest 5, UserRepositoryIntegrationTest 1), and #[Override] added to Role::getRoleId() (was an analyze error).
Core Role enum: webware-core #27 merged into 1.0.x as 2ff483f (feature commit 95c5bf7). Backed string, @api, implements Laminas\Permissions\Acl\Role\RoleInterface, cases Guest, Member, Administrator, Developer, plus test/unit/RoleTest.php. Merged state verified locally: 55 tests / 90 assertions, all four mago gates clean, all 11 CI checks green including patch coverage at 100% and mutation testing.
UserInterface::GUEST_ROLE and AclInterface::DEVELOPER_ROLE_ID are deleted in that commit, so webware-acl, webware-admin and this package do not run against the new core until each adopts Role::Guest / Role::Developer. The latest tag is 1.0.0-alpha.2, so adoption is blocked on a new tag — prefer-stable resolves ^1.0.0-alpha.x to the tag, not to 1.0.x-dev.
Note for adoption: a Role case can be passed directly to Laminas (isAllowed(Role::Administrator), hasRole(), addRole()) because the role registry resolves it via getRoleId(). ->value is only needed where a plain string is required — DB values, session payloads, array keys, string properties. Enum instances cannot be array keys.
Notes
acl_role.roleId is already VARCHAR(50); user.roleId is the outlier.
Context
Tracking issue for the single-role
roleIddirection recorded indocs/roleid-single-role-direction.md.Follow-on to #33; relates to #34.
Goal
Force the invariant to a
stringand keep it there. The array is never an input, never stored, and never mutated — it exists at exactly one point: the outboundgetRoles()return.That return is the only array in the role/identity contract surface, because it is the only member of it that deviates from Laminas permissions ACL's shape —
getRoleId()andgetResourceId()are@return string,getOwnerId()ismixed, and Laminas has nogetRoles()at all (it handles multiple roles by inheritance, not arrays). The array is Mezzio's, andgetRoles()is its only entry point, so it is wrapped there and nowhere else.Direction
$roleIdis typed hard tostring.getRoles()wraps that string in an array — it exists only to satisfy Mezzio'siterablecontract.roleIdcolumn becomesvarchar(50).Webware\Acl\Role\SingleRoleUserProxyis never needed.getRoles()is only reached at boundaries this codebase never touches, e.g. Laminas permissions calling it.Webware\Core\Role(see below) — the single source of truth.Why the array-first approach was wrong
The array was treated as the source of truth and the single role as a derived value. That produced, in order: a nullable
array|string|nullproperty, anarray_shift/index-0 collapsing hook, awithRoleId()that merged and then had to replace, agetRoleId()unable to satisfy Laminas'stringcontract, andSingleRoleUserProxyexisting only to present one role as aniterable.Prerequisite (blocking, core)
Webware\Core\UserInterface::withRoleId(array|string $roleId): staticmust narrow tostringbefore any dependent can change. PHP forbids narrowing a parameter type in an implementation, so an implementation that narrows will not load:Core is a dependency of everything, so it lands first. The failure is loud at class declaration, so it cannot be missed silently — recorded here only so the ordering is known up front. The
@param RoleInterface[]|string[]|stringdocblock arm narrows with it.Work
withRoleId()tostring(unblocks everything below) — core Escape email message bodies via laminas-escaper rather than raw htmlspecialchars() #25, core Complete mago tranche 2 and route verification email through the bus #26, tagged1.0.0-alpha.2Entity\User::$roleIdtostring; drop the array-collapsing hookEntity\User::getRoles()returns[$this->roleId]Entity\User::withRoleId()takes and stores a plain stringCreateUserCommand::roleIdloses its JSON-encoding hook and narrows tostringConsole\UserSchemaroleIdtoVarchar(50)— no migration, see DirectionRoleenum lives inwebware-coreasWebware\Core\Role— core chore(mago): tranche 3 type precision + adopt message-bus getCommandName #27, merged as2ff483f1.0.xso dependents can resolve the enum (blocks every adoption below)Webware\Core\Rolehere once core is tagged, and delete this package'ssrc/Role.phpRole/SingleRoleUserProxy.phpAcl::isAllowed()passes the user object to Laminas instead of iteratinggetRoles()into proxiesRole/UserRoleIterator.php— no longer needed; the earlier "make no changes" freeze is releasedStatus
usermanager source side landed on
chore/userinterface-contract-conformance(PR #33), commit4228303: 272 tests / 778 assertions passing, mago format, lint, analyze and guard all clean, 3 lint baseline entries and 1 analysis baseline entry removed as outdated.Uncommitted on top of
4228303: 11 role fixtures converted from array-shaped values to'Member'(RegistrationDataFilterTest5,UpdateUserDataFilterTest5,UserRepositoryIntegrationTest1), and#[Override]added toRole::getRoleId()(was ananalyzeerror).Core
Roleenum:webware-core#27 merged into1.0.xas2ff483f(feature commit95c5bf7). Backedstring,@api, implementsLaminas\Permissions\Acl\Role\RoleInterface, casesGuest,Member,Administrator,Developer, plustest/unit/RoleTest.php. Merged state verified locally: 55 tests / 90 assertions, all four mago gates clean, all 11 CI checks green including patch coverage at 100% and mutation testing.UserInterface::GUEST_ROLEandAclInterface::DEVELOPER_ROLE_IDare deleted in that commit, sowebware-acl,webware-adminand this package do not run against the new core until each adoptsRole::Guest/Role::Developer. The latest tag is1.0.0-alpha.2, so adoption is blocked on a new tag —prefer-stableresolves^1.0.0-alpha.xto the tag, not to1.0.x-dev.Note for adoption: a
Rolecase can be passed directly to Laminas (isAllowed(Role::Administrator),hasRole(),addRole()) because the role registry resolves it viagetRoleId().->valueis only needed where a plain string is required — DB values, session payloads, array keys,stringproperties. Enum instances cannot be array keys.Notes
acl_role.roleIdis alreadyVARCHAR(50);user.roleIdis the outlier.