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 (feedback & 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 |
| 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 |
| Soft-delete | DELETE /ajx/users/{id} |
Self or user:delete-any; emits user.deleted |
| 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
- 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, 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
│ │ + Submit/ListFeedbackQuery/FeedbackPage
│ │ + Update{Profile,Preferences,Privacy,NotificationPreferences}
│ └── IntegrationEvents/ UserRegistered/Updated/Deleted, FeedbackSubmitted, Generic
├── Application/
│ ├── Ports/ UserStore, OutboxPort, FeedbackStore internal DIP seams (testability)
│ └── Services/ UserService, FeedbackService, UserSettingsService
├── Domain/
│ ├── Entities/ User, FeedbackEntry, UserProfile, UserPreferences,
│ │ UserPrivacySettings, UserNotificationPreferences
│ ├── Events/ UserRegistered/Updated/Deleted domain events
│ ├── Exceptions/DuplicateUserException.php
│ └── ValueObjects/ UserId, Ulid, Username, Email, PasswordPolicy,
│ Feedback{Id,Category,Rating,Status,Message}, Theme, ProfileVisibility
├── Infrastructure/
│ ├── Audit/AuditLogger.php
│ ├── Cli/RelayUserOutboxCommand.php user:outbox:relay
│ ├── Http/Controllers/ UserController, UserPageController,
│ │ FeedbackController, UserSettingsController
│ ├── Outbox/ OutboxWriter, OutboxRelay
│ └── Persistence/ UserRepository (central), FeedbackRepository + 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, user_feedback (per-TENANT)
│ ├── seeders/UserSeeder.php
│ └── factories/UserFactory.php
└── resources/views/ layouts/app.php, users/{index,create,edit,show}.php,
account/{settings,feedback}.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 |
char(60) | bcrypt (exactly 60 chars) |
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…' \
-H 'Authorization: Bearer <token>' # or same-site session cookiecurl -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): feedback and the four settings singletons (profile, preferences, privacy, notification preferences).
Key differences from the identity tables:
- Tenant-routed, not central. Their repositories take 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.
| Verb | Path | Who | Service |
|---|---|---|---|
| Create | POST /ajx/feedback |
any authenticated user (throttle:5,1) |
FeedbackService::submit |
| List | GET /ajx/feedback |
feedback:manage (admin triage) |
list |
| Read one | GET /ajx/feedback/{id} |
self or feedback:manage |
find |
| Update status | PATCH /ajx/feedback/{id} |
feedback:manage (forward-only) |
updateStatus |
Emits feedback.submitted (dispatched directly after the write — not via the
outbox, since the write is a single tenant-scoped insert).
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 and /account/feedback.
Internal, not published. Feedback + settings are consumed only by this plugin's own controllers, so their services are bound
bindInternaland are not inexposes()— onlyUserServiceContractis cross-module. Services return 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); never password_hash() directly; plaintext never persisted/logged/returned |
| Password strength | PasswordPolicy VO — length 12–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) |
| 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 not dispatched directly after commit (which can be lost on a crash). Instead:
- The service writes the event into
user_outboxinside the same transaction as the state change (atomic). user:outbox:relay(cron/supervised) reads pending rows and dispatches them to theEventBus— at-least-once, idempotency keyed by the event UUID.
register/update/delete ──tx──▶ [users row + user_outbox row] COMMIT
│
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).
-
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).