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.manager or meridian_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.options or 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: false makes 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.

flowchart LR A[core.services.yml] --> C[Container builder] B[MODULE.services.yml<br/>of every installed module] --> C S[MODULEServiceProvider<br/>register and alter] --> C Y[sites/default/services.yml<br/>container_yamls] --> C C --> P[Compiler passes<br/>aliases, decoration, tags, autowiring] P --> K[(Compiled container<br/>cached in the database or on disk)] K --> R[Request: get service by ID] R --> O[Shared instance created on first use]

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']

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

class

The fully qualified class to instantiate. May be omitted when the service ID is itself a class name (see aliases below).

arguments

Values passed to the constructor, or to the factory method. Each is a service reference, a parameter reference or a literal.

calls

Setter injection: a list of method names with arguments, called right after instantiation.

factory

A [class_or_service, method] pair that creates the object instead of calling the constructor (for example cache_factory creating a cache bin).

configurator

A callable that receives the new object and configures it.

parent and abstract

parent makes a definition inherit another’s settings (the logger channel above); abstract: true marks a definition that exists only to be inherited and is never built.

decorates, decoration_priority, decoration_inner_name

Wrap an existing service (see the section on decoration).

tags

A list of tags, each with a name and optional attributes, consumed by compiler passes and collectors.

alias

Gives a second name to an existing service (used for autowiring, see below).

autowire

When true, the container infers the arguments from the constructor type hints.

shared

true by default (one instance); false creates a new instance per request for the service.

lazy

When true, other services receive a proxy that creates the real service on first method call.

synthetic

The container does not build the service; code injects it from outside (used by the kernel for itself).

public

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.

file

A file included just before the service is created.

properties

Property injection. Prefer the constructor.

Argument syntax

Syntax Meaning

'@service.id'

Inject another service.

'@?service.id'

Inject the service if it exists, otherwise null. Used for optional integrations, such as a module that works with or without a contributed project.

'%parameter.name%'

Inject a container parameter.

'@.inner'

Inside a decorator, the service being decorated.

!tagged_iterator tag.name

A lazy iterable of every service carrying the tag (10.3+).

!service_closure '@service.id'

A \Closure that creates the service when called (10.3+).

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\ModuleHandlerInterface is an alias of module_handler. A module service can then set autowire: true and 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 _defaults key (support arrived in the same period) applies options such as autowire: true to every service in a file. Drupal 10.2.0 used it to turn autowiring on across core.services.yml itself and to remove many explicit arguments from core definitions.

  • Drupal 10.2.0 also introduced AutowireTrait for classes that implement ContainerInjectionInterface (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']],
    ];
  }

}

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 ContainerInjectionInterface (using AutowireTrait)

10.2.0

Add use AutowireTrait; and remove create().

PluginBase (plugins implementing ContainerFactoryPluginInterface)

11.3.0

The generic create() autowires the extra constructor arguments after $configuration, $plugin_id and $plugin_definition.

FormBase

11.4.0

Forms that extend it need no create() for simple cases; existing create() methods keep working.

Setter injection with #[Required] on PluginBase and ControllerBase subclasses

11.4

A typed property plus a #[Required] setter avoids touching the parent constructor.

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);
  }

}

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'),
    );
  }

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:

  • decorates names the service without a leading @; Drupal’s loader rejects an @ there.

  • Inside the decorator the original is injected as '@.inner'. The decoration_inner_name key remains available when a decorator wraps several services or must name the inner service explicitly.

  • decoration_priority defaults to 0. 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.

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:

  • tag names the tag that collected services carry. If omitted, it defaults to the collector’s own service ID.

  • call names the method invoked for each collected service.

  • If the method has parameters named $id or $priority, Drupal fills them from the collected service’s definition; the priority is read from the tag, defaults to 0, and a higher priority is meant to be used before a lower one.

  • The string_translation service is the canonical core example: it collects services tagged string_translator and receives each through addTranslator().

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: true on 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 the core/scripts/generate-proxy.sh script (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’s src/ProxyClass directory.

  • !service_closure '@service.id' (10.3+) or #[AutowireServiceClosure('service.id')] injects a \Closure that 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'

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'));
  }

}

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);
  }

}

The test types and their conventions are covered by Automated testing.

Diagnosing container problems

Symptom Usual cause and cure

ServiceNotFoundException: You have requested a non-existent service

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 drush devel:services (Devel module) or by searching the *.services.yml files, then run drush cache:rebuild.

Cannot autowire service ... argument references interface ... but no such service exists

No alias maps the interface to a service. Add an alias in the module’s services file, or add #[Autowire(service: '...')].

Circular reference detected

Two services inject each other. Break the loop with a service closure, a lazy service, or by moving shared logic to a third service.

ArgumentCountError when a plugin or block is created

The plugin class has extra constructor arguments but does not implement ContainerFactoryPluginInterface, or its create() returns the wrong number of arguments.

Properties are null after an AJAX rebuild of a form

Injected properties were private, or the form stored a service obtained through a global call. Use protected properties and constructor injection; FormBase serializes services by ID through DependencySerializationTrait.

A new tag is ignored

Tags are processed at compile time. Rebuild the container.

A decorator is never called

The decorator’s decorates ID is wrong, the decorated service is private and was inlined, or another module’s decorator with a higher priority does not delegate.

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.yml or 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; autowire: true and _defaults available in module services files.

10.2.0

AutowireTrait for ContainerInjectionInterface classes; core enables autowiring in core.services.yml.

10.3.0

!tagged_iterator and !service_closure in services files.

11.0.0

Symfony 7 provides #[AutowireIterator].

11.1.0

Hook classes in src/Hook are registered as autowired services.

11.3.0

PluginBase provides a generic autowired create().

11.4.0

FormBase uses AutowireTrait; #[Required] setter injection for plugin and controller base classes.

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.