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.
What and why
Section titled “What and why”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.defaultservice 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.
Mental model
Section titled “Mental model”Think of the container as a compiled phone book, built in two phases:
- Collection. Core contributes
core/core.services.ymlplus 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. - Compilation. The result is dumped to a cached container. This is why editing a
services.ymlfile does nothing until you rebuild caches (drush cache:rebuild), and why lookups at runtime are cheap.
Service IDs, including class names
Section titled “Service IDs, including class names”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
EntityTypeManagerInterfacewithout any configuration. -
Class names as the ID itself. Newer core services skip the machine name entirely:
services:Drupal\Core\DefaultContent\Importer:autowire: trueThe 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.
Registering your own services
Section titled “Registering your own services”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:
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:
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.
Autoconfiguration
Section titled “Autoconfiguration”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 interface | Tag applied automatically |
|---|---|
Symfony\Component\EventDispatcher\EventSubscriberInterface | event_subscriber |
Psr\Log\LoggerAwareInterface | logger_aware |
Drupal\Core\Queue\QueueFactoryInterface | queue_factory |
Drupal\Core\PreWarm\PreWarmableInterface | cache_prewarmable |
Drupal\Core\Extension\ModuleUninstallValidatorInterface | module_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.
Getting services into your classes
Section titled “Getting services into your classes”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:
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\ContainerFactoryPluginInterfacefor plugins). A staticcreate()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 <?phpdeclare(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\ControllerBaseuses\Drupal\Core\DependencyInjection\AutowireTrait, which provides a genericcreate()that resolves each constructor parameter from its type hint (honoring Symfony’s#[Autowire]parameter attribute for overrides). A controller extendingControllerBasecan therefore skipcreate()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.
Lazy services
Section titled “Lazy services”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.
How core uses it
Section titled “How core uses it”core.services.ymlis the reference implementation. Several hundred service definitions,_defaults: autoconfigure: trueat the top, machine-name IDs with interface aliases throughout, class-name IDs for newer services, andlazy: truesprinkled where construction is expensive. When unsure how to write a definition, find a similar one there.ControllerBaseships autowiring for everyone. Since\Drupal\Core\Controller\ControllerBaseusesAutowireTrait, 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”.
Common misconceptions
Section titled “Common misconceptions”- “
\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 increate()): 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.
Where to go next
Section titled “Where to go next”- Services and events: define a service, subscribe to events, and use service tags, with working code.
- Routes and controllers: inject services into controllers both ways.
- Hooks and events: hook classes as autowired services.
- Build your first module, part 3: extract logic into a service and inject it.
Services and Dependency Injection Container topic on api.drupal.org.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.