Drupal: Services and Dependency Injection
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Almost everything a Drupal module does is done by a service: an object that the framework builds once, gives a stable
name and hands to whoever asks for it. The database connection, the entity type manager, the current user, the logger,
the cache backends, the mailer and the event dispatcher are all services, and so are the classes a custom module adds
for its own business logic. This page explains how the container that manages them is built from YAML files, how a class
declares what it needs (explicit arguments or autowiring), how controllers, forms, plugins and hook classes receive their
dependencies, how an existing service is decorated or replaced, how services are grouped with tags, and why calls such as
\Drupal::service() are reserved for procedural code.
It was written against Drupal 11.4.x (11.4.0 was released on 1 July 2026), with Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation and change records on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as upcoming events. New sites start on Drupal 11.4; the Drupal 10 behaviour is mentioned only where a feature arrived in a Drupal 10 minor.
The running example is the Meridian Museum, a fictitious regional science museum. Its custom module
meridian_collection (introduced in Module anatomy and info.yml) gains
a small set of services in this page: an object repository, a client for the museum’s collections-management API with a
caching decorator, a pluggable set of accession-number validators, and the controller, form, block and hook classes that
consume them. The same services are reused by the pages on
routing and controllers, the Form API,
events and subscribers and
queues and cron.
Why a container exists
A class that creates its own collaborators (new GuzzleHttp\Client() inside a method) is hard to test, hard to configure
per environment and impossible to replace without editing it. Dependency injection reverses the arrangement: a class
states what it needs in its constructor and something else supplies it. In Drupal that something is the service
container, a Symfony DependencyInjection component that Drupal compiles from the *.services.yml files of core and of
every installed module.
The vocabulary used throughout:
- Service
-
Any object managed by the container, reached by a single string key (the service ID) such as
entity_type.managerormeridian_collection.object_repository. The official documentation defines a service as any object managed by the services container. - Service definition
-
The YAML entry that tells the container which class to instantiate, with which arguments, and with which tags and options.
- Container
-
The compiled set of all definitions. It is built once, cached, and rebuilt when caches are cleared, a module is installed or uninstalled, or a services file changes. Compilation also optimises the result (for example by inlining private services) and runs compiler passes that process tags.
- Parameter
-
A scalar or array value stored in the container (for example
session.storage.optionsor a module’s own constant), referenced from arguments with%name%. - Shared service
-
By default the container creates one instance and reuses it for every request for that ID (a singleton per request). Setting
shared: falsemakes a new instance each time.
Because the container is compiled and cached, a change to a *.services.yml file is not seen until the container is
rebuilt. During development, run drush cache:rebuild (drush cr) after adding or editing a service definition; on
a deployed site this happens as part of the drush deploy sequence described in
Drush.
The MODULE.services.yml file
A module registers services in a file named after its machine name, meridian_collection.services.yml, at the module
root next to meridian_collection.info.yml. The file has a top-level services: key and an optional parameters: key.
The convention is to prefix every service ID with the module name, so that IDs from different modules never collide.
# meridian_collection.services.yml
parameters:
meridian_collection.api_timeout: 5.0
services:
# A service with explicit arguments.
meridian_collection.object_repository:
class: Drupal\meridian_collection\ObjectRepository
arguments: ['@entity_type.manager', '@database']
# A service that needs a parameter and a logger channel.
meridian_collection.api_client:
class: Drupal\meridian_collection\CollectionApiClient
arguments:
- '@http_client'
- '@config.factory'
- '@logger.channel.meridian_collection'
- '%meridian_collection.api_timeout%'
# A logger channel is a tiny service of its own.
logger.channel.meridian_collection:
parent: logger.channel_base
arguments: ['meridian_collection']
Source: Structure of a service file
The corresponding class receives the arguments in order. A constructor with promoted, readonly properties is the modern
style and keeps the class free of boilerplate.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Loads collection objects for the Meridian Museum.
*/
final class ObjectRepository {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
private readonly Connection $database,
) {}
/**
* Returns the IDs of objects currently on display, newest first.
*
* @return int[]
*/
public function onDisplayIds(int $limit = 10): array {
$ids = $this->entityTypeManager->getStorage('collection_object')
->getQuery()
->accessCheck(TRUE)
->condition('on_display', 1)
->sort('changed', 'DESC')
->range(0, $limit)
->execute();
return array_map('intval', array_values($ids));
}
}
The entity query ends in an explicit accessCheck() call, as every entity query must (see
Database API and entity queries).
Keys of a service definition
The keys that matter in day-to-day work are listed below. Anything not documented by Drupal is documented by Symfony, and Drupal’s YAML loader is a close relative of Symfony’s, so most Symfony service keys behave the same way.
| Key | Meaning |
|---|---|
|
The fully qualified class to instantiate. May be omitted when the service ID is itself a class name (see aliases below). |
|
Values passed to the constructor, or to the factory method. Each is a service reference, a parameter reference or a literal. |
|
Setter injection: a list of method names with arguments, called right after instantiation. |
|
A |
|
A callable that receives the new object and configures it. |
|
|
|
Wrap an existing service (see the section on decoration). |
|
A list of tags, each with a |
|
Gives a second name to an existing service (used for autowiring, see below). |
|
When |
|
|
|
When |
|
The container does not build the service; code injects it from outside (used by the kernel for itself). |
|
Whether the service can be fetched from the container by ID. The official guide advises making services public only with a clear reason, because private services can be inlined and optimised away. |
|
A file included just before the service is created. |
|
Property injection. Prefer the constructor. |
Argument syntax
| Syntax | Meaning |
|---|---|
|
Inject another service. |
|
Inject the service if it exists, otherwise |
|
Inject a container parameter. |
|
Inside a decorator, the service being decorated. |
|
A lazy iterable of every service carrying the tag (10.3+). |
|
A |
A service whose ID contains no module prefix, or a prefix that is not the module’s machine name, is a common mistake: two
modules may then define the same ID and the later one silently wins. Prefix with meridian_collection. and keep IDs lowercase
with dots.
Autowiring and aliases
Writing every argument by hand is repetitive: the class already declares its needs through type hints. Autowiring lets the container read the constructor and find a service for each typed argument. Three change records describe how it reached Drupal:
-
Drupal 10.1.0 added aliases for core interfaces in
core.services.yml, so that, for example,Drupal\Core\Extension\ModuleHandlerInterfaceis an alias ofmodule_handler. A module service can then setautowire: trueand leave out its arguments. The change record states that standalone core classes and core interfaces with a single implementation can be autowired, while interfaces with several implementations cannot be resolved from the type alone. -
A
_defaultskey (support arrived in the same period) applies options such asautowire: trueto every service in a file. Drupal 10.2.0 used it to turn autowiring on acrosscore.services.ymlitself and to remove many explicit arguments from core definitions. -
Drupal 10.2.0 also introduced
AutowireTraitfor classes that implementContainerInjectionInterface(covered below).
# meridian_collection.services.yml
services:
_defaults:
autowire: true
# No arguments needed: ModuleHandlerInterface, TranslationInterface and
# EntityTypeManagerInterface all have core aliases.
meridian_collection.object_repository:
class: Drupal\meridian_collection\ObjectRepository
# Alias an interface of this module to its only implementation, so that other
# classes can type-hint the interface and be autowired.
Drupal\meridian_collection\CollectionApiClientInterface:
alias: meridian_collection.api_client
Aliases
An alias is a second service ID that resolves to an existing definition. Autowiring looks up a service whose ID equals the type-hinted class or interface name, so an alias named after the interface is how the container learns which service satisfies that type. Core ships many of them. Your module adds its own for its own interfaces, and for core interfaces that core has not aliased:
services:
Drupal\Core\Entity\EntityTypeManagerInterface:
alias: entity_type.manager
The official guide adds a caution: autowiring is a Symfony feature that core does not test as a complete workflow for
contributed code, so it works reliably only when constructor type hints resolve and interfaces have explicit aliases. Keep
it limited to your own module, keep service IDs stable, and exercise container rebuilds in CI. A typical failure message
says that an argument references an interface for which no matching service exists; the cure is an alias or an
#[Autowire] attribute.
The Autowire attribute
When the type alone is ambiguous (several services implement CacheBackendInterface, several implement
LoggerInterface) or when the dependency is a parameter rather than a service, the constructor argument carries Symfony’s
#[Autowire] attribute, imported from Symfony\Component\DependencyInjection\Attribute\Autowire:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Computes labels and counts for the "on display" listings.
*/
final class OnDisplayCounter {
public function __construct(
private readonly ObjectRepository $repository,
private readonly ConfigFactoryInterface $configFactory,
// Several services implement CacheBackendInterface: name the one we want.
#[Autowire(service: 'cache.default')]
private readonly CacheBackendInterface $cache,
// Several services implement LoggerInterface: use our channel.
#[Autowire(service: 'logger.channel.meridian_collection')]
private readonly LoggerInterface $logger,
// A container parameter instead of a service.
#[Autowire(param: 'meridian_collection.api_timeout')]
private readonly float $timeout,
) {}
public function count(): int {
$cid = 'meridian_collection:on_display_count';
if ($cached = $this->cache->get($cid)) {
return (int) $cached->data;
}
$count = count($this->repository->onDisplayIds(1000));
$this->cache->set($cid, $count, tags: ['collection_object_list']);
$this->logger->debug('Recomputed on-display count (timeout @t s).', ['@t' => $this->timeout]);
return $count;
}
}
#[Autowire] accepts service: (a service ID), param: (a container parameter) and, as in Symfony, other forms such as
expression: and env:. Read configuration or secrets through the config factory or settings.php instead of the env:
form for anything the site owner is expected to manage (see
settings.php and environments).
Related Symfony attributes work in autowired Drupal services: #[AutowireServiceClosure] (the closure counterpart of
!service_closure, available before 10.3 only through autowiring) and #[AutowireIterator] (an iterable of tagged
services, available since Drupal 11.0 on Symfony 7). Both require the service to be autowired.
Autowiring and the classes you do not list in YAML
Controllers, forms, blocks and other plugins are not in *.services.yml; the framework instantiates them itself. They
reach the container through a create() factory method, described next. Since Drupal 10.2 AutowireTrait supplies that
method, and since 11.4 even FormBase does so by default.
Injection in classes the framework instantiates
The create() factory and ContainerInjectionInterface
Controllers, forms, access checkers declared by class and similar objects implement
Drupal\Core\DependencyInjection\ContainerInjectionInterface. Its single static method receives the container and returns a
new instance:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\meridian_collection\ObjectRepository;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Lists objects on display (manual wiring, the pre-10.2 style).
*/
final class OnDisplayController extends ControllerBase {
public function __construct(private readonly ObjectRepository $repository) {}
public static function create(ContainerInterface $container): static {
// The order of get() calls must match the constructor parameter order.
return new static($container->get('meridian_collection.object_repository'));
}
public function list(): array {
$storage = $this->entityTypeManager()->getStorage('collection_object');
$objects = $storage->loadMultiple($this->repository->onDisplayIds());
return [
'#theme' => 'item_list',
'#items' => array_map(static fn ($object) => $object->label(), $objects),
'#cache' => ['tags' => ['collection_object_list']],
];
}
}
Source: Dependency injection in a form
The official guide stresses that the order of $container->get() calls must match the constructor parameter order. The
result is the same pattern for forms (FormBase, ConfigFormBase and ConfirmFormBase all implement the interface). Note
that ControllerBase already offers lazy shortcuts such as $this->entityTypeManager() and $this->currentUser(), but
those shortcuts fetch from the global container internally. A controller that is unit tested, or that is long-lived, is
better served by injecting what it uses.
AutowireTrait: dropping create()
Drupal 10.2 added Drupal\Core\DependencyInjection\AutowireTrait, which provides a generic create() that reads the
constructor with reflection and resolves each argument through autowiring. ControllerBase uses it from 10.2, so a
controller extending it can simply declare its constructor:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\meridian_collection\CollectionApiClientInterface;
use Drupal\meridian_collection\ObjectRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Shows the sync status of the collections-management API.
*/
final class SyncStatusController extends ControllerBase {
public function __construct(
private readonly ObjectRepository $repository,
private readonly CollectionApiClientInterface $client,
#[Autowire(service: 'datetime.time')]
private readonly \Drupal\Component\Datetime\TimeInterface $time,
) {}
public function status(): array {
return [
'#markup' => $this->t('@count objects on display; last check at @time.', [
'@count' => count($this->repository->onDisplayIds(1000)),
'@time' => date('c', $this->time->getRequestTime()),
]),
'#cache' => ['max-age' => 0],
];
}
}
The change record states the limit of the approach: in simple cases the service is inferred from the interface name alone,
and where it cannot be determined an exception is raised. The cure is #[Autowire] with a service ID, or an alias for the
interface. The API reference warns that the trait uses reflection, which may cause performance issues for classes that are
instantiated many times; controllers and forms are instantiated once per request, so this is rarely a concern.
The same idea reached other base classes in later minors:
| Base class | Since | Notes |
|---|---|---|
Classes implementing |
10.2.0 |
Add |
|
11.3.0 |
The generic |
|
11.4.0 |
Forms that extend it need no |
Setter injection with |
11.4 |
A typed property plus a |
Forms
A settings form for the collections API shows both the constructor and the serialization rule. Forms can be serialized
between requests (AJAX rebuilds, multi-step forms), and FormBase includes DependencySerializationTrait, which replaces
injected services by their IDs during serialization and restores them afterwards. That mechanism works for properties of
any visibility that were set through the constructor in the usual way, but the official guide advises keeping injected
properties protected or public in forms, because private properties declared in a parent class can be lost and cause
null errors on later requests of an AJAX form.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\meridian_collection\CollectionApiClientInterface;
/**
* Settings form for the collections-management API.
*/
final class ApiSettingsForm extends ConfigFormBase {
public function __construct(
ConfigFactoryInterface $config_factory,
TypedConfigManagerInterface $typedConfigManager,
protected readonly CollectionApiClientInterface $client,
) {
parent::__construct($config_factory, $typedConfigManager);
}
public static function create(\Symfony\Component\DependencyInjection\ContainerInterface $container): static {
return new static(
$container->get('config.factory'),
$container->get('config.typed'),
$container->get('meridian_collection.api_client'),
);
}
public function getFormId(): string {
return 'meridian_collection_api_settings';
}
protected function getEditableConfigNames(): array {
return ['meridian_collection.settings'];
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$config = $this->config('meridian_collection.settings');
$form['base_url'] = [
'#type' => 'url',
'#title' => $this->t('Collections API base URL'),
'#default_value' => $config->get('base_url'),
'#required' => TRUE,
];
return parent::buildForm($form, $form_state);
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$this->config('meridian_collection.settings')
->set('base_url', $form_state->getValue('base_url'))
->save();
parent::submitForm($form, $form_state);
}
}
Source: Dependency injection in a form
The API token for the collections service is not part of this configuration: it is read from an environment variable by
settings.php or from the Key module, so it is never exported with the configuration (see
Configuration management).
Plugins
Plugins are created by plugin managers, which pass the plugin configuration, ID and definition to the constructor. A plugin
that needs services implements Drupal\Core\Plugin\ContainerFactoryPluginInterface; its create() receives the container
plus those three plugin arguments. The "On display now" block of the Meridian site, written with a PHP attribute (see
Plugin API and attributes), shows the 11.3+ style that omits create():
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\OnDisplayCounter;
/**
* Shows how many collection objects are on display.
*/
#[Block(
id: 'meridian_on_display_now',
admin_label: new TranslatableMarkup('On display now'),
category: new TranslatableMarkup('Meridian Museum'),
)]
final class OnDisplayNowBlock extends BlockBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
private readonly OnDisplayCounter $counter,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public function build(): array {
return [
'#markup' => $this->t('@count objects on display now', ['@count' => $this->counter->count()]),
'#cache' => ['tags' => ['collection_object_list']],
];
}
}
On Drupal 10.2 to 11.2, add the explicit factory:
public static function create(
\Symfony\Component\DependencyInjection\ContainerInterface $container,
array $configuration,
$plugin_id,
$plugin_definition,
): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('meridian_collection.on_display_counter'),
);
}
Source: Dependency injection in a form
The OnDisplayCounter class from the earlier example must be registered, and since OnDisplayCounter has no interface the
autowiring needs the class name as the service ID (or an alias):
services:
Drupal\meridian_collection\OnDisplayCounter:
class: Drupal\meridian_collection\OnDisplayCounter
autowire: true
meridian_collection.on_display_counter:
alias: Drupal\meridian_collection\OnDisplayCounter
A plugin class that forgets implements ContainerFactoryPluginInterface is not an error at compile time: the plugin
factory simply calls the plain constructor with three arguments and the extra argument is missing, which produces an
ArgumentCountError the first time the block is placed.
Hook classes
Since Drupal 11.1, hooks are methods on classes in the module’s src/Hook namespace marked with #[Hook('hook_name')].
Such a class is registered automatically as an autowired service, so it needs no entry in the services file and receives
its dependencies through the constructor with no create() at all (see
Hooks and OOP hooks):
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Entity hooks of the collection module.
*/
final class CollectionHooks {
#[Hook('entity_presave')]
public function entityPresave(EntityInterface $entity): void {
if ($entity->getEntityTypeId() === 'collection_object') {
Cache::invalidateTags(['collection_object_list']);
}
}
}
The remaining procedural hooks (hook_install, hook_uninstall, hook_schema, hook_requirements, hook_update_N and
hook_post_update_NAME) must stay in .install or .module files. Those files are one of the two places where
\Drupal::service() is legitimate, as the next section explains.
Procedural code and the global container
The \Drupal class is a static facade over the container. It exists for code that has no other way to get a service:
procedural functions in .module, .install and .theme files, update and post-update functions, and tests that
need a service for set-up. The official guide describes it as intended for global functions and advises minimising the code
that lives in them.
<?php
/**
* @file
* Install and update hooks for the Meridian collection module.
*/
declare(strict_types=1);
/**
* Implements hook_install().
*/
function meridian_collection_install(): void {
// A procedural hook has no constructor, so the global container is the only
// way to reach a service. Keep the work here small and delegate to a service.
\Drupal::service('meridian_collection.object_repository')->onDisplayIds(1);
}
Inside a class, the same call is harmful for four reasons:
-
it hides the dependency: the constructor no longer says what the class needs, so readers, static analysis and reviewers cannot see it;
-
it makes the class hard to unit test, because the global container must be built first (a Unit test cannot);
-
it ties the class to service IDs spelled as strings, which are not checked by the type system;
-
it couples the class to the single global container, which breaks when a service is swapped or decorated by a test.
\Drupal::service() can also be abused to fetch a service during container compilation or inside another service’s
constructor, which creates circular dependencies and surprising ordering. Quick static helpers such as \Drupal::time() or
\Drupal::currentUser() are subject to the same rule. Static-analysis rules in the Drupal coding standards and PHPStan’s
Drupal extension report these calls inside classes (see
Coding standards and static analysis).
Older books and tutorials, including Drupal 10 Development Cookbook (2023), still call \Drupal:: helpers from inside
controllers and services, hand-write every create() method and list every dependency in services.yml. That code still
runs, but since 10.1 services can be autowired, since 10.2 AutowireTrait removes create() from most controllers, since
11.3 PluginBase does the same for plugins, and since 11.4 so does FormBase. In theme code, Modernizing Drupal 10
Theme Development (2023) uses \Drupal:: in .theme files; themes can implement OOP hooks from 11.3 and preprocess hooks
from 11.2, so the same advice applies there. The page
Where older books and tutorials are outdated collects
these differences.
Decorating a service
Replacing a core service with a class of your own is brittle: if two modules replace the same service, the winner depends on build order, and a minor release can change what the original did. A decorator instead wraps the original, delegating most calls to it and adding behaviour around some of them. The original remains reachable, and several decorators can be stacked.
The Meridian module wraps its collections API client with a cache. The client implements an interface; the decorator implements the same interface and receives the decorated service as an argument.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Cache\CacheBackendInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Caches reads of the collections-management API.
*/
final class CachedCollectionApiClient implements CollectionApiClientInterface {
public function __construct(
private readonly CollectionApiClientInterface $inner,
#[Autowire(service: 'cache.default')]
private readonly CacheBackendInterface $cache,
) {}
public function fetchObject(string $accessionNumber): ?array {
$cid = 'meridian_collection:api:' . $accessionNumber;
if ($hit = $this->cache->get($cid)) {
return $hit->data;
}
$data = $this->inner->fetchObject($accessionNumber);
$this->cache->set($cid, $data, time() + 3600, ['collection_object_list']);
return $data;
}
}
services:
meridian_collection.api_client.cached:
class: Drupal\meridian_collection\CachedCollectionApiClient
decorates: meridian_collection.api_client
decoration_priority: 10
arguments:
- '@.inner'
- '@cache.default'
Three details matter:
-
decoratesnames the service without a leading@; Drupal’s loader rejects an@there. -
Inside the decorator the original is injected as
'@.inner'. Thedecoration_inner_namekey remains available when a decorator wraps several services or must name the inner service explicitly. -
decoration_prioritydefaults to0. When several modules decorate the same service the priority orders the wrappers, with higher values applied earlier (that is, closer to the original).
Every service that asks for meridian_collection.api_client now receives the decorator, including classes that
type-hinted the interface alias. A decorator that decorates a service of an optional module must handle the case where the
module is not installed; the container otherwise fails to compile.
Altering and replacing existing services
When decoration is not enough, a module can modify the container itself with a service provider class. Drupal discovers
one per module, and the class must be named from the module machine name in PascalCase followed by ServiceProvider, in the
module’s top namespace, and must implement Drupal\Core\DependencyInjection\ServiceModifierInterface. Extending
ServiceProviderBase supplies empty register() and alter() methods.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
/**
* Container customisations for the Meridian collection module.
*/
final class MeridianCollectionServiceProvider extends ServiceProviderBase {
public function alter(ContainerBuilder $container): void {
// Always check first: getDefinition() throws if the service is missing.
if ($container->hasDefinition('meridian_search.indexer')) {
$container->getDefinition('meridian_search.indexer')
->addMethodCall('setBatchSize', [50]);
}
}
}
The two methods differ:
-
alter()modifies definitions that already exist (change a class, add a method call, add an argument). -
register()adds definitions or compiler passes in code. The documentation says to do this rarely, because definitions generated in code are harder to audit.
Both run while the container is built, so their effect is stored in the cached container, not evaluated per request, and a
service provider cannot use other services. A provider that replaces a core service by changing its class is the least
compatible option: a core minor release can change the constructor and break the module. The documentation orders the
preferences as events, hooks and plugins first, then decoration, then replacement.
A site (not a module) can override service parameters and definitions in sites/default/services.yml, loaded through the
container_yamls setting. The development configuration shipped as development.services.yml uses this to turn Twig debug on, and
is documented on settings.php and environments. Do not enable it on
production.
Tagged services and collectors
A tag marks a service as belonging to a category or as playing a role. Tags are consumed when the container is compiled, so adding or removing a tag requires a container rebuild.
Many core features are built from tags: event_subscriber registers event subscribers (see
Events and subscribers), access_check registers route access checkers,
cache.bin marks a cache bin service, normalizer and encoder register serialization services, twig.extension adds Twig
extensions, and string_translator
registers translation sources. The authoritative list lives in the "Service Tags" topic of the API reference.
Source: Service Tags (API reference)
Collecting tagged services
The Meridian module lets other modules contribute validators for accession numbers. The extension point is a collector:
a service tagged service_collector that Drupal instantiates and then calls once per collected service.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
/**
* One rule for accession numbers.
*/
interface AccessionValidatorInterface {
public function validate(string $accession): bool;
}
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
/**
* Runs every registered validator, highest priority first.
*/
final class AccessionValidatorChain {
/** @var \Drupal\meridian_collection\AccessionValidatorInterface[] */
private array $validators = [];
/**
* Called by the container once for each tagged validator.
*/
public function addValidator(AccessionValidatorInterface $validator, int $priority): void {
$this->validators[$priority][] = $validator;
}
public function isValid(string $accession): bool {
krsort($this->validators);
foreach ($this->validators as $group) {
foreach ($group as $validator) {
if (!$validator->validate($accession)) {
return FALSE;
}
}
}
return TRUE;
}
}
services:
meridian_collection.accession_validator_chain:
class: Drupal\meridian_collection\AccessionValidatorChain
tags:
- { name: service_collector, tag: meridian_collection.accession_validator, call: addValidator }
meridian_collection.validator.format:
class: Drupal\meridian_collection\Validator\FormatValidator
tags:
- { name: meridian_collection.accession_validator, priority: 100 }
meridian_collection.validator.unique:
class: Drupal\meridian_collection\Validator\UniqueValidator
arguments: ['@meridian_collection.object_repository']
tags:
- { name: meridian_collection.accession_validator, priority: 50 }
Source: Service tags
The rules of a collector are:
-
tagnames the tag that collected services carry. If omitted, it defaults to the collector’s own service ID. -
callnames the method invoked for each collected service. -
If the method has parameters named
$idor$priority, Drupal fills them from the collected service’s definition; the priority is read from the tag, defaults to0, and a higher priority is meant to be used before a lower one. -
The
string_translationservice is the canonical core example: it collects services taggedstring_translatorand receives each throughaddTranslator().
A collector instantiates every collected service, even if the request uses none of them. When there are many, or when they are expensive, use a lazy iterable instead.
Tagged iterators
Since Drupal 10.3, services files support Symfony’s !tagged_iterator, which injects a Traversable that yields each
tagged service on demand. Services are created only when the iterator reaches them, which makes the approach lighter than
a collector. The change record for it uses the module installer and the module_install.uninstall_validator tag.
services:
meridian_collection.accession_validator_registry:
class: Drupal\meridian_collection\AccessionValidatorRegistry
arguments: [!tagged_iterator meridian_collection.accession_validator]
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
/**
* Validates accession numbers with every tagged validator.
*/
final class AccessionValidatorRegistry {
/**
* @param iterable<AccessionValidatorInterface> $validators
*/
public function __construct(private readonly iterable $validators) {}
public function isValid(string $accession): bool {
foreach ($this->validators as $validator) {
if (!$validator->validate($accession)) {
return FALSE;
}
}
return TRUE;
}
}
With autowiring turned on, the same result is written in the constructor with Symfony’s attribute, with
#[AutowireIterator('meridian_collection.accession_validator')] on the argument. The attribute works only in autowired
services. The iterator follows tag priority order, but it is a stream: iterate it once or convert it with
iterator_to_array().
For extension points where the implementations need metadata (a label, an access rule, a UI), a plugin type is better than a tag; the manager discovers plugins by attribute and creates them on demand (see Plugin API and attributes).
Lazy services and closures
Some services are expensive to build but needed only in rare branches (a mailer, an API client that opens a connection). Two tools avoid the cost:
-
lazy: trueon the definition makes the container inject a generated proxy class that creates the real service on the first method call. The proxy class has to be generated with thecore/scripts/generate-proxy.shscript (a container rebuild without it reports the command to run). The official API topic "Lazy Services" describes the mechanism. Core uses it for some services, with the proxy classes kept under the module’ssrc/ProxyClassdirectory. -
!service_closure '@service.id'(10.3+) or#[AutowireServiceClosure('service.id')]injects a\Closurethat returns the service when invoked. It needs no generated code, and the consumer controls when the service is built.
services:
meridian_collection.sync_runner:
class: Drupal\meridian_collection\SyncRunner
arguments:
- !service_closure '@meridian_collection.api_client'
Source: Lazy Services (API reference)
Services in tests
Because a class declares its dependencies, a Unit test can pass in test doubles directly:
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Unit;
use Drupal\meridian_collection\AccessionValidatorInterface;
use Drupal\meridian_collection\AccessionValidatorRegistry;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
#[CoversClass(AccessionValidatorRegistry::class)]
#[Group('meridian_collection')]
final class AccessionValidatorRegistryTest extends TestCase {
public function testRejectsWhenAnyValidatorFails(): void {
$pass = $this->createStub(AccessionValidatorInterface::class);
$pass->method('validate')->willReturn(TRUE);
$fail = $this->createStub(AccessionValidatorInterface::class);
$fail->method('validate')->willReturn(FALSE);
$registry = new AccessionValidatorRegistry([$pass, $fail]);
$this->assertFalse($registry->isValid('MM-2026-001'));
}
}
Source: Automated testing
In a Kernel test the real container is built from the modules the test enables. A test (or a test module) replaces a service
by overriding its definition in register():
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\KernelTests\KernelTestBase;
use Drupal\meridian_collection\CollectionApiClientInterface;
use PHPUnit\Framework\Attributes\Group;
#[Group('meridian_collection')]
final class ApiClientTest extends KernelTestBase {
protected static $modules = ['meridian_collection', 'system', 'user'];
public function register(ContainerBuilder $container): void {
parent::register($container);
// Replace the real HTTP client so the test never reaches the network.
$container->register('http_client', \GuzzleHttp\Client::class)
->addArgument(['handler' => \GuzzleHttp\HandlerStack::create(new \GuzzleHttp\Handler\MockHandler([]))]);
}
public function testClientIsDecorated(): void {
// \Drupal::service() is acceptable inside a test.
$client = \Drupal::service('meridian_collection.api_client');
$this->assertInstanceOf(CollectionApiClientInterface::class, $client);
}
}
Source: Automated testing
The test types and their conventions are covered by Automated testing.
Diagnosing container problems
| Symptom | Usual cause and cure |
|---|---|
|
Wrong service ID, a typo in the module prefix, a service of a module that is not installed, or a stale container. Check the
ID with |
|
No alias maps the interface to a service. Add an alias in the module’s services file, or add |
|
Two services inject each other. Break the loop with a service closure, a lazy service, or by moving shared logic to a third service. |
|
The plugin class has extra constructor arguments but does not implement |
Properties are |
Injected properties were |
A new tag is ignored |
Tags are processed at compile time. Rebuild the container. |
A decorator is never called |
The decorator’s |
Security and quality considerations
-
A service is a privilege boundary. The code inside a service often runs with the permissions of whoever called it, so services that perform privileged work (deleting content, reading private files) should check access themselves or document that the caller must; see Access control and permissions in code.
-
Never put credentials in a service definition or in a parameter of a services file. Parameters are compiled into the container cache, which is stored in the database or on disk. Read secrets from the environment through
settings.php, or from the Key module, at the moment they are used. -
Do not make a service public only to fetch it by ID. Public services cannot be inlined and are part of the surface other code can depend on.
-
Overriding core services through
sites/default/services.ymlor a service provider changes behaviour for the whole site; review such overrides as carefully as a core patch, and prefer decoration so the original behaviour remains available. -
Be careful with services that hold state (a per-request cache in a property, the current user). Shared services live for the length of a request, but long-running processes (a queue worker run by
drush queue:run, a Drush batch) reuse them for many items, so reset or avoid state that should be per item; see Cron, queues and maintenance.
Version summary
| Version | Change |
|---|---|
10.1.0 |
Core services provide aliases for autowiring; |
10.2.0 |
|
10.3.0 |
|
11.0.0 |
Symfony 7 provides |
11.1.0 |
Hook classes in |
11.3.0 |
|
11.4.0 |
|
Drupal 10 ends on 2026-12-09, so the Drupal 10.2 and 11.2 patterns above that require a manual create() matter only for
modules that must still support those releases; #[LegacyHook] and a procedural shim keep hook classes compatible with
10.x. New code written for Drupal 11.4 should use constructor injection with autowiring and fall back to explicit
service IDs only where the type is ambiguous.
References
-
Change record: Core services provide aliases for autowiring (10.1)
-
Change record: Autowiring is enabled in core.services.yml (10.2)
-
Change record: AutowireTrait allows ContainerInjectionInterface classes to be autowired (10.2)
-
Change record: Services can now use tagged iterators in services.yml files (10.3)
-
Change record: Services can now define service closures as arguments in services.yml files (10.3)
-
Change record: Support for object oriented hook implementations (11.1)
-
Change record: PluginBase provides create() factory method with autowired parameters (11.3)
-
Change record: FormBase provides create() factory method with autowired parameters (11.4)