Plugins
Blocks, field types, text filters, image effects, queue workers, views handlers: whenever Drupal offers a list of interchangeable behaviors and lets any module add to that list, you are looking at a plugin type. This page explains the machinery behind that pattern so you can both use existing plugin types and read the code of any plugin you encounter.
What and why
Section titled “What and why”A plugin is a small class that does one job behind a common interface, identified by a plugin ID. A plugin type is the contract for one family of those jobs: “a block produces a render array”, “a field formatter turns field values into output”. Modules provide implementations; Drupal discovers them all, presents them as choices (in the UI or in configuration), and instantiates the chosen one on demand.
The problem plugins solve is open-ended choice. Site builders pick blocks from a catalog that any module can extend, without the Block module knowing any of them in advance. Compare the alternatives: hard-coded switch statements do not let other modules add options, and services are shared objects the container builds from definitions compiled ahead of time, not configurable choices created per use. Plugins fill exactly the gap between those two.
Mental model
Section titled “Mental model”Three roles cooperate, and keeping them distinct dissolves most plugin confusion:
- The manager owns one plugin type. It is a regular service (
plugin.manager.block,plugin.manager.field.field_type, …) that can list definitions and instantiate plugins. Nearly all managers extend\Drupal\Core\Plugin\DefaultPluginManager. - Discovery finds implementations. The manager scans defined namespaces, in every installed module, for classes carrying the plugin type’s attribute. The block manager scans
src/Plugin/Block/for classes with#[Block(...)]. - The definition vs the instance. Discovery produces a definition: the static metadata from the attribute (ID, label, category). Definitions are cheap, cached, and listable without loading plugin classes. An instance is the class instantiated for actual use, with its configuration passed in. One definition, many possible instances: the same block plugin placed in two regions is two instances with different configuration.
A useful invariant: the attribute describes the plugin; the class is the plugin; the manager is the only thing that connects the two. Your code never instantiates a plugin class directly; it asks the manager:
// Fragment: $blockManager is the injected plugin.manager.block service.$definitions = $blockManager->getDefinitions();$block = $blockManager->createInstance('system_powered_by_block', []);$build = $block->build();A PHP attribute on a plugin class is found by discovery, which produces a cached definition holding the attribute's static metadata. The plugin manager reads the cached definitions and, on createInstance, builds an instance with its own configuration. One definition can produce many instances: the same block plugin placed in two regions is two instances.
Attribute discovery
Section titled “Attribute discovery”A plugin announces itself with a PHP attribute on its class. Attributes are native PHP: typed constructor arguments your IDE autocompletes and static analysis verifies. The attribute class for blocks, Block, takes the plugin ID, an admin label, a category, optional context definitions, and an optional deriver.
The flagship tutorial’s RSVP block shows the shape (built step by step in part 5 of the module tutorial):
<?php
declare(strict_types=1);
namespace Drupal\event_rsvp\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;use Drupal\Core\Block\BlockBase;use Drupal\Core\Cache\Cache;use Drupal\Core\Plugin\ContainerFactoryPluginInterface;use Drupal\Core\Routing\RouteMatchInterface;use Drupal\Core\StringTranslation\TranslatableMarkup;use Drupal\event_rsvp\RsvpStorage;use Drupal\node\NodeInterface;use Symfony\Component\DependencyInjection\ContainerInterface;
/** * Shows how many people have responded to the event on this page. * * The #[Block] attribute replaces the old @Block annotation: plain PHP * that your IDE and static analysis understand. */#[Block( id: 'event_rsvp_count', admin_label: new TranslatableMarkup('RSVP count'), category: new TranslatableMarkup('Event RSVP'),)]final class RsvpCountBlock extends BlockBase implements ContainerFactoryPluginInterface {
/** * Constructs the RSVP count block. * * @param array $configuration * The plugin configuration. * @param string $plugin_id * The plugin ID. * @param mixed $plugin_definition * The plugin definition. * @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage * The RSVP storage service. * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch * The current route match, used to find the node being viewed. */ public function __construct( array $configuration, string $plugin_id, mixed $plugin_definition, protected readonly RsvpStorage $rsvpStorage, protected readonly RouteMatchInterface $routeMatch, ) { parent::__construct($configuration, $plugin_id, $plugin_definition); }
/** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { return new static( $configuration, $plugin_id, $plugin_definition, $container->get('event_rsvp.storage'), $container->get('current_route_match'), ); }
/** * {@inheritdoc} */ public function build(): array { $node = $this->getNode(); if (!$node) { return []; } $count = $this->rsvpStorage->countForNode((int) $node->id()); return [ '#markup' => $this->formatPlural( $count, '1 person is attending.', '@count people are attending.', ), ]; }
/** * {@inheritdoc} */ public function getCacheContexts(): array { // The output depends on which page (route) the block appears on: // a different node means a different count. return Cache::mergeContexts(parent::getCacheContexts(), ['route']); }
/** * {@inheritdoc} */ public function getCacheTags(): array { $tags = parent::getCacheTags(); if ($node = $this->getNode()) { // Invalidate the cached block whenever the node is saved, or when // RsvpStorage invalidates its custom tag after a new RSVP. $tags = Cache::mergeTags($tags, $node->getCacheTags()); $tags = Cache::mergeTags($tags, [RsvpStorage::cacheTag((int) $node->id())]); } return $tags; }
/** * Returns the node from the current route, if there is one. */ protected function getNode(): ?NodeInterface { $node = $this->routeMatch->getParameter('node'); return $node instanceof NodeInterface ? $node : NULL; }
}Definitions are cached aggressively: managers store the discovery result in the cache backend and only re-scan after a cache rebuild. While developing a new plugin, run drush cache:rebuild (drush cr) before concluding your attribute is wrong.
Context definitions
Section titled “Context definitions”Some plugins need data from their environment to do their job: “the user being viewed”, “the node on this page”. Declaring a context_definitions entry in the attribute, with \Drupal\Core\Plugin\Context\ContextDefinition or \Drupal\Core\Plugin\Context\EntityContextDefinition objects, lets the plugin require that data without fetching it. The surrounding system (block layout, Layout Builder (Drupal’s drag-and-drop layout tool), conditions) maps available contexts to the plugin’s requirements, and the plugin reads them with getContextValue():
<?php
declare(strict_types=1);
namespace Drupal\docs_snippets\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;use Drupal\Core\Block\BlockBase;use Drupal\Core\Plugin\Context\EntityContextDefinition;use Drupal\Core\StringTranslation\TranslatableMarkup;
/** * Greets the user the block is shown to. * * The context_definitions key declares what this plugin needs from its * environment: a user entity. Drupal supplies a matching value before * build() runs, so the plugin never has to know where the user came from. */#[Block( id: 'docs_snippets_user_greeting', admin_label: new TranslatableMarkup('User greeting'), context_definitions: [ 'user' => new EntityContextDefinition( data_type: 'entity:user', label: new TranslatableMarkup('User to greet'), ), ],)]final class ConceptsFoundationsUserGreetingBlock extends BlockBase {
/** * {@inheritdoc} */ public function build(): array { /** @var \Drupal\user\UserInterface $user */ $user = $this->getContextValue('user'); return [ '#markup' => $this->t('Hello, @name!', ['@name' => $user->getDisplayName()]), // The greeting differs per user and must update when the account does. '#cache' => [ 'contexts' => ['user'], 'tags' => $user->getCacheTags(), ], ]; }
}The inversion matters: the block does not know whether the user came from the current request, a Layout Builder preview, or a test. It declared a need; the host supplied it. Context-aware plugins are also what make a block correctly cacheable per user or per node, because the context carries cacheability metadata with it.
Core declares contexts the same way. Drupal\user\Plugin\Condition\UserRole requires a user context through an EntityContextDefinition in its #[Condition] attribute, which is how block visibility conditions know which account to check.
Derivatives
Section titled “Derivatives”Sometimes one class should yield many plugins, one per item of configuration. Every menu gets its own “menu block”; every view display gets its own views block. The attribute names a deriver class (deriver: SystemMenuBlockDeriver::class), and the deriver, extending \Drupal\Component\Plugin\Derivative\DeriverBase, returns one definition per variant at discovery time. Derivative IDs use a colon: system_menu_block:footer, views_block:content_recent-block_1.
When you see a colon in a plugin ID, you are looking at a derivative, and the code that defines it is a deriver class, not a plugin class per ID.
Reading legacy annotations
Section titled “Reading legacy annotations”Before attributes, plugins were discovered through annotations: structured @Block(...) docblock comments parsed by a custom reader. You will still meet them in older contrib code:
/** * Counter-example: the pre-Drupal-11 annotation style. Use #[Block] instead. * * @Block( * id = "my_legacy_block", * admin_label = @Translation("Legacy block"), * ) */Drupal 11 still parses annotations for backwards compatibility (managers pass both an attribute class and an annotation class to discovery), but they are a comment-based emulation of what attributes do natively. Write attributes; translate annotations to attributes mentally when reading old code; convert them when you touch the file. Each annotated plugin triggers a deprecation notice, and annotation discovery is removed in Drupal 13.
How core uses it
Section titled “How core uses it”- The block manager in one line.
\Drupal\Core\Block\BlockManagercalls the\Drupal\Core\Plugin\DefaultPluginManagerconstructor with'Plugin/Block'as the namespace to scan,BlockPluginInterfaceas the required interface, and theBlockattribute class for discovery. Every plugin manager is a variation on those arguments. - Entity types are plugins.
\Drupal\Core\Entity\EntityTypeManageris a plugin manager whose definitions come from#[ContentEntityType]and#[ConfigEntityType]attributes. The entity system is built on the plugin system. - Menu blocks are derivatives.
Drupal\system\Plugin\Block\SystemMenuBlockdeclaresderiver: SystemMenuBlockDeriver::class; the deriver loads all menus and emits one block definition each. One class, a definition per menu, zero per-menu code. - Conditions consume context. The
user_rolecondition (core/modules/user/src/Plugin/Condition/UserRole.php) declares a requiredusercontext in its attribute and evaluates the supplied account, which is exactly the pattern your own context-aware plugins follow.
Common misconceptions
Section titled “Common misconceptions”- “A plugin is a kind of service.” Services are shared objects the container builds on first use from definitions compiled ahead of time. Plugins are classes instantiated on demand, possibly many times, each with its own configuration. The confusion is understandable because plugin managers are services. If a plugin needs services, it implements
\Drupal\Core\Plugin\ContainerFactoryPluginInterfaceand injects them increate(). - “The attribute is decoration; the real registration happens elsewhere.” The attribute is the registration. There is no YAML file, no hook, no registry to update for a standard plugin: a correctly attributed class in the right namespace is discovered automatically (after a cache rebuild).
- “Context definitions are configured in an annotation.” The old framing is dead. Context definitions are
context_definitionsentries inside the PHP attribute, holding realContextDefinitionobjects, as the examples above show. Only legacy code declares context in@Blockannotations. - “My plugin doesn’t show up, so discovery is broken.” In order of likelihood: the definition cache was not rebuilt, the namespace or directory is wrong (
src/Plugin/Block/, exactly), the attribute class is the wrong one for the plugin type, or the module is not installed. Discovery itself almost never breaks. - “One plugin class equals one plugin.” Derivers multiply one class into many definitions, and conversely a plugin ID with a colon does not have a dedicated class. Match IDs to classes through the manager’s definition list, not by guessing file names.
Where to go next
Section titled “Where to go next”- Blocks: the most common plugin type in practice, with configuration forms and cacheability.
- Build your first module, part 5: write the RSVP block shown above from scratch.
- Services and dependency injection: the container that plugin managers live in, and how plugins inject services.
- Entities, bundles, and fields: the biggest plugin-based subsystem in core.
Plugin API topic on api.drupal.org.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.