How Traditional Controllers Violate SRP
Every Symfony developer has been there: you open a controller file expecting a quick fix, only to find a 500-line monster with 15 methods and 10 injected dependencies. What went wrong? As someone who audits projects for external companies, I see this pattern pop up constantly in growing Symfony applications.
What starts as a simple ProductController can quickly morph into a monolithic God Object — a class that knows too much, does too much, and becomes a nightmare to maintain, test, and scale.
Even though Symfony gives us some nice tools like autowiring and dependency injection through the argument resolver, that alone isn’t enough. You still need solid design patterns to really guide developers and help them build controllers that are clean, lightweight, and easy to maintain. A controller with 15 actions might end up injecting 10 different services, even though each method only uses a few of them. That’s a classic symptom of low cohesion and high coupling: the class isn’t a unified whole, it’s a convenient but messy collection of methods.
While Symfony’s lazy loading mitigates the performance impact of injecting many services, the true cost is human. A constructor with ten dependencies signals a violation of SRP and imposes a heavy cognitive load on any developer trying to understand or modify the class. It becomes impossible to know which dependencies are needed for which action without reading the entire file.
The solution is both elegant and powerful: the Single Action Controller.
In fact, this isn’t just a community trend, it’s a pattern officially recommended in Symfony’s own best practices, especially for a common use case: having a single controller action that both renders a form and processes its submission.
The Single Responsibility Principle (SRP), Applied Correctly to Controllers
The Single Responsibility Principle (SRP), a precise rule that helps enforce a clear Separation of Concerns (SoC), is one of the most frequently misunderstood ideas in software design. Drawing from Robert C. Martin’s clarification, the principle isn’t about a class “doing only one thing.” Instead, it states:
“_A module should have a single reason to change and be the responsibility of exactly one actor.”_
An actor is a group of people, a stakeholder, or a department that requires changes in the software. The SRP is about structuring our code to align with the structure of your organization. For controllers, this means a single controller class should serve the needs of exactly one of these actors.
Why the “Fat Controller” Violates SRP
A “Fat Controller” is a classic SRP violation because it becomes a magnet for requests from multiple, unrelated business departments. It serves many actors, giving it many reasons to change.
Let’s look at a typical OrderController:
class OrderController extends AbstractController
{
// Constructor with many dependencies for different actors...
public function __construct(
private OrderRepository $orderRepository, // Used by Marketing/UX
private PromotionManager $promotionManager, // Used by Marketing
private PaymentGateway $paymentGateway, // Used by Finance
private ShippingCalculator $shippingCalculator, // Used by Logistics
private MailerInterface $mailer, // Used by Finance & Logistics
private LoggerInterface $logger
) {}
// Method for Actor 1: Marketing/UX Team
public function showCart(Request $request): Response { /* ... */ }
// Method for Actor 2: Finance Department
public function processPayment(Request $request): Response { /* ... */ }
// Method for Actor 3: Logistics Department
public function calculateShipping(Request $request): Response { /* ... */ }
}
This single class is forced to change for reasons driven by completely different parts of the business. It is responsible to three distinct actors:
- Actor: The Marketing & UX Team
- Their Goal: Optimize the user’s shopping experience to drive sales.
- Reason to Change: They might request a new feature in the cart display, like adding promo codes or changing the layout. A change to the showCart method is required.
2. Actor: The Finance Department
- Their Goal: Ensure payments are processed securely and accurately.
- Reason to Change: They might decide to switch payment providers or add new fraud-detection logic. A change to the processPayment method is needed.
3. Actor: The Logistics Department
- Their Goal: Fulfill and ship orders efficiently.
- Reason to Change: They could partner with a new shipping carrier or need to adjust the shipping cost calculation rules. This requires modifying the calculateShipping method.
The problem is that these unrelated reasons for change are tangled together in one class. A developer from the finance team modifying payment logic could accidentally introduce a bug that breaks the shipping calculator, affecting the logistics team. This creates high coupling between organizational departments within the codebase, leading to merge conflicts, increased cognitive load, and a higher risk of regressions.
How Single-Action Controllers Enforce SRP
By adopting Single-Action Controllers, you align your code structure with your organizational structure. Each controller is responsible to exactly one actor and handles the orchestration of a single use case.
1. Serving the Marketing & UX Team
This controller’s sole reason to exist is to fulfill requests from the Marketing/UX team regarding the cart’s presentation.
#[Route('/cart', methods: ['GET'])]
final class ShowCartController extends AbstractController
{
public function __construct(
private CartService $cartService,
private PromotionService $promotionService
) {}
public function __invoke(Request $request): Response
{
// Single responsibility: orchestrate cart display for the user
$cartData = $this->cartService->getCartForDisplay($this->getUser());
return $this->render('cart/show.html.twig', $cartData);
}
}
- Single Actor & Reason to Change: The Marketing Team needs to alter the cart’s appearance or data. Changes are isolated here, with zero risk of affecting payments or shipping.
2. Serving the Finance Department
This controller is exclusively owned by the needs of the Finance department.
#[Route('/payment/process', methods: ['POST'])]
final class ProcessPaymentController extends AbstractController
{
public function __construct(private PaymentService $paymentService) {}
public function __invoke(Request $request): Response
{
// Single responsibility: orchestrate payment processing
$result = $this->paymentService->processPayment($request->request->all());
return $this->json(['status' => 'success', 'id' => $result->getId()]);
}
}
- Single Actor & Reason to Change: The Finance Department needs to modify the payment workflow. This class can be changed independently of all other application features.
3. Serving the Logistics Department
This controller’s responsibility is to the Logistics team and no one else.
#[Route('/shipping/calculate', methods: ['POST'])]
final class CalculateShippingController extends AbstractController
{
public function __construct(private ShippingService $shippingService) {}
public function __invoke(Request $request): Response
{
// Single responsibility: orchestrate shipping cost calculation
$cost = $this->shippingService->calculateShipping($request->request->all());
return $this->json(['cost' => $cost]);
}
}
- Single Actor & Reason to Change: The Logistics Department needs to update how shipping is calculated.
By breaking up the “fat controller,” we’ve created small, focused classes. The dependencies of each controller are now a clear signal of which actor it serves. This design minimizes coupling, reduces the risk of unintended side effects, and makes the codebase dramatically easier to navigate and maintain. Your code’s structure now reflects and respects your organization’s structure.
Radically Simplified Testing (This is a Game-Changer)
This is where the pattern truly shines. Let’s compare the testing experience.
The “Fat Controller” Testing Nightmare:
// Testing the processPayment method requires mocking ALL dependencies
public function testProcessPayment()
{
// Need to mock services that have nothing to do with payment processing
$orderRepository = $this->createMock(OrderRepository::class);
$shippingCalculator = $this->createMock(ShippingCalculator::class); // Not needed!
$promotionManager = $this->createMock(PromotionManager::class); // Not needed!
$paymentGateway = $this->createMock(PaymentGateway::class);
$mailer = $this->createMock(MailerInterface::class);
$logger = $this->createMock(LoggerInterface::class); // Not needed!
$controller = new OrderController(
$orderRepository,
$paymentGateway,
$shippingCalculator,
$promotionManager,
$mailer,
$logger
);
// Complex setup just to test one method...
}
The problems: Noisy setup, fragility (change the constructor, break all tests), and high cognitive load.
The Single Action Controller Testing Dream:
public function testProcessPayment()
{
// We mock the service, not its dependencies
$paymentService = $this->createMock(PaymentService::class);
// We can configure the mock if needed
// $paymentService->expects($this->once())->method('processPayment')->willReturn(...);
$controller = new ProcessPaymentController($paymentService);
$response = $controller($this->createPaymentRequest());
$this->assertSame(200, $response->getStatusCode());
}
The benefits: Readability, robustness, and maintainability. You test behavior, not configuration.
Addressing Common Concerns
“But doesn’t this create too many files?”
Yes, you’ll have more files, but organization beats convenience every time. Would you rather have:
- One 500-line file that’s impossible to navigate, or
- Fifty 10-line files that are immediately understandable?
Modern IDEs make file navigation trivial, and the mental overhead of understanding a focused class is dramatically lower.
“What about code duplication?”
Shared logic doesn’t belong in controllers anyway — it belongs in services, Command Handlers. Single Action Controllers actually encourage better architecture by making this obvious:
// Shared logic moves to services where it belongs
class OrderService
{
public function validateOrderData(array $data): void
{
// Validation logic used by multiple controllers
}
}
“Won’t this hurt performance?”
Symfony’s lazy loading means classes are only instantiated when needed. The performance impact is negligible, and the maintainability gains far outweigh any theoretical overhead.
The Bottom Line: An Architectural Win-Win
Adopting Single Action Controllers isn’t just a syntactic preference; it’s a strategic architectural decision that prioritizes maintainability, robustness, and cleaner code.
The “Fat Controller” is technical debt in disguise. Easy to write at first, but its hidden costs in testing time, debugging, and fragility compound quickly.
The Single Action Controller requires a slight shift in mindset but pays immediate and lasting dividends. You get a codebase that’s easier for new developers to onboard, safer to refactor, and built to last.
For a deeper dive into this architectural pattern, I highly recommend watching “ Cruddy by Design” by Adam Wathan from Laracon US 2017.
Top comments (1)
A controller doesn't need to be a god object, just add the needed dependency to the methods instead of the constructor and it already saves you headaches.
For the example I would move the
showCart
method to aCartController
. The rest of the methods look fine as part of the order actions.Some comments have been hidden by the post's author - find out more