Controllers are a core architectural concept in modern Drupal. They sit at the boundary between the HTTP request and Drupal’s internal systems. Every route that does not point to a form eventually resolves to a controller.
Starting with Drupal 8, controllers replaced procedural callbacks and menu based page builders from Drupal 7. This change aligned Drupal with modern MVC inspired frameworks while still preserving Drupal’s render and cache driven architecture.
The focus here is not how to write a controller quickly, but how controllers fit into Drupal’s request lifecycle and architectural design.
Why Controllers Belong in Core Architecture
Controllers are not business logic containers. They are request handlers.
Drupal controllers are responsible for:
- Receiving resolved route parameters
- Coordinating services
- Returning render arrays or response objects
- Attaching cacheability metadata
They do not store data, query databases directly, or contain complex domain logic. That responsibility belongs to services.
Understanding this separation is critical for scalable Drupal architecture.
Controllers in the Request Lifecycle
Controllers execute after routing and access checks.
High level flow:
- Routing system matches the request
- Access checks pass
- Controller is instantiated via service container
- Controller method is executed
- Render array or response is returned
Controllers operate in a fully resolved context. They do not decide whether a user has access. They assume access has already been granted.
What Is a Controller in Drupal
A controller is a PHP class method that returns either:
- A render array
- A Symfony Response object
Most Drupal controllers extend ControllerBase, but extending it is optional.
Example controller:
namespace Drupal\my_module\Controller;
use Drupal\Core\Controller\ControllerBase;
class ExampleController extends ControllerBase {
public function content() {
return [
'#markup' => 'Hello from controller',
];
}
}
ControllerBase Explained
ControllerBase is a convenience base class. It provides helper methods such as:
- currentUser()
- t()
- entityTypeManager()
- config()
- messenger()
These methods internally access services.
Using ControllerBase is acceptable, but relying heavily on its helpers can hide dependencies. For strict dependency injection, controllers can be plain PHP classes.
Controllers Without ControllerBase
A controller does not need to extend ControllerBase.
use Drupal\my_module\Service\ExampleService;
class ExampleController {
protected ExampleService $exampleService;
public function __construct(ExampleService $exampleService) {
$this->exampleService = $exampleService;
}
public function content() {
$this->exampleService->doSomething();
return ['#markup' => 'Executed'];
}
}
This approach makes dependencies explicit and improves testability.
Dependency Injection in Controllers
Controllers are services.
They are instantiated by the service container using a factory method.
use Symfony\Component\DependencyInjection\ContainerInterface;
public static function create(ContainerInterface $container) {
return new static(
$container->get('my_module.example_service')
);
}
This pattern applies to Drupal 8, 10, and 11.
Route Parameters and Controllers
Controllers receive resolved route parameters automatically.
Example route:
path: '/node/{node}'
Controller method:
use Drupal\node\NodeInterface;
public function content(NodeInterface $node) {
return ['#markup' => $node->label()];
}
Parameter conversion happens before controller execution.
Returning Render Arrays
Most controllers should return render arrays.
Reasons:
- Automatic caching
- Theme system integration
- Alter hooks support
- Lazy builders
Example:
return [
'#theme' => 'item_list',
'#items' => ['One', 'Two'],
];
Returning Response Objects
Controllers may return Response objects when necessary.
Common cases:
- File downloads
- JSON responses
- Redirects
Example:
use Symfony\Component\HttpFoundation\JsonResponse;
return new JsonResponse(['status' => 'ok']);
When returning Response objects, caching must be handled explicitly.
Controllers and Caching
Controllers do not cache themselves. Render arrays do.
Controllers are responsible for attaching cacheability metadata.
Example:
return [
'#markup' => 'Cached output',
'#cache' => [
'contexts' => ['user.roles'],
'tags' => ['node:1'],
],
];
Improper cache metadata leads to incorrect or insecure output.
Controllers vs Forms
Controllers:
- Handle read operations
- Build pages
- Return output
Forms:
- Handle user input
- Validate data
- Submit data
Routing decides whether a request goes to a controller or a form.
Controllers vs Services
Controllers:
- Orchestrate
- Handle HTTP
- Coordinate dependencies
Services:
- Contain business logic
- Are reusable
- Are framework agnostic
A controller should be thin. A service should do the work.
Common Mistakes
- Putting database queries directly in controllers
- Performing access checks in controllers
- Using \Drupal::service() repeatedly
- Returning HTML strings instead of render arrays
- Overloading controllers with logic
Drupal 10 and 11 Best Practices
- Use dependency injection
- Keep controllers thin
- Delegate logic to services
- Prefer render arrays
- Attach explicit cache metadata
How Controllers Fit with Other Core Systems
Controllers integrate tightly with:
- Routing system
- Service container
- Render API
- Cache API
- Access system
Understanding controllers makes these systems easier to reason about.
Summary
Controllers are the execution point of Drupal’s routing system. They translate resolved requests into renderable output while coordinating services and cacheability. Treating controllers as orchestration layers rather than logic containers is essential for clean Drupal 10 and 11 architecture.
This article prepares you for advanced topics such as controller resolvers, response subscribers, and HTTP kernel events.