05

Dependency Injection in Drupal 11: A Practical Guide

Have you ever written \Drupal::service('entity_type.manager') inside a class and wondered if there's a better way? There is. Dependency injection (DI) is the foundation of modern Drupal development, and mastering it will make your code more testable, maintainable, and professional.

What you will learn:

  • How the service container works and why it matters
  • Creating custom services with constructor injection
  • Using services in controllers, forms, blocks, hooks, and event subscribers
  • Advanced patterns including optional dependencies and service tags

Prerequisites:

  • PHP classes, interfaces, and namespaces
  • Basic Drupal module structure
  • A working Drupal 11 environment (examples use DDEV)

Estimated time: 45 minutes


Understanding the Service Container

The Problem with Static Calls

Consider this common anti-pattern:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Service;

class ArticleManager {

  public function getRecentArticles(): array {
    // Reaching inside the class to fetch dependencies.
    $entity_type_manager = \Drupal::entityTypeManager();
    $current_user = \Drupal::currentUser();
    // ...
  }

}

This works, but it creates problems:

  • Testing is difficult - You cannot easily substitute mock objects
  • Dependencies are hidden - You must read the method body to understand what the class needs
  • Tight coupling - The class depends on Drupal's global state

The Solution: Constructor Injection

With dependency injection, dependencies are passed in through the constructor:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Service;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountProxyInterface;

class ArticleManager {

  public function __construct(
    protected EntityTypeManagerInterface $entityTypeManager,
    protected AccountProxyInterface $currentUser,
  ) {}

  public function getRecentArticles(): array {
    // Dependencies are ready to use.
    // ...
  }

}

Why this matters:

  1. Testability - Swap real services for mocks in tests
  2. Clarity - Dependencies are explicit in the constructor signature
  3. Flexibility - Implementations can be changed without modifying your class
  4. Standards - This is the Drupal-recommended approach since Drupal 8

How the Container Works

Drupal uses Symfony's dependency injection component. The service container maintains a registry of all services, knowing:

  • Which services exist (by service ID)
  • How to instantiate them (class name and constructor arguments)
  • What dependencies each service requires

Services are defined in *.services.yml files. When you request a service, the container instantiates it with all dependencies automatically resolved.


Creating a Custom Service

Let's build a practical service for managing featured content. This demonstrates real-world patterns you will use in your own projects.

Step 1: Module Structure

Create the following structure:

web/modules/custom/gd_content/
├── gd_content.info.yml
├── gd_content.services.yml
└── src/
    └── Service/
        └── FeaturedContentManager.php

Step 2: Module Info File

web/modules/custom/gd_content/gd_content.info.yml:

name: 'GD Content'
type: module
description: 'Provides content management services'
core_version_requirement: ^10 || ^11
package: Custom

Step 3: Service Class

web/modules/custom/gd_content/src/Service/FeaturedContentManager.php:

<?php

declare(strict_types=1);

namespace Drupal\gd_content\Service;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\node\NodeInterface;
use Psr\Log\LoggerInterface;

/**
 * Manages featured content across the site.
 */
class FeaturedContentManager {

  /**
   * Constructs a FeaturedContentManager object.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager.
   * @param \Drupal\Core\Session\AccountProxyInterface $currentUser
   *   The current user.
   * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
   *   The date formatter.
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger channel.
   */
  public function __construct(
    protected EntityTypeManagerInterface $entityTypeManager,
    protected AccountProxyInterface $currentUser,
    protected DateFormatterInterface $dateFormatter,
    protected LoggerInterface $logger,
  ) {}

  /**
   * Retrieves featured articles.
   *
   * @param int $limit
   *   Maximum articles to return.
   * @param string $bundle
   *   Content type to query.
   *
   * @return \Drupal\node\NodeInterface[]
   *   Array of published nodes.
   */
  public function getFeaturedContent(int $limit = 5, string $bundle = 'article'): array {
    try {
      $storage = $this->entityTypeManager->getStorage('node');
      $nids = $storage->getQuery()
        ->condition('type', $bundle)
        ->condition('status', NodeInterface::PUBLISHED)
        ->accessCheck(TRUE)
        ->sort('created', 'DESC')
        ->range(0, $limit)
        ->execute();

      return $nids ? $storage->loadMultiple($nids) : [];
    }
    catch (\Exception $e) {
      $this->logger->error('Failed to retrieve featured content: @message', [
        '@message' => $e->getMessage(),
      ]);
      return [];
    }
  }

