Skip to content

refactor: single-role roleId (string, not array) #35

Description

@tyrsson

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

  • core: narrow withRoleId() to string (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, tagged 1.0.0-alpha.2
  • Entity\User::$roleId to string; drop the array-collapsing hook
  • Entity\User::getRoles() returns [$this->roleId]
  • Entity\User::withRoleId() takes and stores a plain string
  • CreateUserCommand::roleId loses its JSON-encoding hook and narrows to string
  • Console\UserSchema roleId to Varchar(50) — no migration, see Direction
  • Role enum lives in webware-core as Webware\Core\Role — core chore(mago): tranche 3 type precision + adopt message-bus getCommandName #27, merged as 2ff483f
  • core: tag the merged 1.0.x so dependents can resolve the enum (blocks every adoption below)
  • Adopt Webware\Core\Role here once core is tagged, and delete this package's src/Role.php
  • acl: delete Role/SingleRoleUserProxy.php
  • acl: Acl::isAllowed() passes the user object to Laminas instead of iterating getRoles() into proxies
  • acl: remove Role/UserRoleIterator.php — no longer needed; the earlier "make no changes" freeze is released
  • Update the docs that describe the array shape — supersession pointers are in place; full rewrites deferred until the direction settles

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions