Skip to content

ConceptFor module developersDrupal 11

Services and dependency injection

Nearly every object that does work in Drupal, from the database connection to the entity type manager, is a service: a shared object registered in a central container. This page builds the container mental model and shows the two injection idioms you will encounter in modern Drupal code, explicit factories and autowiring, so you can read and write both.

A service is a reusable object that performs a task: sending mail, logging, querying entities. The service container is the registry that knows how to construct every one of them, including which other services each needs. Code asks for entity_type.manager and receives a fully wired object; it neither knows nor cares that constructing one requires seven other services.

Dependency injection is the discipline this enables: a class receives its dependencies (through its constructor) instead of reaching out for them. The benefits are concrete in daily Drupal work:

  • Swappability. Anything that depends on the cache.default service works unchanged when a site swaps the database cache for Redis, because the container, not your class, decides which implementation backs the ID.
  • Testability. A class whose dependencies arrive in its constructor can be unit tested with mocks, with no database or container at all.
  • Decoupling and alterability. Modules can decorate or replace any service. None of that is possible with code that calls concrete classes directly.

During a request, the kernel compiles the container from every enabled module’s definitions on first boot and caches the result; afterwards, the container is a generated PHP class optimized for fast lookups.

Think of the container as a compiled phone book, built in two phases:

  1. Collection. Core contributes core/core.services.yml plus a service provider class; every module may ship a <module>.services.yml. Compiler passes (registered by \Drupal\Core\CoreServiceProvider) process tags, wire collectors, and resolve autowiring.
  2. Compilation. The result is dumped to a cached container. This is why editing a services.yml file does nothing until you rebuild caches (drush cache:rebuild), and why lookups at runtime are cheap.

Historically every service has a machine-name ID like entity_type.manager. Two newer conventions live alongside it, and core uses all three; you need to recognize each:

  • Interface aliases. Core aliases interfaces to services, for example in core.services.yml:

    services:
    Drupal\Core\Entity\EntityTypeManagerInterface: '@entity_type.manager'

    These aliases are what let autowiring resolve a constructor parameter typed EntityTypeManagerInterface without any configuration.

  • Class names as the ID itself. Newer core services skip the machine name entirely:

    services:
    Drupal\Core\DefaultContent\Importer:
    autowire: true

    The ID is the class name. Hook classes (see hooks and events) are registered this way automatically.

A service ID is a name, not a class reference: the class key behind an ID can change, be decorated, or be swapped. Depend on IDs and interfaces, not on concrete classes.

A module declares services in <module>.services.yml. The flagship tutorial’s storage service shows the explicit style, including the class-name alias that makes it autowirable:

web/modules/custom/event_rsvp/event_rsvp.services.yml
services:
event_rsvp.storage:
class: Drupal\event_rsvp\RsvpStorage
arguments:
- '@database'
- '@datetime.time'
- '@cache_tags.invalidator'
# Alias the class name to the service so autowired classes — like the
# #[Hook] implementations in src/Hook — can receive it by type hint.
Drupal\event_rsvp\RsvpStorage: '@event_rsvp.storage'
event_rsvp.access_check:
class: Drupal\event_rsvp\Access\RsvpAccessCheck
arguments:
- '@config.factory'
tags:
# "applies_to" connects this service to the _event_rsvp_enabled
# requirement used in event_rsvp.routing.yml.
- { name: access_check, applies_to: _event_rsvp_enabled }

With _defaults, a file can opt into autowiring wholesale, so entries shrink to the class name and any argument autowiring cannot guess:

web/modules/custom/docs_snippets/docs_snippets.services.yml
services:
# Opting into autowiring per file: services only need an entry, and
# constructor arguments are resolved from their type hints.
_defaults:
autowire: true
docs_snippets.response_subscriber:
class: Drupal\docs_snippets\EventSubscriber\ResponseSubscriber
tags:
- { name: event_subscriber }
docs_snippets.article_repository:
class: Drupal\docs_snippets\ArticleRepository
docs_snippets.article_manager:
class: Drupal\docs_snippets\ArticleManager
docs_snippets.welcome_mailer:
class: Drupal\docs_snippets\WelcomeMailer
# Not every argument can be autowired: pick a named logger channel.
arguments:
$logger: '@logger.channel.docs_snippets'
logger.channel.docs_snippets:
parent: logger.channel_base
arguments:
- 'docs_snippets'

