Core Architecture - Controllers

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:

  1. Routing system matches the request
  2. Access checks pass
  3. Controller is instantiated via service container
  4. Controller method is executed
  5. 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.