Create Drupal block plugins and custom plugin types with correct plugin-style dependency injection (ContainerFactoryPluginInterface, 4-param create()). Use when asked to create a custom block, add a block configuration form, build a custom plugin type with a plugin manager, or work with Drupal's plugin discovery system. Covers D10 annotations and D11 PHP attributes for plugin discovery. Do NOT use for controller/service DI (use drupal-routing-controllers instead).
Drupal's plugin system provides extensible functionality. Choose the right approach:
Is it a custom block (content placed via Block Layout)?
YES -> Extend BlockBase, implement build(). Add DI via ContainerFactoryPluginInterface. See "Block plugin" below.
Is it a block with a configuration form?
YES -> Same as above, plus implement defaultConfiguration(), blockForm(), blockSubmit(), optionally blockValidate(). See "Block configuration forms" below.
Is it a custom plugin type (reusable extension point for your module)? YES -> Create a plugin manager + annotation/attribute class + plugin interface. See "Custom plugin types" below.
Is it an existing plugin type (field formatter, field widget, entity handler)? YES -> Extend the appropriate base class with correct annotation/attribute. See drupal-entities-fields (if installed) for field plugin patterns. If not available, follow the same DI and annotation/attribute patterns shown below for blocks.
Block plugins live in src/Plugin/Block/ and extend BlockBase. The class needs a plugin annotation (D10) or attribute (D11) to be discovered.
Namespace: Drupal\module_name\Plugin\Block
File location: src/Plugin/Block/MyBlock.php
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\BlockBase;
/**
* Provides a custom block.
*
* @Block(
* id = "my_module_example",
* admin_label = @Translation("Example Block"),
* category = @Translation("Custom"),
* )
*/
class ExampleBlock extends BlockBase {
public function build() {
return [
'#markup' => $this->t('Hello from the example block.'),
];
}
}
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
#[Block(
id: "my_module_example",
admin_label: new TranslatableMarkup("Example Block"),
category: new TranslatableMarkup("Custom"),
)]
class ExampleBlock extends BlockBase {
public function build() {
return [
'#markup' => $this->t('Hello from the example block.'),
];
}
}
Key differences: D10 uses @Block(...) docblock annotation with @Translation("..."). D11 uses #[Block(...)] PHP attribute with new TranslatableMarkup("..."). The class body is identical.
WRONG: Using
@Translation("...")in D11 attribute syntax.@Translationis annotation-only -- it does not work inside PHP attributes. RIGHT: Usenew TranslatableMarkup("...")in D11 attributes. ImportDrupal\Core\StringTranslation\TranslatableMarkup.
Plugin DI is different from controller/form DI. This is the single most common mistake Claude makes with Drupal plugins. Plugin classes that need services MUST implement ContainerFactoryPluginInterface and follow the 4-parameter create() signature.
// Controller create() -- 1 parameter:
public static function create(ContainerInterface $container) {
return new static($container->get('my_service'));
}
// Plugin create() -- 4 parameters:
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('my_module.my_service')
);
}
WRONG: Using controller DI signature in plugin classes --
create(ContainerInterface $container)with only 1 parameter. Plugincreate()MUST have 4 parameters:$container, $configuration, $plugin_id, $plugin_definition. Using 1 parameter causes a fatal error because the plugin manager passes all 4 arguments. RIGHT: Plugincreate()signature iscreate(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition). Always pass$configuration, $plugin_id, $plugin_definitionas the first 3 constructor arguments, followed by injected services.
WRONG: Forgetting
parent::__construct($configuration, $plugin_id, $plugin_definition)in the plugin constructor. Without this call, the plugin loses its configuration array, plugin ID, and definition metadata. Block placement settings, default configuration, and plugin inspection methods all break silently. RIGHT: Plugin constructors MUST callparent::__construct($configuration, $plugin_id, $plugin_definition)before storing injected services. This is unlike controllers, which have no required parent constructor call.
WRONG: Using
\Drupal::service('my_service')inside plugin classes to get services. Static service calls bypass dependency injection, making the plugin untestable and hiding its dependencies. RIGHT: ImplementContainerFactoryPluginInterface, inject services viacreate()+ constructor. The.modulefile is the ONE place where\Drupal::service()calls are acceptable because procedural code cannot use constructor injection.
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\my_module\DataService;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a data display block.
*
* @Block(
* id = "my_module_data_block",
* admin_label = @Translation("Data Display Block"),
* category = @Translation("Custom"),
* )
*/
class DataBlock extends BlockBase implements ContainerFactoryPluginInterface {
protected DataService $dataService;
public function __construct(array $configuration, $plugin_id, $plugin_definition, DataService $data_service) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->dataService = $data_service;
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('my_module.data_service')
);
}
public function build() {
$items = $this->dataService->getItems();
return [
'#theme' => 'item_list',
'#items' => $items,
'#cache' => [
'max-age' => 3600,
],
];
}
}
namespace Drupal\my_module\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\my_module\DataService;
use Symfony\Component\DependencyInjection\ContainerInterface;
#[Block(
id: "my_module_data_block",
admin_label: new TranslatableMarkup("Data Display Block"),
category: new TranslatableMarkup("Custom"),
)]
class DataBlock extends BlockBase implements ContainerFactoryPluginInterface {
// Constructor, create(), and build() are IDENTICAL to D10 version.
// Only the annotation/attribute syntax at the top of the class changes.
}
Blocks can have their own configuration form, displayed when an admin places or edits the block in Block Layout. Block config is stored automatically by the block placement system.
defaultConfiguration(): Provide default values for config keys.blockForm($form, FormStateInterface $form_state): Add form elements to block config.blockValidate($form, FormStateInterface $form_state): Optional validation.blockSubmit($form, FormStateInterface $form_state): Save config values.$this->configuration['key']use Drupal\Core\Block\BlockBase;
use Drupal\Core\Form\FormStateInterface;
class ConfigurableBlock extends BlockBase {
public function defaultConfiguration() {
return [
'message' => 'Default message',
'items_count' => 5,
];
}
public function blockForm($form, FormStateInterface $form_state) {
$form['message'] = [
'#type' => 'textfield',
'#title' => $this->t('Message'),
'#default_value' => $this->configuration['message'],
'#required' => TRUE,
];
$form['items_count'] = [
'#type' => 'number',
'#title' => $this->t('Number of items'),
'#default_value' => $this->configuration['items_count'],
'#min' => 1,
'#max' => 50,
];
return $form;
}
public function blockValidate($form, FormStateInterface $form_state) {
$count = $form_state->getValue('items_count');
if ($count < 1 || $count > 50) {
$form_state->setErrorByName('items_count', $this->t('Items count must be between 1 and 50.'));
}
}
public function blockSubmit($form, FormStateInterface $form_state) {
$this->configuration['message'] = $form_state->getValue('message');
$this->configuration['items_count'] = $form_state->getValue('items_count');
}
public function build() {
return [
'#markup' => $this->configuration['message'],
];
}
}
WRONG: Manually saving block configuration to Config API using
$this->configFactory->getEditable()->set()->save(). Block config is NOT stored in the Config API. It is managed by the block placement system and saved automatically whenblockSubmit()sets values on$this->configuration. RIGHT: InblockSubmit(), set values via$this->configuration['key'] = $value. Inbuild()and other methods, read them via$this->configuration['key']. Provide defaults indefaultConfiguration(). The block system handles persistence.
Override blockAccess() to control who sees the block. Return an AccessResult with appropriate cache metadata.
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
public function blockAccess(AccountInterface $account) {
return AccessResult::allowedIfHasPermission($account, 'access my_module content')
->addCacheContexts(['user.permissions']);
}
AccessResult::allowedIfHasPermission() handles cache contexts automatically. If you need custom logic, use AccessResult::allowedIf($condition) and add cache contexts manually.
See also: drupal-access-security (if installed) for the full access control system including AccessResult cache metadata patterns, permission definitions, and route-level access. If not available, use AccessResult::allowedIfHasPermission() which handles caching automatically, or AccessResult::allowedIf() with ->addCacheContexts(['user.permissions']) for custom conditions.
When your module needs a reusable extension point (e.g., "sandwich types", "notification channels", "import formatters"), create a custom plugin type. This requires three components plus a services.yml entry.
// src/Plugin/SandwichPluginInterface.php
namespace Drupal\my_module\Plugin;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
interface SandwichPluginInterface extends PluginInspectionInterface, ContainerFactoryPluginInterface {
/**
* Returns the sandwich description.
*/
public function description(): string;
/**
* Returns the number of calories.
*/
public function calories(): int;
}
// src/Annotation/Sandwich.php
namespace Drupal\my_module\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a Sandwich plugin annotation.
*
* @Annotation
*/
class Sandwich extends Plugin {
/**
* The human-readable plugin label.
*
* @var \Drupal\Core\Annotation\Translation
*/
public $label;
/**
* A brief description.
*
* @var \Drupal\Core\Annotation\Translation
*/
public $description;
}
// src/Attribute/Sandwich.php
namespace Drupal\my_module\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
#[\Attribute(\Attribute::TARGET_CLASS)]
class Sandwich extends Plugin {
public function __construct(
public readonly string $id,
public readonly TranslatableMarkup $label,
public readonly TranslatableMarkup $description = new TranslatableMarkup(""),
) {
parent::__construct($id);
}
}
// src/Plugin/SandwichPluginManager.php
namespace Drupal\my_module\Plugin;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\my_module\Annotation\Sandwich;
class SandwichPluginManager extends DefaultPluginManager {
public function __construct(
\Traversable $namespaces,
CacheBackendInterface $cache_backend,
ModuleHandlerInterface $module_handler,
) {
parent::__construct(
'Plugin/Sandwich',
$namespaces,
$module_handler,
SandwichPluginInterface::class,
Sandwich::class,
);
$this->alterInfo('sandwich_info');
$this->setCacheBackend($cache_backend, 'sandwich_plugins');
}
}
For D11 attribute-based discovery, replace the annotation class reference:
// D11 manager constructor -- use Attribute class instead of Annotation class: