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:
- Testability - Swap real services for mocks in tests
- Clarity - Dependencies are explicit in the constructor signature
- Flexibility - Implementations can be changed without modifying your class
- 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 $entityTypeManagerin 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_nameby 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:
ControllerBaseimplementsContainerInjectionInterface, which tells Drupal to callcreate()with the service container. Yourcreate()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, notEntityTypeManager - Use constructor property promotion - Cleaner PHP 8+ syntax
- Document dependencies - PHPDoc comments explain each injected service
- Follow naming conventions -
module_name.service_namefor 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:
- Restructure to remove the cycle
- Use setter injection for one dependency
- Add
lazy: trueto 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: