Skip to main content

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 fileUserController was 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::define registry maps cleanly onto smaller controllers.

UserController split

WasAfter
UserControllerUserController (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

WasAfter
Admin\TenantControllerAdmin\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

WasAfter
ProfileControllerProfileController (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

FilePurpose
App\Services\PermissionResolverSingle 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\UserAccessPolicyTenant-aware access checks (canViewUser, canManageUser, canManageTenantUsers, canAssignRole) shared between UserController, UserRoleController, UserPermissionController.
App\Services\StepUpAuthServiceShort-lived elevation cache for the Tenant Owner Danger Zone flow.
App\Services\EventPublisherRabbitMQ topic publisher (F7).

Line count summary

OriginalLoCAfter F3 (LoC)
UserController985UserController 341 + UserRoleController 136 + UserPermissionController 125 = 602
Admin\TenantController906Admin\TenantController 252 + Admin\TenantMemberController 186 + Admin\TenantCredentialsController 167 = 605
ProfileController747ProfileController 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 files
  • app/Http/Controllers/Admin/Tenant*Controller.php
  • app/Http/Controllers/Profile/ — namespaced subcontrollers
  • app/Services/PermissionResolver.php — extracted resolver
  • app/Services/UserAccessPolicy.php — extracted access checks
  • routes/api.php — full route → controller wiring