  /**
   * Checks if a node should be featured.
   */
  public function isFeatured(NodeInterface $node): bool {
    return $node->isPublished() && $node->access('view', $this->currentUser);
  }

  /**
   * Gets a formatted creation date.
   */
  public function getFormattedDate(NodeInterface $node, string $format = 'medium'): string {
    return $this->dateFormatter->format($node->getCreatedTime(), $format);
  }

}

Note: Constructor property promotion (PHP 8+) combines declaration and assignment: protected EntityTypeManagerInterface $entityTypeManager in the constructor both declares the property and assigns the argument to it.

Step 4: Service Definition

web/modules/custom/gd_content/gd_content.services.yml:

services:
  gd_content.featured_content_manager:
    class: Drupal\gd_content\Service\FeaturedContentManager
    arguments:
      - '@entity_type.manager'
      - '@current_user'
      - '@date.formatter'
      - '@logger.channel.gd_content'

  logger.channel.gd_content:
    parent: logger.channel_base
    arguments: ['gd_content']

Key syntax:

  • Service ID: module_name.service_name by convention
  • @ prefix: References another service
  • Argument order: Must match constructor parameter order exactly

Step 5: Enable and Verify

ddev drush en gd_content -y
ddev drush cr

Verify registration:

ddev drush eval "print_r(\Drupal::hasService('gd_content.featured_content_manager'));"

Output 1 confirms the service exists.


Using Services in Different Contexts

Drupal provides different injection patterns depending on the context. Here is when to use each.

Controllers

Controllers use the create() factory method pattern.

web/modules/custom/gd_content/src/Controller/FeaturedContentController.php:

<?php

declare(strict_types=1);

namespace Drupal\gd_content\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\gd_content\Service\FeaturedContentManager;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Controller for featured content pages.
 */
class FeaturedContentController extends ControllerBase {

  public function __construct(
    protected FeaturedContentManager $featuredContentManager,
  ) {}

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('gd_content.featured_content_manager'),
    );
  }

  /**
   * Displays featured content.
   */
  public function listFeatured(): array {
    $articles = $this->featuredContentManager->getFeaturedContent(10);

    $items = [];
    foreach ($articles as $article) {
      $items[] = [
        '#type' => 'link',
        '#title' => $article->getTitle() . ' - ' . $this->featuredContentManager->getFormattedDate($article),
        '#url' => $article->toUrl(),
      ];
    }

    return [
      '#theme' => 'item_list',
      '#title' => $this->t('Featured Articles'),
      '#items' => $items,
      '#empty' => $this->t('No featured content available.'),
      '#cache' => [
        'tags' => ['node_list'],
        'contexts' => ['user.permissions'],
      ],
    ];
  }

}

Add the route in web/modules/custom/gd_content/gd_content.routing.yml:

gd_content.featured:
  path: '/featured-content'
  defaults:
    _controller: '\Drupal\gd_content\Controller\FeaturedContentController::listFeatured'
    _title: 'Featured Content'
  requirements:
    _permission: 'access content'

How it works: ControllerBase implements ContainerInjectionInterface, which tells Drupal to call create() with the service container. Your create() method retrieves dependencies and passes them to the constructor.

Forms

Forms follow the same create() pattern.

web/modules/custom/gd_content/src/Form/FeaturedContentSettingsForm.php:

<?php

declare(strict_types=1);

namespace Drupal\gd_content\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\gd_content\Service\FeaturedContentManager;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Configuration form for featured content.
 */
class FeaturedContentSettingsForm extends ConfigFormBase {

  public function __construct(
    protected FeaturedContentManager $featuredContentManager,
  ) {}

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container): static {
    $instance = new static(
      $container->get('gd_content.featured_content_manager'),
    );
    $instance->setConfigFactory($container->get('config.factory'));
    return $instance;
  }

  /**
   * {@inheritdoc}
   */
  protected function getEditableConfigNames(): array {
    return ['gd_content.settings'];
  }

  /**
   * {@inheritdoc}
   */
  public function getFormId(): string {
    return 'gd_content_featured_settings';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state): array {
    $config = $this->config('gd_content.settings');

    $form['featured_count'] = [
      '#type' => 'number',
      '#title' => $this->t('Number of featured items'),
      '#default_value' => $config->get('featured_count') ?? 5,
      '#min' => 1,
      '#max' => 20,
      '#required' => TRUE,
    ];

    return parent::buildForm($form, $form_state);
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state): void {
    $this->config('gd_content.settings')
      ->set('featured_count', $form_state->getValue('featured_count'))
      ->save();

    parent::submitForm($form, $form_state);
  }

}

Add to gd_content.routing.yml:

gd_content.settings:
  path: '/admin/config/content/featured'
  defaults:
    _form: '\Drupal\gd_content\Form\FeaturedContentSettingsForm'
    _title: 'Featured Content Settings'
  requirements:
    _permission: 'administer site configuration'

Block Plugins

Plugins implement ContainerFactoryPluginInterface and receive additional plugin parameters.

web/modules/custom/gd_content/src/Plugin/Block/FeaturedContentBlock.php:

<?php

declare(strict_types=1);

namespace Drupal\gd_content\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\gd_content\Service\FeaturedContentManager;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Provides a featured content block.
 *
 * @Block(
 *   id = "gd_content_featured_block",
 *   admin_label = @Translation("Featured Content"),
 *   category = @Translation("Content")
 * )
 */
class FeaturedContentBlock extends BlockBase implements ContainerFactoryPluginInterface {

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    protected FeaturedContentManager $featuredContentManager,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition,
  ): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('gd_content.featured_content_manager'),
    );
  }

  /**
   * {@inheritdoc}
   */
  public function build(): array {
    $articles = $this->featuredContentManager->getFeaturedContent(3);

    if (empty($articles)) {
      return ['#markup' => $this->t('No featured content available.')];
    }

    $items = [];
    foreach ($articles as $article) {
      $items[] = [
        '#type' => 'link',
        '#title' => $article->getTitle(),
        '#url' => $article->toUrl(),
      ];
    }

    return [
      '#theme' => 'item_list',
      '#items' => $items,
      '#cache' => ['tags' => ['node_list'], 'max-age' => 3600],
    ];
  }

}

Important: Plugin constructors must call parent::__construct() with the plugin parameters. Your custom dependencies come after the required plugin parameters.

Hook Implementations

Hooks are procedural functions and cannot use constructor injection. Use the static service locator instead.

web/modules/custom/gd_content/gd_content.module:

<?php

/**
 * @file
 * Hook implementations for GD Content module.
 */

declare(strict_types=1);

use Drupal\node\NodeInterface;

/**
 * Implements hook_ENTITY_TYPE_view() for node entities.
 */
function gd_content_node_view(array &$build, NodeInterface $node, $display, $view_mode): void {
  /** @var \Drupal\gd_content\Service\FeaturedContentManager $featured_manager */
  $featured_manager = \Drupal::service('gd_content.featured_content_manager');

  if ($featured_manager->isFeatured($node)) {
    $build['featured_badge'] = [
      '#markup' => '<span class="featured-badge">' . t('Featured') . '</span>',
      '#weight' => -100,
    ];
  }
}

When is static access acceptable? Only in procedural code like hooks and preprocess functions. Inside classes (services, controllers, forms, plugins), always use constructor injection.

Event Subscribers

Event subscribers are services themselves and support full dependency injection.

web/modules/custom/gd_content/src/EventSubscriber/ContentEventSubscriber.php:

<?php

declare(strict_types=1);

namespace Drupal\gd_content\EventSubscriber;

use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\gd_content\Service\FeaturedContentManager;
use Drupal\node\NodeInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Responds to content-related events.
 */
class ContentEventSubscriber implements EventSubscriberInterface {

  public function __construct(
    protected FeaturedContentManager $featuredContentManager,
    protected RouteMatchInterface $routeMatch,
  ) {}

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    return [
      KernelEvents::REQUEST => ['onRequest', 100],
    ];
  }

  /**
   * Handles the kernel request event.
   */
  public function onRequest(RequestEvent $event): void {
    $node = $this->routeMatch->getParameter('node');

    if ($node instanceof NodeInterface && $this->featuredContentManager->isFeatured($node)) {
      // Track featured content views, update analytics, etc.
    }
  }

}

Register in gd_content.services.yml:

  gd_content.content_event_subscriber:
    class: Drupal\gd_content\EventSubscriber\ContentEventSubscriber
    arguments:
      - '@gd_content.featured_content_manager'
      - '@current_route_match'
    tags:
      - { name: event_subscriber }

Advanced Patterns

Service Tags

Tags identify services for specific purposes. Common tags:

Tag Purpose
event_subscriber Subscribe to events
twig.extension Register Twig extensions
cache.context Register cache contexts
access_check Register route access checkers

Example from the gd_twig module:

services:
  gd_twig.twig_extension:
    class: Drupal\gd_twig\Twig\GdTwigExtension
    arguments: ['@current_route_match']
    tags:
      - { name: twig.extension }

Optional Dependencies

Use nullable types when a dependency might not be available:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Service;