autowire: true means: resolve each constructor parameter from its type hint, through exactly the interface aliases described above. When a type hint is ambiguous (every logger channel implements the same interface) you pin the argument explicitly, as the welcome_mailer entry does. The walkthrough for this file is in services and events.

autoconfigure: true (per service or in _defaults) applies tags based on the interfaces a class implements. \Drupal\Core\CoreServiceProvider registers these autoconfiguration rules, verbatim from core:

Implemented interfaceTag applied automatically
Symfony\Component\EventDispatcher\EventSubscriberInterfaceevent_subscriber
Psr\Log\LoggerAwareInterfacelogger_aware
Drupal\Core\Queue\QueueFactoryInterfacequeue_factory
Drupal\Core\PreWarm\PreWarmableInterfacecache_prewarmable
Drupal\Core\Extension\ModuleUninstallValidatorInterfacemodule_install.uninstall_validator

Core’s own core.services.yml sets _defaults: autoconfigure: true, and so do module files like shortcut.services.yml. With autoconfiguration on, an event subscriber needs only a service entry; the event_subscriber tag appears by itself.

Constructor injection with promoted properties is the baseline everywhere:

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

What differs is who calls the constructor. You will meet two idioms, both current:

Who calls the constructor

A service's constructor is called by the container, which builds the service on first use and shares it for the rest of the request. A controller is not in the container: the kernel asks the class resolver to build it, and the controller's static create() factory reaches into the container for the services it needs, then calls its own constructor.

  • Explicit create() factories. Controllers, forms, and plugins are not services; Drupal instantiates them through \Drupal\Core\DependencyInjection\ContainerInjectionInterface (or \Drupal\Core\Plugin\ContainerFactoryPluginInterface for plugins). A static create() receives the container and passes services to the constructor. It is explicit, debuggable with a breakpoint, and works for every constructor signature:

    web/modules/custom/event_rsvp/src/Controller/RsvpReportController.php
    <?php
    declare(strict_types=1);
    namespace Drupal\event_rsvp\Controller;
    use Drupal\Core\Controller\ControllerBase;
    use Drupal\Core\Datetime\DateFormatterInterface;
    use Drupal\Core\StringTranslation\TranslatableMarkup;
    use Drupal\event_rsvp\RsvpStorage;
    use Drupal\node\NodeInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    /**
    * Lists who has responded to an event.
    */
    final class RsvpReportController extends ControllerBase {
    /**
    * Constructs the report controller.
    *
    * @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage
    * The RSVP storage service.
    * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
    * The date formatter service.
    */
    public function __construct(
    protected readonly RsvpStorage $rsvpStorage,
    protected readonly DateFormatterInterface $dateFormatter,
    ) {}
    /**
    * {@inheritdoc}
    */
    public static function create(ContainerInterface $container): static {
    // Dependency injection: ask the container for the services this
    // controller needs instead of reaching for \Drupal::service().
    return new static(
    $container->get('event_rsvp.storage'),
    $container->get('date.formatter'),
    );
    }
    /**
    * Builds the RSVP report for one event node.
    *
    * @param \Drupal\node\NodeInterface $node
    * The event node, upcast from the {node} route parameter.
    *
    * @return array
    * A render array.
    */
    public function report(NodeInterface $node): array {
    $rows = [];
    foreach ($this->rsvpStorage->listForNode((int) $node->id()) as $record) {
    $rows[] = [
    $record->mail,
    $this->dateFormatter->format((int) $record->created, 'short'),
    ];
    }
    $build['table'] = [
    '#type' => 'table',
    '#header' => [$this->t('Email'), $this->t('Submitted')],
    '#rows' => $rows,
    '#empty' => $this->t('Nobody has responded yet.'),
    // Cached copies of this page are invalidated when the node changes or
    // when RsvpStorage writes a new RSVP for it.
    '#cache' => [
    'tags' => array_merge(
    $node->getCacheTags(),
    [RsvpStorage::cacheTag((int) $node->id())],
    ),
    ],
    ];
    return $build;
    }
    /**
    * Returns the report page title.
    *
    * @param \Drupal\node\NodeInterface $node
    * The event node.
    *
    * @return \Drupal\Core\StringTranslation\TranslatableMarkup
    * The translated page title.
    */
    public function reportTitle(NodeInterface $node): TranslatableMarkup {
    return $this->t('RSVPs for @title', ['@title' => $node->label()]);
    }
    }
  • Autowired create() through a trait. \Drupal\Core\Controller\ControllerBase uses \Drupal\Core\DependencyInjection\AutowireTrait, which provides a generic create() that resolves each constructor parameter from its type hint (honoring Symfony’s #[Autowire] parameter attribute for overrides). A controller extending ControllerBase can therefore skip create() entirely:

    // ControllerBase inherits create() from AutowireTrait: no factory needed
    // when every parameter resolves from its type hint.
    public function __construct(
    protected readonly RsvpStorage $rsvpStorage,
    ) {}

    This only works when each parameter’s type resolves to a service ID, which is why class-name aliases like Drupal\event_rsvp\RsvpStorage: '@event_rsvp.storage' matter.

Reserve \Drupal::service() for the few places that have no constructor: procedural update hooks, settings.php-adjacent scripts. Inside any class you control, inject.

Injection has one cost: every dependency is constructed with you, even on requests that never use it. For expensive services, definitions can declare lazy: true, and the container injects a tiny proxy that builds the real service on first method call. Core marks several services this way in core.services.yml. It is an optimization to reach for when profiling says so, not a default. See the Lazy Services topic for the proxy generation details.

  • core.services.yml is the reference implementation. Several hundred service definitions, _defaults: autoconfigure: true at the top, machine-name IDs with interface aliases throughout, class-name IDs for newer services, and lazy: true sprinkled where construction is expensive. When unsure how to write a definition, find a similar one there.
  • ControllerBase ships autowiring for everyone. Since \Drupal\Core\Controller\ControllerBase uses AutowireTrait, every custom controller that extends it gets type-hint injection without writing a factory.
  • Hook classes are autowired services. The hook collector registers every class with #[Hook] attributes as an autowired service whose ID is the class name; constructor injection in hook classes needs no YAML at all.
  • Tags drive collection. The HTTP middleware stack from the request lifecycle is assembled by a compiler pass collecting http_middleware-tagged services; breadcrumb builders, access checkers, and cache bins work the same way. A tag is how a service announces “include me in that subsystem”.
  • “\Drupal::service() is how you use services.” It is a static bridge for code that cannot be injected. Inside classes it hides dependencies, defeats unit testing, and fails in early bootstrap. Treat any \Drupal:: call inside a class you own as a refactoring marker.
  • “Autowiring is runtime magic.” Autowiring resolves while the container compiles (or, for AutowireTrait, through one reflection pass in create()): a type hint maps to a service ID through an alias. Nothing is guessed; if no alias exists for the type, you get an explicit autowiring exception naming the parameter.
  • “Editing services.yml takes effect on the next request.” The container is compiled and cached. Until you run drush cache:rebuild (drush cr), Drupal keeps using the old container. This bites everyone exactly once.
  • “Plugins and controllers are services.” They are not in the container; they are instantiated on demand, which is exactly why they need the create() factory pattern (explicit or trait-provided) to reach it.
  • “Injecting many services is fine since they’re shared.” Each injected service is constructed when your service is, transitively. A constructor with eight dependencies is a design smell first and a performance question second; split the class, or mark genuinely expensive rarely-used dependencies lazy.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.