Killing the God Controller: A Vertical Slicing Strategy
How we decomposed a 1,500-line 'God Controller' into atomic, readable slices using PHP Traits and Architectural Rigor.
The Obsession with Order
In the early days of a project, a controller often starts as a small, manageable file. But as features creep in—Authentication, CRUD, Admin tools, Third-party integrations—that file begins to swell. It becomes a God Controller: a monolithic beast that knows too much, does too much, and eventually, becomes impossible to change without fear.
At erpbsg, our StaffProfileController had reached that breaking point. At 1,484 lines of code, it wasn’t just a file; it was a liability.
It was time for a Reboot.
The Mess: 1.5k Lines of Cognitive Noise
The original controller was a textbook example of “High Coupling.” It was handling everything from public account activation to sensitive administrative assignments.
But the real “villain” was the Boilerplate. Look at this repetitive pattern that haunted every single method:
public function updateMe(Request $request, Response $response): Response
{
$userId = $request->getAttribute('user_id');
$data = $request->getParsedBody();
try {
$result = $this->staffProfileService->updateStaffByUserId($userId, $data);
$response->getBody()->write(json_encode($result, JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
} catch (NotFoundException $e) {
$response->getBody()->write(json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
} catch (\Exception $e) {
$this->logger->error("Error in updateMe: " . $e->getMessage());
$response->getBody()->write(json_encode(['error' => 'Internal Server Error.'], JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
}
}
Imagine this block repeated for 18 endpoints. That’s nearly 600 lines of pure noise. The business logic was drowned in a sea of json_encode and manual header management.
The Strategy: Slices and Traits
Our strategy for the Reboot was twofold:
-
Vertical Slicing: Instead of one massive controller, we would create four specialized “Slices” based on security contexts and responsibility:
StaffMeController: Self-service for authenticated staff.StaffActivationController: Public token-based activation.AdminStaffController: Global administrative CRUD.StaffAssignmentController: Merchant-specific assignments.
-
The Weapon:
ApiControllerTrait: We decided to stop repeating ourselves. We built a native Trait to handle the “plumbing” of the API, allowing our controllers to focus purely on the request/response flow.
The Craft: Precision Extraction
We didn’t just cut and paste. We followed the BOS Protocol: TDD first. We kept the original test suite (17 integration tests) as our safety net while we moved logic one slice at a time.
First, we extracted the “Plumbing” into a Trait:
trait ApiControllerTrait
{
protected function jsonResponse(Response $response, mixed $data, int $status = 200): Response
{
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json')->withStatus($status);
}
protected function handleException(Response $response, \Exception $e, LoggerInterface $logger, string $context = ''): Response
{
// Internal mapping of Domain Exceptions to HTTP Status Codes
$status = match(get_class($e)) {
ValidationException::class => 400,
NotFoundException::class => 404,
ConflictException::class => 409,
ForbiddenException::class => 403,
default => 500
};
if ($status === 500) $logger->error("Error in {$context}: " . $e->getMessage());
return $this->jsonResponse($response, ['error' => $e->getMessage()], $status);
}
}
Then, we implemented the new, ultra-lean controllers. Look at the difference in the updateMe method now:
public function updateMe(Request $request, Response $response): Response
{
$userId = $request->getAttribute('user_id');
$data = $request->getParsedBody();
try {
$result = $this->staffProfileService->updateStaffByUserId($userId, $data);
return $this->jsonResponse($response, $result);
} catch (\Exception $e) {
return $this->handleException($response, $e, $this->logger, 'updateMe Staff');
}
}
The “Cognitive Noise” is gone. The intention is clear.
The Result: Architectural Sovereignty
By killing the God Controller, we achieved:
- Minimalism: Deleted the 1,484-line file. Replaced it with 4 controllers averaging 120 lines each.
- Security: Public activation logic is now physically separated from administrative tools.
- Readability: The code is now “Art.” You can understand any endpoint in less than 5 seconds of scanning.
- Confidence: PHPStan now reports 0 errors across the new structure, and our test suite remains 100% Green.
This is Engineering as Art. We didn’t just fix a bug; we restored the architectural integrity of our ecosystem.
DAMMGO HOLDING - Engineering Longevity.