use Drupal\Core\Cache\CacheBackendInterface;

class MyService {

  public function __construct(
    protected ?CacheBackendInterface $cache = NULL,
  ) {}

  public function getData(): array {
    if ($this->cache && $cached = $this->cache->get('my_data')) {
      return $cached->data;
    }

    $data = $this->computeExpensiveData();

    $this->cache?->set('my_data', $data);

    return $data;
  }

}

In services.yml, use @? for optional dependencies:

services:
  my_module.my_service:
    class: Drupal\my_module\Service\MyService
    arguments:
      - '@?cache.default'

Service Decoration

Replace or extend core services:

services:
  my_module.custom_entity_manager:
    class: Drupal\my_module\CustomEntityTypeManager
    decorates: entity_type.manager
    arguments:
      - '@my_module.custom_entity_manager.inner'

Best Practices

Do

  • Use declare(strict_types=1) - Enables strict type checking
  • Type-hint to interfaces - Use EntityTypeManagerInterface, not EntityTypeManager
  • Use constructor property promotion - Cleaner PHP 8+ syntax
  • Document dependencies - PHPDoc comments explain each injected service
  • Follow naming conventions - module_name.service_name for service IDs
  • Add cache metadata - Tags and contexts in render arrays
  • Handle exceptions - Log errors, return sensible defaults
  • Keep services focused - One service, one responsibility

Avoid

  • Static calls in classes - Never use \Drupal::service() where DI is possible
  • Skipping access checks - Always use ->accessCheck(TRUE) in entity queries
  • Too many dependencies - More than 5-6 suggests the service does too much
  • Missing return types - Always declare return types

When to Use Each Pattern

Pattern Use When
Service Reusable logic needed across multiple classes
Controller Handling HTTP requests/responses
Form User input, validation, and submission
Plugin Swappable implementations (blocks, fields, widgets)
Event Subscriber Reacting to system events

Common Services Reference

Service ID Interface Purpose
entity_type.manager EntityTypeManagerInterface Load and query entities
current_user AccountProxyInterface Current user information
current_route_match RouteMatchInterface Current route data
config.factory ConfigFactoryInterface Read configuration
database Connection Direct database queries
messenger MessengerInterface Display user messages
logger.factory LoggerChannelFactoryInterface Create logger channels
cache.default CacheBackendInterface Default cache backend
date.formatter DateFormatterInterface Format dates
module_handler ModuleHandlerInterface Module operations

Debugging

Service Not Found

# Check if service exists
ddev drush eval "var_dump(\Drupal::hasService('my_module.my_service'));"

# List services matching a pattern
ddev drush eval "\$c = \Drupal::getContainer(); foreach (\$c->getServiceIds() as \$id) { if (str_contains(\$id, 'my_module')) echo \"\$id\n\"; }"

Circular Reference Error

This means Service A depends on Service B, which depends on Service A. Solutions:

  1. Restructure to remove the cycle
  2. Use setter injection for one dependency
  3. Add lazy: true to defer instantiation

Arguments Mismatch

Ensure arguments in services.yml match the constructor parameter count and order exactly.


Complete Module Reference

Final gd_content.services.yml:

services:
  gd_content.featured_content_manager:
    class: Drupal\gd_content\Service\FeaturedContentManager
    arguments:
      - '@entity_type.manager'
      - '@current_user'
      - '@date.formatter'
      - '@logger.channel.gd_content'

  logger.channel.gd_content:
    parent: logger.channel_base
    arguments: ['gd_content']

  gd_content.content_event_subscriber:
    class: Drupal\gd_content\EventSubscriber\ContentEventSubscriber
    arguments:
      - '@gd_content.featured_content_manager'
      - '@current_route_match'
    tags:
      - { name: event_subscriber }

Final gd_content.routing.yml:

gd_content.featured:
  path: '/featured-content'
  defaults:
    _controller: '\Drupal\gd_content\Controller\FeaturedContentController::listFeatured'
    _title: 'Featured Content'
  requirements:
    _permission: 'access content'

gd_content.settings:
  path: '/admin/config/content/featured'
  defaults:
    _form: '\Drupal\gd_content\Form\FeaturedContentSettingsForm'
    _title: 'Featured Content Settings'
  requirements:
    _permission: 'administer site configuration'

Next Steps

Continue your learning with these related topics:

  • Unit Testing Services - Write PHPUnit tests with mock dependencies
  • Service Collectors - Aggregate tagged services automatically
  • Compiler Passes - Modify the service container during compilation
  • Lazy Services - Defer instantiation for performance

Further Reading:

Tags