Skip to content

ConceptFor module developersDrupal 11

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.

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.

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();
Definition versus instance

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.

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):

web/modules/custom/event_rsvp/src/Plugin/Block/RsvpCountBlock.php
<?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.

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():

web/modules/custom/docs_snippets/src/Plugin/Block/ConceptsFoundationsUserGreetingBlock.php
<?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.

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.

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.

  • “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\ContainerFactoryPluginInterface and injects them in create().
  • “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_definitions entries inside the PHP attribute, holding real ContextDefinition objects, as the examples above show. Only legacy code declares context in @Block annotations.
  • “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.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.