Controller Split (F3)
The audit identified three monolith controllers — UserController
(985 LoC), Admin\TenantController (906 LoC), ProfileController
(747 LoC) — each holding multiple unrelated responsibilities. F3 split
them along functional lines and extracted shared cross-cutting concerns
into services.
Why split
- Single responsibility per file —
UserControllerwas doing CRUD, role management, AND permission editing. Each domain has its own test surface and its own permission gate. - Less duplicated permission resolution — the same "user can do X"
lookup lived in 4 places (
User::getAllPermissions,CheckPermission,JwtClaimService,ProfileController). Drift between those copies caused the "non-admin always denied" class of bugs in the audit. - Easier to apply policies — F6
Gate::defineregistry maps cleanly onto smaller controllers.
UserController split
| Was | After |
|---|---|
UserController | UserController (CRUD only — index/show/store/update/destroy) |
UserRoleController (/users/{user}/roles*) | |
UserPermissionController (/users/{user}/permissions*, /custom-permissions) |
Routes mapped:
GET /api/users UserController@index
POST /api/users UserController@store
GET /api/users/{user} UserController@show
PUT /api/users/{user} UserController@update
DELETE /api/users/{user} UserController@destroy
GET /api/users/{user}/roles UserRoleController@index
POST /api/users/{user}/roles UserRoleController@store
DELETE /api/users/{user}/roles/{role} UserRoleController@destroy
GET /api/users/{user}/permissions UserPermissionController@index
GET /api/users/{user}/custom-permissions UserPermissionController@showCustom
PUT /api/users/{user}/permissions UserPermissionController@sync
Admin\TenantController split
| Was | After |
|---|---|
Admin\TenantController | Admin\TenantController (CRUD only) |
Admin\TenantMemberController (owner / users / role) | |
Admin\TenantCredentialsController (OAuth client + system token) |
# Tenant CRUD
GET /api/admin/tenants AdminTenantController@index
POST /api/admin/tenants AdminTenantController@store
GET /api/admin/tenants/{tenant} AdminTenantController@show
PUT /api/admin/tenants/{tenant} AdminTenantController@update
DELETE /api/admin/tenants/{tenant} AdminTenantController@destroy
# Member management
POST /api/admin/tenants/{tenant}/owner AdminTenantMemberController@setOwner
POST /api/admin/tenants/{tenant}/users AdminTenantMemberController@addUser
DELETE /api/admin/tenants/{tenant}/users/{user} AdminTenantMemberController@removeUser
PUT /api/admin/tenants/{tenant}/users/{user}/role AdminTenantMemberController@updateUserRole
# Credentials
GET/POST /api/admin/tenants/{tenant}/oauth-client* AdminTenantCredentialsController@*
GET/POST /api/admin/tenants/{tenant}/system-token* AdminTenantCredentialsController@*
ProfileController split
| Was | After |
|---|---|
ProfileController | ProfileController (CRUD: profile, avatar, email, activity, deletion) |
Profile\PermissionsController (permission introspection) | |
Profile\AuthorizationsController (OAuth authorizations / connected apps) |
# Profile CRUD (unchanged paths)
GET /api/profile ProfileController@show
PUT /api/profile ProfileController@update
POST /api/profile/avatar ProfileController@updateAvatar
DELETE /api/profile/avatar ProfileController@deleteAvatar
PUT /api/profile/email ProfileController@updateEmail
GET /api/profile/activity ProfileController@activityLog
DELETE /api/profile/account ProfileController@deleteAccount (F7: 30-day GDPR)
POST /api/profile/account/cancel-deletion ProfileController@cancelDeletion (F7)
# Permission introspection (new namespace)
GET /api/me/permissions Profile/PermissionsController@index
GET /api/me/permissions/{tenant} Profile/PermissionsController@tenantPermissions
GET /api/me/policies/{tenant} Profile/PermissionsController@tenantPolicies
GET /api/me/access Profile/PermissionsController@accessSummary
# OAuth authorizations
GET /api/me/authorizations Profile/AuthorizationsController@index
DELETE /api/me/authorizations/{id} Profile/AuthorizationsController@revoke
GET /api/me/connected-apps Profile/AuthorizationsController@connectedApps
DELETE /api/me/connected-apps/{id} Profile/AuthorizationsController@disconnectApp
Extracted services
| File | Purpose |
|---|---|
App\Services\PermissionResolver | Single source of truth for resolving a user's effective permission set. Handles direct policies, global roles, tenant-pivot roles, with dotted glob wildcards. |
App\Services\UserAccessPolicy | Tenant-aware access checks (canViewUser, canManageUser, canManageTenantUsers, canAssignRole) shared between UserController, UserRoleController, UserPermissionController. |
App\Services\StepUpAuthService | Short-lived elevation cache for the Tenant Owner Danger Zone flow. |
App\Services\EventPublisher | RabbitMQ topic publisher (F7). |
Line count summary
| Original | LoC | After F3 (LoC) |
|---|---|---|
UserController | 985 | UserController 341 + UserRoleController 136 + UserPermissionController 125 = 602 |
Admin\TenantController | 906 | Admin\TenantController 252 + Admin\TenantMemberController 186 + Admin\TenantCredentialsController 167 = 605 |
ProfileController | 747 | ProfileController 201 + Profile\PermissionsController 290 + Profile\AuthorizationsController 126 = 617 |
The total LoC change is small (the logic is largely the same) — what matters is the per-file cognitive load and the testable surface area.
Where to look in code
app/Http/Controllers/UserController.php+ sibling filesapp/Http/Controllers/Admin/Tenant*Controller.phpapp/Http/Controllers/Profile/— namespaced subcontrollersapp/Services/PermissionResolver.php— extracted resolverapp/Services/UserAccessPolicy.php— extracted access checksroutes/api.php— full route → controller wiring