Skip to main content

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. ChatController is back to a single dependency (WebhookEventPublisher) instead of four.
  • Future per-controller policies — when ChatPolicy lands, 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 ensureFolderAccess helper; could split move/upload-version into a DocumentVersionController.
  • 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 participant
  • App\Services\WebhookEventPublisher — async outbound webhook dispatch
  • App\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).