Solves:
user.management· Namespace:Plugins\User\· Type: on-demand GDA module
The User plugin owns the user.management business domain on the AlfacodeTeam
PhpServicePlatform (GDA) framework. It provides enterprise-grade user
registration, lookup, partial update, email verification, soft-deletion, and
timing-safe, rate-limited credential verification — all over the GLOBAL
central users identity table.
It is the canonical reference for how a first-party plugin is structured: pure Domain, an Application service that owns the transaction + events, Infrastructure adapters behind ports, and a published API contract that other modules consume.
- What it does
- Architecture at a glance
- Directory layout
- Data model
- HTTP API
- Web UI (AJAX + CSRF)
- Tenant-scoped sub-resources (settings)
- Security model
- Reliability: the transactional outbox
- Installation & wiring
- Configuration
- Using it from another plugin
- Using it from a project
- CLI
- Testing
- Extending the pattern
| Capability | Entry point | Notes |
|---|---|---|
| Register (public) | POST /ajx/users |
Public self-signup, rate-limited. Returns 202 {status:"pending_verification"} — no identity data. Queues a verification email (optional MailPort). May submit a profile block → tenant user_profiles. Emits user.registered |
| Register (admin) | POST /ajx/admin/users |
auth + user:create. Returns the FULL created record for the admin table |
| Verify email (public) | POST /ajx/users/verify |
Unauthenticated, token-based: SHA-256-stored, one-time, 24h expiry. Sets email_verified_at |
| Verify email (self/admin) | POST /ajx/users/{id}/verify-email |
Authenticated variant (self or user:update-any) |
| List users | GET /ajx/users |
Admin-only; keyset paginated; ?q= searches username/email, ?verified=1|0 filters |
| Show a user | GET /ajx/users/{id} |
Self or user:read-any |
| Update (partial) | PUT/PATCH /ajx/users/{id} |
Self or user:update-any; optimistic-locked; emits user.updated; changing the email re-arms verification and re-sends the link |
| Soft-delete | DELETE /ajx/users/{id} |
Self or user:delete-any; emits user.deleted |
| Lockout status | GET /ajx/users/{id}/lockout |
user:unlock. { "locked": bool } |
| Clear lockout | DELETE /ajx/users/{id}/lockout |
user:unlock. Clears the failure counter early, ahead of its TTL |
| Verify credentials | UserServiceContract::verifyCredentials() |
Timing-safe, lockout, rehash-on-login; requires a verified email |
| Settings | GET/PUT /ajx/{profile,preferences,privacy,notification-preferences} |
TENANT-scoped, self-only; one consolidated service |
| HTML UI | GET /users[...], /account/settings |
AJAX-driven, cookie auth, CSRF on every form |
Recent changes
- Fixed a broken-access-control bug on admin registration.
POST /ajx/admin/userswas documented as requiringauth+user:create, but onlyauthwas ever actually enforced —UserService::register()had no permission check anywhere in its call chain, so any authenticated user (including a freshly self-registered one) could create accounts through the admin endpoint.register()now callsrequirePermission('user:create');registerPublic()(the intentionally public path) is unaffected.- Outbox delivery fixed. A stray commented-out line meant integration events (
user.registered/user.updated/user.deleted) never actually dispatched in-process after commit — every one silently depended on theuser:outbox:relaycron. Restored the intended synchronous dispatch; the relay is now purely the crash-recovery backstop it was designed to be.register/verify-email/profilepages now boot the right Vite bundle. They render on thesitesurface (matching where their.tsxfiles actually live) instead ofadmin— the wrong surface meant a direct page load couldn't resolve the component.Userentity no longer carries tenant membership/profile state.UserDTO::fromEntity()now takes them as parameters instead — keeps the Domain entity from importing another plugin's DTO (Plugins\Tenancy\API\DTOs\TenantSummary).- Admin lockout visibility.
GET/DELETE /ajx/users/{id}/lockout— an admin withuser:unlockcan see whether an account is currently rate-limited out of login and clear it early, instead of waiting out the 15-minute TTL.- User search/filter.
ListUsersQuery(andGET /ajx/users) now takesq(username/email substring) andverified(1/0) alongsidelimit/after.- Feedback moved out into its own
Plugins\Feedbackplugin (one plugin, one domain). The/ajx/feedbackroutes +user_feedbacktable now live there.- Registration split into public (
registerPublic→ token only) vs admin (register→ full record); public email verification is token-based (hashed, one-time, 24h) viaverifyEmailByToken.- Input DTOs now extend
Plugins\Validation\AbstractDtoand declarerules()instead of hand-rolled validation.
HTTP ─▶ UserController (thin) CLI ─▶ user:outbox:relay
│ DTO → service → Response │
▼ ▼
UserServiceContract ◀── published to other modules
│
UserService (Application)
├─ authorization (Identity) ── self-or-permission
├─ TransactionManager begin/commit/rollback
├─ DomainEventCollector (in-tx buffer)
├─ OutboxPort ─▶ user_outbox (same tx) ── at-least-once events
├─ HashingPort (crypto.services) ── bcrypt or Argon2id, rehash-on-login
├─ CachePort ─▶ login lockout
└─ AuditLogger
│
UserStore (port) ◀── UserRepository (central DatabasePort, global, version-locked)
│
User aggregate (Domain — zero external imports)
├─ UserId (monotonic ULID), Username, Email, PasswordPolicy
└─ records UserRegistered/Updated/Deleted domain events
(login gate = email_verified_at; no status column)
The five GDA access rules hold: Controller → Service (contract only), Service → Repository + Gateway, Repository → DatabasePort only, Domain imports nothing external.
plugins/User/
├── module.json single source of truth (routes, requires, config)
├── Provider.php DI wiring + CLI registration
├── API/
│ ├── Contracts/UserServiceContract.php the ONLY published interface
│ ├── DTOs/ Register/Update/VerifyEmail/User/ListUsersQuery/UserPage
│ │ + Update{Profile,Preferences,Privacy,NotificationPreferences}
│ └── IntegrationEvents/ UserRegistered/Updated/Deleted, Generic
├── Application/
│ ├── Ports/ UserStore, OutboxPort internal DIP seams (testability)
│ └── Services/ UserService, UserSettingsService
├── Domain/
│ ├── Entities/ User, UserProfile, UserPreferences,
│ │ UserPrivacySettings, UserNotificationPreferences
│ ├── Events/ UserRegistered/Updated/Deleted domain events
│ ├── Exceptions/DuplicateUserException.php
│ └── ValueObjects/ UserId, Ulid, Username, Email, PasswordPolicy, Theme, ProfileVisibility
├── Infrastructure/
│ ├── Audit/AuditLogger.php
│ ├── Cli/RelayUserOutboxCommand.php user:outbox:relay
│ ├── Http/Controllers/ UserController, UserPageController, UserSettingsController
│ ├── Outbox/ OutboxWriter, OutboxRelay
│ └── Persistence/ UserRepository (central), UserSettingsRepository (tenant)
├── config/user.php
├── database/
│ ├── migrations/ create_user_table, create_user_outbox_table (CENTRAL)
│ ├── tenant-template/ user_profiles, user_privacy_settings, user_preferences,
│ │ user_notification_preferences (per-TENANT)
│ ├── seeders/UserSeeder.php
│ └── factories/UserFactory.php
└── resources/views/ layouts/app.php, users/{index,create,edit,show}.php,
account/settings.php
users is the GLOBAL central identity table (authentication is centralized,
username/email globally unique). Owned by the migration; the repository never
alters schema.
| Column | Type | Purpose |
|---|---|---|
id |
bigint PK | internal surrogate (never leaves persistence) |
user_id |
char(31) | public ULID identifier |
username |
varchar(50) | globally unique |
email |
varchar(150) | globally unique, lowercased |
password_hash |
varchar(255) | bcrypt (60 chars) or Argon2id (~97 chars) — widened so either fits |
remember_token |
char(64) null | SHA-256 of the remember-me token |
version |
int unsigned | optimistic-lock version |
email_verified_at |
timestamp null | set on confirmation — this is the login gate |
created_at/updated_at/deleted_at |
timestamps | soft-delete aware |
Uniqueness is global (uniq_username, uniq_email).
Login gate = a verified email. There is no
statuscolumn. A user can authenticate only onceemail_verified_atis set (UserService::verifyCredentialschecksUser::canLogin()); "disable an account" is done via soft delete. The earlierstatus/auth_provider/provider_subject/is_platform_admin/last_login_atcolumns were removed to keep the table lean — federation and platform-admin, if needed, belong in their own tables/claims.
The repository and the user_outbox writer are pinned to the central
connection (the ConnectionManager default) so identity I/O always targets the
central database regardless of any per-request DatabasePort rebinding. A second
table, user_outbox, stores integration events for reliable delivery.
All API responses use the framework envelope:
curl -X POST https://app.example.com/ajx/users \
-H 'Content-Type: application/json' \
-d '{"username":"jane","email":"jane@example.com","password":"C0rrectHorse!"}'
# 201 → { "data": { … } } emits user.registeredcurl 'https://app.example.com/ajx/users?limit=50&after=01J…&q=jane&verified=1' \
-H 'Authorization: Bearer <token>' # or same-site session cookieq matches a case-insensitive substring of either username or email; verified
takes 1/0 (omit for either state). Both narrow the WHERE clause only — the
keyset cursor (after) still orders by user_id, so paging through a filtered
result set stays stable.
curl 'https://app.example.com/ajx/users/01J…/lockout' -H 'Authorization: Bearer <token>'
# → { "data": { "locked": true } }
curl -X DELETE 'https://app.example.com/ajx/users/01J…/lockout' \
-H 'Authorization: Bearer <token>' -H 'X-CSRF-Token: …'
# → 204, clears the failure counter immediately instead of waiting out its 15-minute TTLRequires user:unlock — deliberately separate from user:update-any, since this
reverses a brute-force control rather than editing profile data.
curl -X PUT https://app.example.com/ajx/users/01J… \
-H 'Content-Type: application/json' -H 'X-CSRF-Token: …' \
-d '{"email":"new@example.com"}' # only changed fields; bumps versionA concurrent edit that loses the version race → HTTP 409 (OptimisticLock). A duplicate username/email → HTTP 409/422 (DuplicateUserException).
UserPageController renders four pages (/users, /users/create,
/users/{id}, /users/{id}/edit). Each is a thin HTML shell that hydrates over
AJAX against /ajx/users. Authentication is same-site cookie (no bearer
token in the browser).
CSRF on every form: the page controller (via ViewController →
InteractsWithCsrf) mints an HMAC token bound to a dedicated csrf_bind
cookie. Each page exposes it as <meta name="csrf-token"> and a hidden
_csrf_token field; the shared window.UserApp client sends it as the
X-CSRF-Token header on every unsafe (POST/PUT/PATCH/DELETE) request.
Project requirement: wire a
CsrfTokenLayerinwithSecurity()withbindCookie: 'csrf_bind', the samelifetimeasCSRF_LIFETIME, and do not exempt/api(the UI authenticates by cookie, so the write endpoints must be CSRF-checked). Otherwise tokens are sent but never validated.
Beyond central identity, the plugin owns per-user data that lives in the tenant database (not central): the four settings singletons (profile, preferences, privacy, notification preferences).
Key differences from the identity tables:
- Tenant-routed, not central. Their repository takes the request's
DatabasePortafterTenantContextStagerebinds it — so rows land in the caller's tenant DB. Schema ships indatabase/tenant-template/and is applied per-tenant by the Tenancy tooling, notmigrate:run. user_idis the ULID (char(31), the centralusers.user_id) — a soft reference, no cross-DB foreign key.- Guarded by
auth+tenantfilters. Every route declares"filters": ["auth", "tenant"]; thetenantfilter (from the Tenancy plugin) returns 409 when no tenant is active, so these never hit central by mistake. - Self-scoped. The user id always comes from
Identity, never the body.
UserSettingsService + UserSettingsRepository back all four resources
(getX/updateX); each is GET/PUT /ajx/{profile,preferences,privacy,notification-preferences},
self-scoped, idempotent PUT via the portable upsert, audited on write. Demo
UI at /account/settings.
Internal, not published. Settings are consumed only by this plugin's own controller, so the service is bound
bindInternaland is not inexposes()— onlyUserServiceContractis cross-module. The service returns the domain entity; the controller serialises viaentity->toArray()(no separate output DTO).
| Concern | Mechanism |
|---|---|
| Authorization | In the service: admins act on anyone (user:list, user:*-any); a non-admin only on their own record (hash_equals self-check) |
| Password storage | HashingPort (bcrypt or Argon2id); never password_hash() directly; plaintext never persisted/logged/returned |
| Password strength | PasswordPolicy VO — length 8–72, ≥3 char classes, deny-list |
| Login timing | Constant-time decoy hash for unknown users |
| Brute force | Per-identifier lockout via CachePort (5 failures / 15 min); admin with user:unlock can view/clear early |
| Hash upgrades | needsRehash() → transparent rehash on successful login |
| Central identity | Identity is GLOBAL (central users); repository pinned to the central connection |
| PII in logs | Exception context carries IDs only; audit pseudonymises identifiers |
| Credentials in transit | password_hash / remember_token never appear in any DTO/JSON |
| Audit | AuditLogger writes structured JSON for register/update/delete/login/lockout/rehash |
Integration events are written into user_outbox inside the same
transaction as the state change (atomic) — so an event is never lost even if
the process crashes right after commit. From there:
- On the happy path, the service dispatches the just-committed event to the
EventBussynchronously, in-process, and marks its outbox row dispatched — subscribers (e.g.ProvisionTenantProfileListener) see it before the response returns. user:outbox:relay(cron/supervised) is the crash-recovery backstop: it only re-delivers rows a crash between commit and dispatch left pending. Delivery is at-least-once, idempotency keyed by the event UUID either way.
register/update/delete ──tx──▶ [users row + user_outbox row] COMMIT
│
in-process dispatch ──▶ EventBus ──▶ subscribers
│ (if that crashed before marking dispatched)
cron: php cli user:outbox:relay ────▶ EventBus ──▶ subscribers
Consumers must dedupe on the event id (it may be redelivered after a crash between dispatch and mark-dispatched). Still schedule the relay cron — it is the only thing that recovers a row orphaned by a mid-flight crash.
-
Enable the plugin (publishes config/, database/, resources/):
hkm plugins enable User -
Register the Provider in your project bootstrap:
// projects/<name>/bootstrap/app.php return $builder ->withModules([ Plugins\Crypto\Provider::class, // crypto.services (HashingPort) Plugins\View\Provider::class, // view.rendering Plugins\User\Provider::class, // user.management ]) ->build();
-
Ensure required capabilities are available:
database.management(DatabaseConnectionManagerContract — the repository pins to the central/default connection),crypto.services(HashingPort),cache.redis(CachePort),view.rendering(ViewRendererContract). The plugin declares these inrequires[], so boot fails fast if one is missing. -
Run migrations:
php cli migrate:run php cli db:seed --class=UserSeeder # optional baseline admin -
Schedule the outbox relay (e.g. cron every minute):
* * * * * cd /app && php cli user:outbox:relay --limit=500
.env keys (all optional):
HASH_BCRYPT_COST=12 # bcrypt cost (crypto.services)
CSRF_BIND_COOKIE=csrf_bind # must match the CsrfTokenLayer bindCookie
CSRF_LIFETIME=43200 # must match the CsrfTokenLayer lifetime (seconds)module.json declares requires, routes, views, emits, commands, and
config[]. Every env var the plugin reads is declared in config[] (boot fails
otherwise).
Depend on the published contract, never on internals. Declare the domain in
your module.json:
// plugins/Billing/module.json
{ "requires": ["user.management", "database.management"] }Inject the contract in your Provider:
use Plugins\User\API\Contracts\UserServiceContract;
$container->bind(InvoiceService::class, fn($c) => new InvoiceService(
users: $c->make(UserServiceContract::class), // resolvable because you require user.management
));final class InvoiceService
{
public function __construct(private readonly UserServiceContract $users) {}
public function billFor(string $userId): void
{
$user = $this->users->find($userId); // ?UserDTO — primitives only
if ($user === null) { /* … */ }
}
}React to user lifecycle events instead of polling — subscribe in boot():
public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $w, EventBus $events): void
{
$events->subscribe('user.registered', SendWelcomeEmailListener::class);
$events->subscribe('user.deleted', PurgeUserDataListener::class);
}The event payload is primitives only (userId, username, email,
occurredAt, version) — your module never needs the User plugin's value
objects.
Scope isolation still applies: requiring
user.managementgrants the public contract only.UserRepositoryand otherbindInternalbindings throwScopeViolationExceptionif resolved cross-scope.
A project route can opt into the plugin without loading it app-wide:
// projects/<name>/proj.json
{ "routes": [
{ "method": "GET", "path": "/admin/users",
"handler": "Projects\\Admin\\Http\\AdminUserController@index",
"requires": ["user.management"], "filters": ["auth"] }
] }namespace Projects\Admin\Http;
use Plugins\User\API\Contracts\UserServiceContract;
use Plugins\User\API\DTOs\ListUsersQuery;
use Project\Http\Controllers\ApiController;
final class AdminUserController extends ApiController
{
public function __construct(private readonly UserServiceContract $users) {}
public function index(): Response
{
$page = $this->users->list(ListUsersQuery::fromRequest($this->resolveRequest()));
return $this->paginated(array_map(fn($u) => $u->toArray(), $page->items),
total: count($page->items), page: 1, perPage: $page->limit);
}
}Project views override plugin views by default (project-first cascade); target a
specific plugin view with user::users/index.
php cli user:outbox:relay # relay up to 100 pending events
php cli user:outbox:relay --limit=500Returns the number dispatched. Idempotent and safe to run concurrently (rows are claimed via a status guard).
The service depends on the UserStore and OutboxPort interfaces (DIP), so
it is fully unit-testable with in-memory fakes — no database:
$svc = new UserService(
repository: new FakeUserStore(),
transaction: new TransactionManager(new FakeDatabasePort()),
collector: new DomainEventCollector(),
outbox: new FakeOutbox(),
hasher: new FakeHasher(),
identity: Identity::asAdmin(),
cache: new FakeCache(),
audit: new AuditLogger('admin', fn($l) => null),
);See tests/Unit/Plugins/User/: identity (registration, duplicate rejection,
weak-password rejection, authorization, update/delete events, login lockout,
rehash-on-login), FeedbackServiceTest (auth/ownership/triage, forward-only
status, rating validation), and UserSettingsServiceTest (all four settings,
round-tripped through the real repository against a stateful in-memory DB). Run:
vendor/bin/phpunit tests/Unit/Plugins/UserThis plugin is a template. To build your own domain module the same way:
- module.json — declare
solves,requires,exposes,routes,emits,config. One module, one domain. - Domain —
finalentity with a private constructor + named constructors (create/reconstitute); value objects for every concept; record domain events in the aggregate. Zero external imports. - API — a published
…ServiceContractinterface + DTOs (validation infromRequest) + primitives-only integration events. - Application — a service that owns the
TransactionManager, collects domain events, writes integration events to an outbox in-tx, and does authorization first. - Infrastructure — repository (DatabasePort only, optimistic-locked; control-plane repos pin to the central connection), gateways (vendor SDK only), thin controllers (≤3 lines). Hide concretes behind internal ports so the service stays testable.
- Provider —
register()binds internals withbindInternaland the contract withbind;boot()registers hooks / CLI commands / event subscriptions.
Copy plugins/User/ as a starting skeleton, rename the namespace, and replace
the domain.
Part of the AlfacodeTeam PhpServicePlatform. See the root CLAUDE.md and
docs/ai-context/ for framework-wide architecture.
TenantProfileProvisioner implements the published
TenantProfileReaderContract — fullName(userId, tenantId): string — in two
construction modes: pinned (repository already built against the resolved
tenant connection; the listener path) or resolver (container binding;
resolves the tenant DB per call through Tenancy's
TenantConnectionResolverContract). Reads are best-effort and never throw: a
missing profile or unreachable tenant DB yields ''. Consumers: Tenancy's
tenant-selection flow (the JWT name claim) and UserService::find() (attaches
UserDTO.fullName when a membership pins the tenant). UserDTO also carries
avatarUrl and permissions. UserServiceContract::find() accepts
bool $isAuth = false — issuance-time lookups by Auth skip the
self-or-permission check (the request Identity is still guest during login).
- CLAUDE.md — this plugin's contract, config and rules (start here).
- docs/USER.md — the full User reference.
- Kernel guides — the framework contracts this plugin builds on.