Fat controllers are one of the most pervasive structural problems in PHP applications, and this guide provides a concrete playbook for dismantling them safely. This page is aimed at developers who recognise that their controller methods have grown beyond maintainability and need practical extraction strategies, service layer patterns, and before-and-after examples they can apply immediately. The techniques here come from refactoring production PHP codebases across Zend Framework, Laravel, Symfony, and plain PHP projects - work where getting the extraction wrong means breaking features that real users depend on. Below you will find guidance on recognising fat controllers, understanding the costs they impose, extracting service classes, moving validation logic out of controllers, building single-action controllers, testing refactored code, wiring services through dependency injection, and knowing when to leave a fat controller alone.
For the architectural principles behind proper separation of concerns in PHP applications, see the Survive the Deep End guides hub.
Recognising Fat Controllers
A fat controller is a controller method that has accumulated responsibilities beyond its intended role of receiving a request and returning a response. The MVC pattern assigns the controller a coordination role: it interprets the request, delegates work to the model layer, and passes results to the view. A fat controller does the work itself instead of delegating.
Concrete symptoms:
- Line count: any controller action over 30-40 lines deserves scrutiny. Actions over 100 lines are almost certainly fat.
- Multiple levels of indentation: nested
ifblocks,foreachloops containing business logic, andtry/catchblocks wrapping dozens of lines suggest the method is doing too much. - Direct database queries: SQL queries or ORM calls scattered through the action instead of living in repository or model classes.
- Inline validation: validation rules defined inside the controller method rather than in a dedicated validator or form request class.
- Multiple side effects: a single action that saves to the database, sends an email, dispatches a queue job, writes to a log, and updates a cache. Each of those is a separate responsibility.
- Copy-pasted logic: the same block of code appears in multiple actions, sometimes with slight variations. This is the most damaging symptom because bugs must be fixed in every copy.
The Costs of Fat Controllers
Fat controllers are not just an aesthetic problem. They impose real costs:
Testing difficulty: a fat controller action cannot be unit tested. Its logic is tangled with HTTP concerns (request parsing, session access, redirect generation). You are forced to write integration tests that boot the entire framework and make HTTP requests, which are slow and brittle. When a test fails, you cannot tell whether the problem is in the business logic, the request handling, or the view rendering.
Change amplification: when business rules change, you must find every controller action that implements those rules and update them all. In a properly structured application, the rule lives in one place and every controller calls that one place.
Onboarding friction: a new developer looking at a 300-line controller action has no idea what the method does without reading every line. A controller that delegates to well-named service methods reads like documentation: $this->entryCreator->create($data) tells you what happens without revealing how.
Merge conflicts: when multiple developers work on different features that touch the same fat controller, merge conflicts are frequent and painful. Extracting logic into separate classes gives each feature its own file, reducing conflicts.
Extraction Strategy: The Service Class
The primary refactoring for fat controllers is extracting logic into service classes. A service class is a plain PHP class with methods that perform a single coherent piece of business logic. It has no knowledge of HTTP, views, or the controller layer.
Here is a real-world before state. This ZF1 controller action creates a blog entry:
1 | public function createAction() |
This action handles seven distinct responsibilities: form validation, slug generation, duplicate slug checking, database persistence, tag management, email notification, and cache invalidation. Every one of those should be extracted.
Introducing Service Classes
Start by extracting the core creation logic:
1 | namespace App\Service; |
The slug generation logic moves to its own class:
1 | namespace App\Service; |
Tag management becomes its own service:
1 | namespace App\Service; |
The refactored controller action:
1 | public function createAction() |
The action now reads top-to-bottom as a sequence of high-level steps: validate, create, notify, invalidate cache, redirect. Each step is a single method call to a dedicated service.
Moving Validation Out of Controllers
The example above still uses $form->isValid() inside the controller, which is acceptable for ZF1 applications where forms are the validation layer. In more modern setups, you can push validation into the service layer or into dedicated validator classes.
A standalone validator class:
1 | namespace App\Validation; |
The service calls the validator:
1 | public function create(array $data, int $authorId): int |
The controller catches the exception and re-renders the form with errors. This pattern keeps the validation rules in one place regardless of whether the entry is created through a web form, an API endpoint, or a CLI command.
Single-Action Controllers
When a controller class has only one meaningful action (or when one action is significantly more complex than the others), consider using a single-action controller. This is a class with one public method, often __invoke():
1 | namespace App\Controller; |
Single-action controllers have several benefits:
- The constructor explicitly declares all dependencies. No hidden calls to service locators or registries.
- The class is small enough to read in one screen.
- File names map directly to features:
CreateEntryController.php,PublishEntryController.php,DeleteEntryController.php. - Each controller can be tested independently with its own set of mocked dependencies.
In ZF1, single-action controllers require routing each action to a separate controller class. The routing configuration becomes more verbose, but the codebase becomes more navigable. In modern frameworks (Symfony, Slim, Laravel), invokable controllers are a first-class concept.
Testing Refactored Code
The entire point of extracting logic from controllers is to make it testable. Once EntryCreator is a standalone class with injected dependencies, testing it is straightforward:
1 | namespace Tests\Service; |
These tests run in milliseconds with no database, no HTTP server, and no framework bootstrap. They verify the logic of the service class in isolation. This is the payoff for the extraction work.
Dependency Injection for Services
In ZF1 applications without a dependency injection container, you wire services manually in a factory or bootstrap method:
1 | $slugGenerator = new \App\Service\SlugGenerator(); |
This is verbose but explicit. Every dependency is visible. If you are modernising toward a container-based approach, this manual wiring is a stepping stone. You can introduce a simple DI container (PHP-DI, Pimple, or even a hand-rolled factory class) and register these services:
1 | $container['slug_generator'] = function () { |
The controller receives the service through its constructor (if your framework supports constructor injection) or retrieves it from the container in a factory.
When Not to Refactor
Not every fat controller needs immediate surgery. Consider leaving it alone when:
- The code is stable and rarely changes: if a 200-line action has not been touched in two years and has no reported bugs, the risk of refactoring outweighs the benefit. Refactor when you need to change it, not before.
- The application is being replaced: if the entire application is scheduled for decommission in six months, spending time on structural improvements is waste.
- You have no tests and cannot add them: refactoring without tests is dangerous. If the code is so coupled that you cannot even write characterisation tests, focus on adding test coverage first. The guide on modernising Zend Framework applications covers strategies for getting tests into untested code.
- The fat controller is the only one: if your application has one messy controller and fifty clean ones, the structural problem is contained. Document the mess and move on to higher-value work.
The goal is not architectural purity. The goal is a codebase that is safe to change and efficient to work in. Fat controller refactoring is a tool for getting there - use it where it delivers the most value.