F3: ChatController split
ChatController started at ~800 lines / 16 methods. The audit
findings called it out as the biggest monolith in CRM. The F3 split
sliced it into 6 focused controllers, leaving the original with only
channel-level CRUD.
Before After
┌────────────────────────────┐
│ ChatController │
│ channels() │
│ createChannel() │
└────────────────────────────┘
┌────────────────────────────┐
│ ChatMessageController │
│ index() (GET messages) │
│ store() (POST send) │
┌────────────────────────────┐ │ update() (PUT edit) │
│ ChatController (~800 sor) │ ───split──▶ │ destroy()(DELETE soft) │
│ channels, createChannel, │ │ recall() │
│ messages, sendMessage, │ └────────────────────────────┘
│ updateMessage, │ ┌────────────────────────────┐
│ deleteMessage, │ │ ChatFileController │
│ recallMessage, │ │ upload() (file message)│
│ uploadFile, │ │ serve() (download) │
│ serveFile, │ └────────────────────────────┘
│ toggleReaction, │ ┌────────────────────────────┐
│ addParticipant, │ │ ChatParticipantController │
│ removeParticipant, │ │ add(), remove(), │
│ markRead, │ │ markRead() │
│ addBotParticipant, │ └────────────────────────────┘
│ removeBotParticipant │ ┌────────────────────────────┐
└────────────────────────────┘ │ ChatBotMembershipController│
│ add(), remove() │
└────────────────────────────┘
┌────────────────────────────┐
│ ChatReactionController │
│ toggle() │
└────────────────────────────┘
Why split?
- Cognitive load — finding "where does emoji reaction code live?"
takes seconds, not a
grep. - Constructor DI — each controller only injects what it actually
uses.
ChatControlleris back to a single dependency (WebhookEventPublisher) instead of four. - Future per-controller policies — when
ChatPolicylands, it can declare resource-specific rules without one giant policy file with branching logic for every verb. - Test isolation — unit/feature tests can mount one controller at a time without the others' dependencies.
Public URL stability
All route paths are unchanged. The route file maps the same
/api/crm/chat/channels/{id}/messages path to the new
ChatMessageController::index instead of ChatController::messages.
The SPA and any external integrations need zero changes.
Pattern for further splits
The same approach applies to other heavy controllers:
- TaskController (~600 lines) — candidate splits: TaskBoardController (reorder + board), TaskAssignmentController (user pivot), TaskHistoryController.
- DocumentController (~400 lines) — already has the
ensureFolderAccesshelper; could split move/upload-version into aDocumentVersionController. - ProjectController (~300 lines) — could split membership endpoints out.
These are F1+ work; the F3 batch focused on chat because the impact per slice was highest.
What stays in the parent
The "list/detail of the parent resource" stays. For ChatController
that's channels() (list channels the user is in) and
createChannel() (create a new channel + initial participants).
Everything else moves to a per-sub-resource controller.
Rule of thumb: if a method takes both $channelId and another
sub-resource id ($messageId, $participantUserId, $botId), it
belongs in the sub-resource controller. If it takes only $channelId
or no id, it belongs in the parent.
Cross-controller helpers
The 5 chat controllers share three dependencies:
App\Services\EventPublisher— WebSocket fan-out per participantApp\Services\WebhookEventPublisher— async outbound webhook dispatchApp\Services\BotWebhookDispatcher— bot-specific webhook routing
These remain services (injected via constructor) so each controller
gets exactly the slice it needs. ChatMessageController has all three;
ChatReactionController only EventPublisher; ChatController only
WebhookEventPublisher (for chat.channel.created).