Drupal: Plugin API and PHP Attributes
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
A plugin is a small, swappable piece of behaviour that Drupal finds by itself, describes with metadata and creates on demand. Blocks, field types, widgets and formatters, Views handlers, queue workers, text filters, image effects, layouts, migrate sources, condition and action objects, validation constraints, and even entity types are plugins. When a site builder picks "On display now" from the list of blocks, chooses a formatter in a display, or selects an export format from a drop-down, a plugin manager has listed every available implementation and a factory has built the chosen one. This page explains how that machinery works end to end: the plugin vocabulary, discovery, the move from annotations to PHP attributes that began in Drupal 10.2, how a deriver multiplies one class into many plugins, how to define a brand-new plugin type with its own attribute and manager, how plugins receive configuration and show a configuration form, how contexts, caching and alter hooks fit in, and how to test and debug the result.
It was written against Drupal 11.4.x (11.4.0 was released on 1 July 2026), with Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation, change records and the API reference on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as upcoming events. New sites and modules start on Drupal 11.4; Drupal 10 behaviour is mentioned only where a feature arrived in a Drupal 10 minor.
The running example is the Meridian Museum, a fictitious regional science museum. Its module meridian_collection (started
in Module anatomy and info.yml) already has services
(Services and dependency injection), OOP hooks
(Hooks and OOP hooks) and an "On display now" block
(Blocks and block layout). In this page it gains a new plugin type, the
object exporter: a pluggable way to export collection objects as CSV, JSON or a printable label sheet per gallery, with
a configuration form for each exporter, and a condition plugin that uses a context. The settings form is reached from the
module’s configuration page and is similar in shape to the one in the Form API.
Who works with plugins
-
A developer writes plugins of existing types (a block, a formatter, a queue worker), defines new plugin types for extension points of their own module, and alters plugins written by others.
-
A site builder never writes a plugin but sees them everywhere: the block list, field type and widget pickers, the list of Views displays, the media source of a media type and the layouts of Layout Builder are all plugin lists, and each one comes from a plugin manager.
-
An administrator meets plugins when a module’s update removes a plugin class that configuration still references, which shows up as a "plugin does not exist" message on the status report or in the logs.
-
A themer meets them through block plugin IDs in template suggestions and through the layout plugins that Layout Builder offers.
When to use a plugin
Drupal offers several extension mechanisms and choosing the right one avoids over-engineering. The official "Why plugins?" page gives the rule of thumb that a plugin suits behaviour which a user selects or configures, and that tagged services suit implementations of a shared interface with no user interaction.
| Mechanism | Use it when | Typical Meridian example |
|---|---|---|
Plugin |
Several implementations share an interface, each needs metadata (a label, a description, a format) and a user or an administrator selects one, or configures it, or the list is shown in a UI. |
Object exporters, the "On display now" block, field formatters, queue workers. |
Tagged service |
Several implementations share an interface and the code collects all of them automatically. No labels, no UI, no per-instance configuration. |
The chain of accession-number validators in Services and dependency injection. |
Event subscriber |
Code must react to something that happened and many listeners may act in order of priority. |
Reacting after a collection sync (see Events and subscribers). |
Hook |
A core or contributed module offers an alter point or a notification and an implementation is a single method. Since
Drupal 11.1 hooks are methods with the |
Adding a column to an admin list. |
Plain service |
One implementation is enough, possibly replaced per environment through a decorator or a service alias. |
The collections API client. |
Plugins replace the "info hooks" of Drupal 7 (hook_block_info(), hook_field_info() and similar), which could not use
inheritance and could not enforce a required method. A module that wants to replace the class behind someone else’s plugin
alters its definition, for example with hook_block_alter(). The official page calls that the "ultimate override".
Source: Plugin API: Why plugins?
The plugin vocabulary
The Plugin API overview names a small number of parts, and the rest of this page uses the same words.
| Term | Meaning |
|---|---|
Plugin type |
A kind of pluggable behaviour: its interface, its plugin namespace (the subdirectory under |
Plugin (implementation) |
A PHP class of a plugin type, with metadata that identifies it. Its plugin ID is unique inside the type. |
Plugin manager |
A service, normally named |
Discovery |
The strategy used to find the definitions: attributes or annotations on classes, a YAML file, an info hook or a static list. |
Plugin definition |
The metadata of one plugin as discovery returns it, normally an array with the keys |
Factory |
The object that turns a definition plus a configuration array into an instance. The default is the container factory, which
calls |
Mapper |
A component that maps an arbitrary key (a field type, a request) to a plugin ID and returns a configured instance, so callers do not choose the plugin by hand. |
Derivative and deriver |
One plugin class that is registered as several plugins. A deriver class produces the list; each entry is a derivative with
an ID of the form |
Discovery decorator |
A wrapper around another discovery object that adds behaviour, for example caching or derivative expansion. |
Plugin collection |
A lazy set of plugin instances created from stored configuration, used by configuration entities such as blocks and image styles. |
Context |
A typed value (a node, a user, a date) that a context-aware plugin needs in order to work. Contexts are declared in the definition and supplied at run time. |
Source: Plugin API overview
The flow from a class on disk to a running object has several steps. Discovery produces definitions, decorators expand and cache them, alter hooks let other modules change them, and the factory builds an instance on request.
The parts of one plugin type relate to each other like this. Only the manager is a service; the attribute and the plugin classes are plain classes that the manager reads and instantiates.
Plugins in core and where to find them
Every plugin type of core is a good model, because its manager, attribute class and base class show the conventions. The
table lists common types, the namespace under which plugin classes live (in src/Plugin of a module) and the attribute
class that carries the metadata. Check the API reference for the exact attribute of a type before relying on this table,
because a few types, such as Views handlers, use several attributes.
| Plugin type | Plugin namespace | Attribute class |
|---|---|---|
Block |
|
|
Condition |
|
|
Action |
|
|
Field type, widget, formatter |
|
|
Queue worker |
|
|
Layout |
|
|
Entity type (content, configuration) |
(declared on the entity class) |
|
Text filter |
|
|
Views display |
|
|
Media source |
|
|
Migrate source |
|
|
The field-related plugins are explained in Block, field type, widget and formatter plugins, entity types in Custom entity types, queue workers in Queue, batch and cron APIs and Migrate sources in the Migrate API. This page is about the machinery they share.
To see what a running site knows, ask a manager. With Drush (any plugin type, replace the service ID):
ddev drush php:eval 'print_r(array_keys(\Drupal::service("plugin.manager.block")->getDefinitions()));'
ddev drush php:eval 'print_r(\Drupal::service("plugin.manager.block")->getDefinition("meridian_on_display_now"));'
Source: Drush: php:eval
The \Drupal:: static call is acceptable in a one-line debugging command and in tests; classes use constructor injection
(see Services and dependency injection).
Attributes and annotations
What changed and when
Until Drupal 10.1 a plugin class carried its metadata in a docblock annotation parsed by the Doctrine annotation library. Drupal 10.2.0 added discovery from native PHP attributes, and two change records describe the two halves of the move: "Plugin types should use PHP attributes instead of annotations" (for plugin managers and their attribute classes) and "Plugin implementations should use PHP attributes instead of annotations" (for the plugin classes themselves). Both were published in October 2023 for 10.2.0.
| Version | What happened |
|---|---|
10.2.0 |
Attribute discovery arrives. The Action and Block plugin types are converted first. Annotations keep working everywhere. |
10.3.0, 11.0, 11.1 |
More core plugin types are converted. Core plugin classes in the codebase switch to attributes. |
11.2 |
All core plugin types support attributes. A plugin manager that supplies only an annotation class is deprecated and will not be supported in Drupal 12. |
10.6 and 11.3 |
Because the upstream |
12.0 (planned) |
Plugin managers must define an attribute class. Annotation-only plugin types stop working. |
13.0 |
Core’s plugin-annotation parsing is removed (the deprecation message of core’s combined discovery says so). |
The official attribute-based plugins page states that annotated plugins "will continue to be supported for now", and gives
no removal date of its own. The dates above come from the change records and from core’s own deprecation messages, so the
safe reading is that annotation-only plugin managers must be fixed before Drupal 12 and annotated plugin classes before
Drupal 13. A plugin class that still uses an annotation triggers an E_USER_DEPRECATED notice when discovery parses it,
which a deprecation-aware test run or static analysis reports. New code should always use attributes, as long as the plugin
manager supports them, which every core type does since 11.2.
Older books such as Drupal 10 Development Cookbook (2023) write every plugin with an annotation (@Block(...),
@FieldFormatter(...), @Translation("...")) and show a Annotation class for a custom plugin type. That was correct for
Drupal 10.0 and 10.1 and still works, but since 10.2 the attribute is the recommended form and the annotation class is the
deprecated path. Everything in this page that the cookbook writes as an annotation is written as an attribute.
Converting an annotation
The rules of the change record are mechanical: import the attribute class, replace the docblock annotation by the attribute,
write the top-level = as : (a named argument), write arrays in normal PHP syntax, build translatable strings with
new TranslatableMarkup(...) instead of @Translation(...) and use ::class for class references.
<?php
// Before: Drupal 10.0 and 10.1 style, still supported but deprecated.
/**
* Shows the opening hours of the museum.
*
* @Block(
* id = "meridian_opening_hours",
* admin_label = @Translation("Opening hours"),
* category = @Translation("Meridian Museum"),
* )
*/
final class OpeningHoursBlock extends BlockBase {
}
// After: Drupal 10.2 and later.
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Shows the opening hours of the museum.
*/
#[Block(
id: 'meridian_opening_hours',
admin_label: new TranslatableMarkup('Opening hours'),
category: new TranslatableMarkup('Meridian Museum'),
)]
final class OpeningHoursBlock extends BlockBase {
}
Points that trip people up during a conversion:
-
An attribute is evaluated as a PHP expression, so
new TranslatableMarkup('...')is valid (since PHP 8.1newis allowed in attribute arguments) but string concatenation with variables or function calls is not. Constants and::classare. -
Annotation keys that nested other annotations (
@ContextDefinition(...),@Translation(...)) becomenewexpressions. -
The attribute class must exist. If a plugin type’s attribute class lives in a module that is not installed, discovery skips the class and finds it later, once the module is enabled.
-
A plugin file is loaded by attribute discovery (reflection needs the class), so a plugin that extends a base class, implements an interface or uses a trait from an uninstalled module can fail to load. Discovery records files whose parent classes are missing and skips them, and core 11.2 added a compatibility layer for missing traits. The change record suggests defining such plugins in an alter hook instead of a class on disk.
-
Static analysis with
phpstan-drupalreports the remaining deprecated annotations (see Coding standards and static analysis).
The three discovery classes
Core ships three discovery classes for classes on disk, and a plugin manager’s constructor decides which one is used.
| Constructor arguments | Discovery used |
|---|---|
Annotation class only (no attribute class) |
|
Attribute class followed by an annotation class |
|
Attribute class only |
|
The attribute discovery registers a temporary autoloader, scans the PSR-4 directories of the plugin namespace, reads the attribute by reflection, and keeps a file cache keyed by file path, so unchanged files are not parsed again. Because it works on files, rebuilding caches after adding, moving or renaming a plugin class is mandatory.
Writing a plugin of an existing type
Four things make a class a plugin of an existing type: it lives in the right namespace under src/Plugin, it implements the
type’s interface (usually by extending its base class), it carries the type’s attribute, and the module declares nothing else.
The official page gives two accepted layouts: src/Plugin/TYPE/NAME.php and src/Plugin/VENDOR/TYPE/NAME.php.
A complete block was shown in Blocks and block layout. This section writes a different type, a condition plugin, because it shows contexts, configuration and negation at once. The condition "the collection object is on display" can be used by visibility settings (for blocks) or by custom code that evaluates it.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\Condition;
use Drupal\Core\Condition\Attribute\Condition;
use Drupal\Core\Condition\ConditionPluginBase;
use Drupal\Core\Plugin\Context\EntityContextDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Passes when the collection object in the context is on display.
*/
#[Condition(
id: 'meridian_object_on_display',
label: new TranslatableMarkup('Collection object is on display'),
context_definitions: [
'collection_object' => new EntityContextDefinition(
'entity:collection_object',
label: new TranslatableMarkup('Collection object'),
),
],
)]
final class ObjectOnDisplay extends ConditionPluginBase {
/**
* {@inheritdoc}
*/
public function evaluate(): bool {
/** @var \Drupal\Core\Entity\ContentEntityInterface $object */
$object = $this->getContextValue('collection_object');
return (bool) $object->get('on_display')->value;
}
/**
* {@inheritdoc}
*/
public function summary(): string|\Stringable {
return $this->isNegated()
? $this->t('The object is not on display')
: $this->t('The object is on display');
}
}
Using it means asking the condition manager for an instance, handing it a context value and calling execute(), which
applies the "negate" setting for you:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Executable\ExecutableManagerInterface;
use Drupal\Core\Plugin\Context\Context;
use Drupal\Core\Plugin\Context\EntityContextDefinition;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Decides whether a label may be printed for an object.
*/
final class LabelPolicy {
public function __construct(
#[Autowire(service: 'plugin.manager.condition')]
private readonly ExecutableManagerInterface $conditionManager,
) {}
public function mayPrint(ContentEntityInterface $object): bool {
$condition = $this->conditionManager->createInstance('meridian_object_on_display');
$condition->setContext('collection_object', new Context(
EntityContextDefinition::fromEntity($object),
$object,
));
return $this->conditionManager->execute($condition);
}
}
Source: Plugin API: Plugin contexts
Common errors for a plugin of an existing type:
-
The class is in
src/Plugin/Conditionbut the namespace saysPluginsor the file name differs from the class name: PSR-4 discovery then never finds it. The plugin is silently missing, not an error. -
The plugin ID is not unique. The manager keeps one definition per ID, so one plugin overrides the other with no warning. Prefix every ID with the module name (
meridian_followed by the plugin name). -
parent::__construct()is not called when the constructor is overridden, or the first three arguments ($configuration,$plugin_id,$plugin_definition) are renamed or reordered, which breaks the factory. -
The cache was not rebuilt after adding the class (
ddev drush cr).
Defining a new plugin type
The official guide lists the pieces of a plugin type: an interface, an optional base class, an attribute (or annotation) class, an alter hook that lets other modules change the definitions, a manager class and its service definition. This section builds all of them for the Meridian object exporter. An exporter turns a list of collection objects into a downloadable string, and the museum wants several formats, each with a label for the administrative UI, a file extension and a MIME type.
The module tree after this page (files added here are marked):
meridian_collection/
├── meridian_collection.info.yml
├── meridian_collection.services.yml (added: plugin manager)
├── config/
│ ├── install/meridian_collection.export.yml (added)
│ └── schema/meridian_collection.schema.yml (added)
└── src/
├── Attribute/ObjectExporter.php (added)
├── Form/ExportSettingsForm.php (added)
├── Hook/ExporterHooks.php (added)
├── ObjectExporterBase.php (added)
├── ObjectExporterInterface.php (added)
├── ObjectExporterManager.php (added)
└── Plugin/
├── Condition/ObjectOnDisplay.php (added)
├── ObjectExporter/CsvExporter.php (added)
├── ObjectExporter/JsonExporter.php (added)
├── ObjectExporter/GalleryLabelSheet.php (added)
└── Derivative/GalleryLabelSheetDeriver.php (added)
1. The interface
The interface is the contract. Extend PluginInspectionInterface when callers need getPluginId() and
getPluginDefinition(), and add ConfigurableInterface, DependentPluginInterface, ContextAwarePluginInterface or
PluginFormInterface only for the capabilities the type really offers.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Entity\ContentEntityInterface;
/**
* Turns collection objects into a downloadable representation.
*/
interface ObjectExporterInterface extends PluginInspectionInterface {
/**
* The human readable label of the exporter.
*/
public function label(): string;
/**
* Exports the given objects.
*
* @param \Drupal\Core\Entity\ContentEntityInterface[] $objects
* The collection objects to export.
*
* @return string
* The exported file contents.
*/
public function export(array $objects): string;
}
2. The attribute class
New plugin types should define their own attribute class by extending
Drupal\Component\Plugin\Attribute\Plugin, which provides the two properties every plugin has, id and deriver. The
class is marked with #[\Attribute(\Attribute::TARGET_CLASS)], declares public readonly constructor properties for the
metadata and must keep the deriver argument even when the type uses no deriver. Document each key in the constructor
docblock: that docblock is the reference other developers read.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines an object exporter attribute for plugin discovery.
*
* @see \Drupal\meridian_collection\ObjectExporterManager
* @see \Drupal\meridian_collection\ObjectExporterInterface
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class ObjectExporter extends Plugin {
/**
* Constructs an ObjectExporter attribute.
*
* @param string $id
* The plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $label
* The human readable name of the exporter.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $description
* (optional) A short description shown on the settings form.
* @param string $extension
* (optional) The file extension of the export, without a dot.
* @param string $mime_type
* (optional) The MIME type of the export.
* @param class-string|null $deriver
* (optional) The deriver class.
*/
public function __construct(
public readonly string $id,
public readonly TranslatableMarkup $label,
public readonly ?TranslatableMarkup $description = NULL,
public readonly string $extension = 'txt',
public readonly string $mime_type = 'text/plain',
public readonly ?string $deriver = NULL,
) {}
}
The base Plugin attribute implements AttributeInterface; get() turns the attribute into the definition array, and
discovery adds the class and provider keys. Plugin types that only need an ID can use the ready-made
Drupal\Component\Plugin\Attribute\PluginID attribute, which contains just an ID.
3. The base class
A base class holds the code that every plugin of the type repeats. It extends Drupal\Core\Plugin\PluginBase, which stores
$configuration, $pluginId and $pluginDefinition and provides getPluginId(), getBaseId(), getDerivativeId() and
getPluginDefinition().
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Plugin\PluginBase;
/**
* Base class for object exporters.
*/
abstract class ObjectExporterBase extends PluginBase implements ObjectExporterInterface {
/**
* {@inheritdoc}
*/
public function label(): string {
return (string) $this->pluginDefinition['label'];
}
/**
* Reads one value of an object field, for subclasses.
*/
protected function fieldValue(\Drupal\Core\Entity\ContentEntityInterface $object, string $field): string {
return (string) $object->get($field)->value;
}
}
Source: Drupal APIs: Plugin API
4. The manager and its service
The manager extends DefaultPluginManager. Its constructor receives, in this order, $namespaces, a cache backend and the
module handler, and passes to the parent the plugin subdirectory, the namespaces, the module handler, the plugin interface and
the attribute class (a fifth argument, with no annotation class for a new type). It then names the alter hook with
alterInfo() and sets the cache backend, a cache key and optionally cache tags with setCacheBackend().
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\meridian_collection\Attribute\ObjectExporter;
/**
* Manages object exporter plugins.
*/
class ObjectExporterManager extends DefaultPluginManager {
public function __construct(
\Traversable $namespaces,
CacheBackendInterface $cache_backend,
ModuleHandlerInterface $module_handler,
) {
parent::__construct(
'Plugin/ObjectExporter',
$namespaces,
$module_handler,
ObjectExporterInterface::class,
ObjectExporter::class,
);
// Other modules implement hook_meridian_object_exporter_info_alter().
$this->alterInfo('meridian_object_exporter_info');
// Derivatives of the label sheet are built from gallery terms, so the
// cached definitions must be dropped when a gallery term changes.
$this->setCacheBackend($cache_backend, 'meridian_object_exporter_plugins', ['taxonomy_term_list:gallery']);
}
/**
* {@inheritdoc}
*/
public function processDefinition(&$definition, $plugin_id): void {
parent::processDefinition($definition, $plugin_id);
foreach (['label', 'extension', 'mime_type'] as $required) {
if (empty($definition[$required])) {
throw new PluginException(sprintf('The object exporter %s must define the "%s" key.', $plugin_id, $required));
}
}
}
}
The service definition inherits the arguments from the abstract default_plugin_manager service, and the plugin.manager.
prefix is the convention. Add an alias with the class name so that autowiring works in services, controllers and forms
(autowiring resolves by type, see Services and dependency injection):
services:
plugin.manager.meridian_object_exporter:
class: Drupal\meridian_collection\ObjectExporterManager
parent: default_plugin_manager
Drupal\meridian_collection\ObjectExporterManager: '@plugin.manager.meridian_object_exporter'
What the base class gives for free, and what to know about it:
-
getDefinitions()returns all definitions, from the cache when possible. Definitions are cached per language, so aTranslatableMarkuplabel is rendered in the language of the request. -
getDefinition($plugin_id, $exception_on_invalid = TRUE)returns one definition and throwsPluginNotFoundExceptionfor an unknown ID, or returns NULL when the second argument is FALSE. -
hasDefinition($plugin_id)checks an ID without the exception. -
createInstance($plugin_id, array $configuration = [])builds a new object through the container factory. -
processDefinition(&$definition, $plugin_id)merges the manager’s$defaultsinto each definition. It is the place to validate the metadata, as above, and to normalise the class name by stripping a leading backslash. -
findDefinitions()runs discovery,processDefinition()and the alter hook, then removes plugins whose provider module or theme is not installed. -
clearCachedDefinitions()empties the persistent and in-memory definition caches, anduseCaches(FALSE)disables them, which is what tests of discovery do. -
getFactory()returns theContainerFactory. To change how plugins are built, override it.
Source: API: DefaultPluginManager
5. The alter hook
alterInfo('meridian_object_exporter_info') means that after discovery the manager invokes
hook_meridian_object_exporter_info_alter(array &$definitions). Document it for other developers in
meridian_collection.api.php. Since Drupal 11.1 an implementation is a method with the #[Hook] attribute (see
Hooks and OOP hooks). Here the museum hides the JSON exporter on a staging site
until its output has been reviewed:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Alters object exporter definitions.
*/
final class ExporterHooks {
/**
* Implements hook_meridian_object_exporter_info_alter().
*/
#[Hook('meridian_object_exporter_info_alter')]
public function alterExporters(array &$definitions): void {
// The definition array is the processed attribute: change keys, swap
// the class, or unset an entry to hide the plugin.
unset($definitions['meridian_json']);
}
}
Alter hooks are the supported way to replace the class behind a plugin (for example $definitions['id']['class'] = MyClass::class) or
to change its metadata. A pitfall: an alter hook runs while definitions are built, so an implementation must not call the
same manager’s getDefinitions() (it would recurse) or depend on services that need the manager.
6. The plugins
Each exporter is a class with the attribute in src/Plugin/ObjectExporter. This one writes CSV and has its own
configuration (the delimiter and a header row); the configuration part is explained in
Plugin configuration and forms.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\ObjectExporter;
use Drupal\Component\Plugin\ConfigurableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\Attribute\ObjectExporter;
use Drupal\meridian_collection\ObjectExporterBase;
/**
* Exports collection objects as comma separated values.
*/
#[ObjectExporter(
id: 'meridian_csv',
label: new TranslatableMarkup('CSV'),
description: new TranslatableMarkup('One line per object, for spreadsheets.'),
extension: 'csv',
mime_type: 'text/csv',
)]
final class CsvExporter extends ObjectExporterBase implements ConfigurableInterface, PluginFormInterface {
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->setConfiguration($configuration);
}
public function export(array $objects): string {
$handle = fopen('php://temp', 'r+');
if ($this->configuration['include_header']) {
fputcsv($handle, ['accession_number', 'title', 'period', 'material'], $this->configuration['delimiter'], '"', '\\');
}
foreach ($objects as $object) {
fputcsv($handle, [
$this->fieldValue($object, 'accession_number'),
(string) $object->label(),
$this->fieldValue($object, 'period'),
$this->fieldValue($object, 'material'),
], $this->configuration['delimiter'], '"', '\\');
}
rewind($handle);
return (string) stream_get_contents($handle);
}
public function defaultConfiguration(): array {
return ['delimiter' => ',', 'include_header' => TRUE];
}
public function getConfiguration(): array {
return $this->configuration;
}
public function setConfiguration(array $configuration): void {
$this->configuration = $configuration + $this->defaultConfiguration();
}
public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
$form['delimiter'] = [
'#type' => 'select',
'#title' => $this->t('Delimiter'),
'#options' => [',' => $this->t('Comma'), ';' => $this->t('Semicolon'), "\t" => $this->t('Tab')],
'#default_value' => $this->configuration['delimiter'],
];
$form['include_header'] = [
'#type' => 'checkbox',
'#title' => $this->t('Include a header row'),
'#default_value' => $this->configuration['include_header'],
];
return $form;
}
public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
if (!in_array($form_state->getValue('delimiter'), [',', ';', "\t"], TRUE)) {
$form_state->setError($form['delimiter'], $this->t('Choose one of the offered delimiters.'));
}
}
public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
$this->configuration['delimiter'] = $form_state->getValue('delimiter');
$this->configuration['include_header'] = (bool) $form_state->getValue('include_header');
}
}
Source: API: PluginFormInterface
A second, much shorter plugin shows that exporters without settings need no extra interface:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\ObjectExporter;
use Drupal\Component\Serialization\Json;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\Attribute\ObjectExporter;
use Drupal\meridian_collection\ObjectExporterBase;
/**
* Exports collection objects as JSON.
*/
#[ObjectExporter(
id: 'meridian_json',
label: new TranslatableMarkup('JSON'),
extension: 'json',
mime_type: 'application/json',
)]
final class JsonExporter extends ObjectExporterBase {
public function export(array $objects): string {
$rows = [];
foreach ($objects as $object) {
$rows[] = [
'accession_number' => $this->fieldValue($object, 'accession_number'),
'title' => (string) $object->label(),
'period' => $this->fieldValue($object, 'period'),
];
}
return Json::encode($rows);
}
}
7. Using the manager
Callers inject the manager, ask for definitions to build a list, and create instances by ID. The service below is also the
place to guard against an ID that comes from a request: never pass an unchecked value to createInstance().
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
/**
* Exports collection objects with the exporter chosen by the caller.
*/
final class ObjectExportBuilder {
public function __construct(
private readonly ObjectExporterManager $exporters,
) {}
/**
* Returns the export of the objects, or NULL for an unknown exporter.
*
* @param \Drupal\Core\Entity\ContentEntityInterface[] $objects
*/
public function build(string $exporterId, array $objects, array $configuration = []): ?string {
if (!$this->exporters->hasDefinition($exporterId)) {
return NULL;
}
return $this->exporters->createInstance($exporterId, $configuration)->export($objects);
}
/**
* Returns exporter labels keyed by plugin ID, for a select list.
*
* @return string[]
*/
public function options(): array {
$options = [];
foreach ($this->exporters->getDefinitions() as $id => $definition) {
$options[$id] = (string) $definition['label'];
}
asort($options);
return $options;
}
}
With the service registered as Drupal\meridian_collection\ObjectExportBuilder and autowire: true, controllers, forms
and Drush commands receive it through their constructors.
services:
Drupal\meridian_collection\ObjectExportBuilder:
autowire: true
8. Scaffolding with Drush
drush generate plugin:manager asks for the module and the plugin type details and writes the manager and its service
entry. The other generators of the plugin: family
(plugin:block, plugin:condition, plugin:field:formatter, plugin:filter, plugin:queue-worker,
plugin:constraint, plugin:views:field and more) write plugins of existing types. Review the output: generator templates
lag behind core, so check that they use attributes, not annotations, and constructor injection, not \Drupal:: calls.
ddev drush generate plugin:manager
ddev drush generate plugin:block
ddev drush generate --help | grep plugin
Source: Drush: Code generators
Plugin definitions
A definition is what discovery returns for one plugin: for attribute and annotation plugins, an associative array built from
the attribute’s properties plus class and provider (the module or theme that holds the class). Anything that is not null
in the attribute appears as a key, so adding a property to the attribute class adds a key to every definition after the
next cache rebuild.
-
Use
$defaults(a protected property of the manager) for keys with a default value that every definition must have. -
Use
processDefinition()to validate. Throw aPluginExceptionwith a message that names the plugin ID, so the site administrator can find the offending class. -
Keep definitions small and serialisable. They are cached, so they must contain only scalars, arrays and objects that survive serialisation. A
TranslatableMarkupis fine because it keeps its string and arguments and translates when cast to string. -
Never put request-specific data in a definition, such as the current user’s name.
-
A definition read by
getDefinition()is always the processed and altered one, never the raw attribute.
Plugin types in core that use value objects instead of arrays (some entity-type and typed-data definitions) implement
PluginDefinitionInterface; the discovery classes accept both. For attribute plugins the array is the rule.
Source: Plugin API: Plugin definitions
Derivatives
A derivative lets one plugin class stand for many plugins whose number is not known when the code is written. The classic
case is the menu block: one class, SystemMenuBlock, appears in the block list once for every menu, and creating a menu
makes a new block available without writing code. The Meridian museum needs the same for printable label sheets: one
exporter per gallery, so that "Label sheet: Fossil gallery" and "Label sheet: Meteorite hall" are separate choices, and a new
gallery term adds a new choice automatically.
How a deriver is attached
The plugin’s attribute names the deriver class in its deriver key (this is why the key has to exist in the attribute even for
types that have no deriver). During discovery a decorator, Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator
(wrapped around the class discovery by DefaultPluginManager), asks the deriver for definitions and replaces the base
definition by the derived ones. Derived plugins get an ID of the form base_id:derivative_id; a plugin class can read both
parts with getBaseId() and getDerivativeId() from PluginBase.
A deriver implements Drupal\Component\Plugin\Derivative\DeriverInterface (methods getDerivativeDefinition($derivative_id,
$base_plugin_definition) and getDerivativeDefinitions($base_plugin_definition)), usually by extending
Drupal\Component\Plugin\Derivative\DeriverBase, which keeps the results in a protected $derivatives array and implements
the single-derivative method on top of the plural one. A deriver that needs services also implements
Drupal\Core\Plugin\Discovery\ContainerDeriverInterface, whose static create(ContainerInterface $container, $base_plugin_id)
builds it, in the same way a controller or form gets its dependencies.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Creates one label sheet exporter for every gallery term.
*/
final class GalleryLabelSheetDeriver extends DeriverBase implements ContainerDeriverInterface {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
public static function create(ContainerInterface $container, $base_plugin_id): static {
return new static($container->get('entity_type.manager'));
}
public function getDerivativeDefinitions($base_plugin_definition): array {
$this->derivatives = [];
$terms = $this->entityTypeManager->getStorage('taxonomy_term')->loadByProperties(['vid' => 'gallery']);
foreach ($terms as $term) {
$definition = $base_plugin_definition;
$definition['label'] = new TranslatableMarkup('Label sheet: @gallery', ['@gallery' => $term->label()]);
$definition['gallery_tid'] = (int) $term->id();
$this->derivatives[$term->id()] = $definition;
}
return $this->derivatives;
}
}
Source: Plugin API: Plugin Derivatives
The plugin class carries the attribute with the deriver key and reads the derivative ID at run time:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\ObjectExporter;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\Attribute\ObjectExporter;
use Drupal\meridian_collection\ObjectExporterBase;
use Drupal\meridian_collection\Plugin\Derivative\GalleryLabelSheetDeriver;
/**
* Prints a label sheet for the objects of one gallery.
*/
#[ObjectExporter(
id: 'meridian_label_sheet',
label: new TranslatableMarkup('Label sheet'),
extension: 'txt',
mime_type: 'text/plain',
deriver: GalleryLabelSheetDeriver::class,
)]
final class GalleryLabelSheet extends ObjectExporterBase {
public function export(array $objects): string {
$gallery = (int) $this->getDerivativeId();
$lines = [];
foreach ($objects as $object) {
$terms = array_column($object->get('gallery')->getValue(), 'target_id');
if (in_array($gallery, array_map('intval', $terms), TRUE)) {
$lines[] = sprintf('%s %s', $this->fieldValue($object, 'accession_number'), $object->label());
}
}
return implode("\n", $lines);
}
}
Source: Plugin API: Plugin Derivatives
The derivative ID is the term ID as a string, so the plugin ID is, for example, meridian_label_sheet:3. The derivative
definition also appears in the manager’s getDefinitions() list under that full ID. The base definition of the plugin
is replaced by its derivatives.
Derivative pitfalls
-
Caching. Derivative definitions are cached with the rest. A deriver that reads content or configuration (here gallery terms) must be covered by cache tags on the manager, or by calling
clearCachedDefinitions()when the source changes. The manager above passes the tagtaxonomy_term_list:gallery, which is invalidated whenever a term of the gallery vocabulary is saved or deleted. Without the tag a new gallery would not appear until the next cache rebuild. -
Loading entities during discovery. A deriver that loads entities runs while the plugin system is being built. Avoid loading entities of types whose own plugin managers are being built, and keep the work light: it runs on every cold cache.
-
Orphans. If a derivative disappears (a term is deleted) while configuration still points to its ID, the manager throws
PluginNotFoundExceptionwhen it is created. UsegetDefinition($id, FALSE)in code that handles stored IDs. -
Do not map every user choice to a plugin. The official page warns that a one-to-one mapping of user-entered options to plugins is undesirable. If the choice is only data, store it in the plugin’s configuration instead.
-
Reuse. A deriver class is only reusable for plugins that are already very similar.
-
Alter hook instead. For small changes to existing definitions an alter hook such as
hook_block_alter()is simpler than a deriver, as noted in Hooks and OOP hooks.
Older code that you will see in the wild: the annotation form deriver = "Drupal\system\Plugin\Derivative\SystemMenuBlock"
(now deriver: SystemMenuBlock::class in the attribute) and a wrapper new DerivativeDiscoveryDecorator(new HookDiscovery('block_info'))
for the Drupal 8.0-era hook-based managers. The container-aware decorator replaced it.
Discovery methods
Annotation or attribute discovery is the default, but the Plugin API allows others, and some core types use them.
| Discovery | Where it is used and how it works |
|---|---|
Attribute (and annotation) |
The default of |
YAML |
|
Hook |
|
Static |
|
A custom manager chooses a discovery by overriding getDiscovery() (the discovery property it fills is wrapped by the
container derivative decorator and the cache decorator). A YAML based exporter target list would be a file such as:
# meridian_collection.export_targets.yml
sftp_partner:
label: 'Partner SFTP drop'
class: Drupal\meridian_collection\ExportTarget\SftpTarget
description: 'Uploads the export to the partner server named in settings.php.'
Each DiscoveryInterface offers getDefinition(), getDefinitions() and hasDefinition(). A discovery decorator
implements the same interfaces as the object it wraps, takes it in its constructor and forwards other calls with __call().
The core caching decorator is the model: it asks the cache first and only runs the inner discovery on a miss.
Source: Plugin API: Discovery Decorators
Plugin configuration and forms
Configuration
A plugin instance receives an array of configuration in its constructor (the first argument). By convention, the plugin
type’s interface extends Drupal\Component\Plugin\ConfigurableInterface, whose three methods are:
-
defaultConfiguration()returns the default values; -
getConfiguration()returns the current ones; -
setConfiguration(array $configuration)replaces them (a good implementation merges the defaults, asCsvExporterdoes).
A plugin whose configuration refers to other configuration or modules implements
Drupal\Component\Plugin\DependentPluginInterface::calculateDependencies() so that configuration management can track the
dependency; the helper trait PluginDependencyTrait supplies addDependency() and calculatePluginDependencies().
Configuration that names a plugin the site no longer has is a classic source of broken sites after an uninstall (see
Configuration management).
Configuration must be validated and sanitised in the plugin, not assumed correct: it may arrive from a form, from a config
import or from a request. The CsvExporter above checks the delimiter against a fixed list because the value ends up in
fputcsv().
Plugin forms
Drupal\Core\Plugin\PluginFormInterface describes an embeddable plugin form with three methods,
buildConfigurationForm(), validateConfigurationForm() and submitConfigurationForm(). Because the plugin form is
embedded into someone else’s form, the caller passes a subform state created with
Drupal\Core\Form\SubformState::createForSubform($form['settings'], $form, $form_state), so that getValue() inside the plugin
reads from its own part of the submitted values and errors are attached to the right element. The plugin must never read the
full form state by absolute keys.
A plugin type with several forms (an edit form, a delete form) implements Drupal\Core\Plugin\PluginWithFormsInterface
and names the form classes in the forms key of its definition; blocks do this with the forms argument of the
#[Block] attribute and getFormClass() / hasFormClass().
The settings form below stores the chosen exporter and its configuration in meridian_collection.export. It reloads the
plugin’s subform by AJAX when the exporter changes (AJAX is covered in
AJAX and HTMX) and delegates validation and saving to the plugin:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\meridian_collection\ObjectExporterManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Chooses and configures the default object exporter.
*/
final class ExportSettingsForm extends ConfigFormBase {
public function __construct(
ConfigFactoryInterface $config_factory,
TypedConfigManagerInterface $typedConfigManager,
private readonly ObjectExporterManager $exporters,
) {
parent::__construct($config_factory, $typedConfigManager);
}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('config.factory'),
$container->get('config.typed'),
$container->get('plugin.manager.meridian_object_exporter'),
);
}
public function getFormId(): string {
return 'meridian_collection_export_settings';
}
protected function getEditableConfigNames(): array {
return ['meridian_collection.export'];
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$config = $this->config('meridian_collection.export');
$options = [];
foreach ($this->exporters->getDefinitions() as $id => $definition) {
$options[$id] = (string) $definition['label'];
}
$selected = $form_state->getValue('exporter') ?? $config->get('exporter.id');
$form['exporter'] = [
'#type' => 'select',
'#title' => $this->t('Default exporter'),
'#options' => $options,
'#default_value' => $selected,
'#ajax' => [
'callback' => '::updateSettings',
'wrapper' => 'meridian-exporter-settings',
],
];
$form['settings'] = [
'#type' => 'container',
'#tree' => TRUE,
'#attributes' => ['id' => 'meridian-exporter-settings'],
];
if ($selected && $this->exporters->hasDefinition($selected)) {
$plugin = $this->exporters->createInstance($selected, (array) $config->get('exporter.settings'));
if ($plugin instanceof PluginFormInterface) {
$subform_state = SubformState::createForSubform($form['settings'], $form, $form_state);
$form['settings'] = $plugin->buildConfigurationForm($form['settings'], $subform_state);
}
}
return parent::buildForm($form, $form_state);
}
public function updateSettings(array $form, FormStateInterface $form_state): array {
return $form['settings'];
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
parent::validateForm($form, $form_state);
$plugin = $this->pluginFromState($form_state);
if ($plugin instanceof PluginFormInterface) {
$plugin->validateConfigurationForm($form['settings'], SubformState::createForSubform($form['settings'], $form, $form_state));
}
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$plugin = $this->pluginFromState($form_state);
$settings = [];
if ($plugin instanceof PluginFormInterface) {
$plugin->submitConfigurationForm($form['settings'], SubformState::createForSubform($form['settings'], $form, $form_state));
$settings = $plugin->getConfiguration();
}
$this->config('meridian_collection.export')
->set('exporter', ['id' => $form_state->getValue('exporter'), 'settings' => $settings])
->save();
parent::submitForm($form, $form_state);
}
private function pluginFromState(FormStateInterface $form_state): ?object {
$id = $form_state->getValue('exporter');
return $this->exporters->hasDefinition($id) ? $this->exporters->createInstance($id) : NULL;
}
}
Source: API: PluginFormInterface
Because the form creates a new instance on every request, the plugin keeps no state between requests: all of it comes from the configuration array. The default configuration and the schema complete the picture:
# config/install/meridian_collection.export.yml
exporter:
id: meridian_csv
settings:
delimiter: ','
include_header: true
# config/schema/meridian_collection.schema.yml
meridian_collection.export:
type: config_object
label: 'Meridian export settings'
mapping:
exporter:
type: mapping
label: 'Default exporter'
mapping:
id:
type: string
label: 'Exporter plugin ID'
settings:
type: meridian_collection.exporter.settings.[%parent.id]
meridian_collection.exporter.settings.meridian_csv:
type: mapping
label: 'CSV exporter settings'
mapping:
delimiter:
type: string
label: 'Delimiter'
include_header:
type: boolean
label: 'Include header row'
meridian_collection.exporter.settings.meridian_json:
type: ignore
label: 'JSON exporter settings'
The dynamic type name ending in [%parent.id] is how core types such as block settings are described: one schema entry per
plugin. A missing schema entry is reported as a configuration schema error by the test traits and the status report. See
Configuration API and schema.
Plugin collections
When a configuration entity holds plugin instances (the block entity holds one block plugin, an image style holds a list of
effects), it exposes them through a plugin collection, a lazy set of instances built from the stored configuration. The
entity implements Drupal\Core\Entity\EntityWithPluginCollectionInterface (extend ConfigEntityBase, which already does)
and returns its collections from getPluginCollections(), which tells the configuration system which key holds plugin
configuration and makes the plugin configuration part of the export. An entity for named export profiles would add:
// In an ExportProfile config entity: the stored keys are "exporter" (plugin ID) and "settings".
protected string $exporter = '';
protected array $settings = [];
private ?\Drupal\Core\Plugin\DefaultSingleLazyPluginCollection $collection = NULL;
public function getPluginCollections(): array {
$this->collection ??= new \Drupal\Core\Plugin\DefaultSingleLazyPluginCollection(
\Drupal::service('plugin.manager.meridian_object_exporter'),
$this->exporter,
$this->settings,
);
return ['settings' => $this->collection];
}
Source: API: Plugin API topic
The \Drupal::service() call here sits in a config entity, which is created by the entity system rather than the container,
one of the few places where a static call is acceptable. Entity types are built in
Custom entity types.
Contexts
Some plugins need an object to work with: the condition above needs a collection object; a "node type" condition needs a node.
Such a plugin implements ContextAwarePluginInterface (the base classes for blocks and conditions already do, with
ContextAwarePluginTrait), and declares what it needs in the context_definitions key of its definition, as the condition
did. At run time the caller supplies values with setContext() or setContextValue(), and the plugin reads them with
getContextValue('name'). A context definition has a data type (a typed-data plugin ID such as entity:node or
entity:collection_object), a label, and the flags required (default TRUE), multiple (default FALSE), a description and a
default value. The ContextHandler matches the contexts available in a request against the definitions, which is how the block
system decides what a block can use.
Contexts make a plugin reusable, because it does not fetch its own input, and they keep plugin classes testable: a unit or kernel test sets a context and calls the method.
Source: Plugin API: Plugin Contexts
Instantiation, mappers and fallbacks
createInstance($plugin_id, $configuration) always returns a new object. getInstance(array $options) is the mapper
hook: a manager that implements it picks the plugin from the options (for example a request or a field type) and returns a
configured instance. The base DefaultPluginManager leaves the mapping policy to the manager, so a custom manager adds getInstance() when a "which plugin fits this input?" question recurs.
A manager that should survive missing plugins implements Drupal\Component\Plugin\FallbackPluginManagerInterface and
getFallbackPluginId($plugin_id, array $configuration = []). Core uses a "broken" or "missing" placeholder plugin for blocks,
filters and field widgets so that a removed module does not take the site down; a custom plugin type that stores plugin IDs
in configuration should do the same.
Plugin classes that need services implement ContainerFactoryPluginInterface and receive them through create(). Since
Drupal 11.3 PluginBase provides an autowired create(), so a plugin can list services in its constructor after the three
plugin arguments, as the "On display now" block of Services and dependency injection shows; on Drupal
10.2 to 11.2 the class writes the explicit factory. Use the #[Autowire] attribute on a constructor argument when the service
ID differs from the class.
Security
-
Never create a plugin from a raw request value. A plugin ID from a query string, a form field or an API payload must be checked with
hasDefinition()(asObjectExportBuilderdoes) or restricted to the options the form offered. A raw value passed tocreateInstance()throwsPluginNotFoundException, which becomes a 500 response and fills the log. -
Plugin IDs are not class names. Do not let configuration or a request name a PHP class directly. The plugin ID layer is the allow-list: only classes found by discovery in a plugin namespace can be built.
-
Validate plugin configuration in
validateConfigurationForm()and again where the value is used. Configuration can come from a config import, a Drush command or a JSON:API write that never ran the form. -
Check access separately. Having a plugin is not authorisation. A route that returns an export must require a permission (see Access control and permissions in code), and the objects handed to an exporter must be loaded with entity access respected. Entity queries must call
accessCheck()explicitly (see Database API and entity queries). -
Escape output. A plugin that returns markup builds render arrays or uses
$this->t()placeholders; values from content must never be concatenated into#markup. -
Do not store secrets in plugin configuration. Configuration is exported to the repository. A plugin that talks to an API reads the key from an environment variable or from the Key module, never from exported configuration.
-
Trusted code only. A deriver or an alter hook runs on every cold cache with the privileges of the site, so review third party plugin modules like any other code.
Testing plugins
A plugin type is best tested with a Kernel test: it enables the module, uses the real plugin manager and asserts the discovered definitions and the created instances. The test below uses PHPUnit attributes, the form used by core since 11.2. Plugin classes whose methods are pure functions of their configuration (the exporters) can additionally be unit tested.
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* Tests discovery and creation of object exporters.
*/
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class ObjectExporterManagerTest extends KernelTestBase {
protected static $modules = ['meridian_collection', 'user', 'system', 'taxonomy', 'text', 'field'];
public function testExportersAreDiscovered(): void {
$manager = $this->container->get('plugin.manager.meridian_object_exporter');
$definitions = $manager->getDefinitions();
$this->assertArrayHasKey('meridian_csv', $definitions);
$this->assertArrayHasKey('meridian_json', $definitions);
$this->assertSame('csv', $definitions['meridian_csv']['extension']);
}
public function testCsvDelimiterConfiguration(): void {
$plugin = $this->container->get('plugin.manager.meridian_object_exporter')
->createInstance('meridian_csv', ['delimiter' => ';']);
$this->assertSame(';', $plugin->getConfiguration()['delimiter']);
$this->assertTrue($plugin->getConfiguration()['include_header']);
}
}
Run it from the project root: ddev exec ./vendor/bin/phpunit -c web/core web/modules/custom/meridian_collection. The
testing strategy is described in Automated testing.
When something does not appear, debug in this order: is the class in the right directory and namespace; was the cache
rebuilt (ddev drush cr); does the file load without a fatal error (php -l); is the module providing the attribute class
installed; and is a required key missing (processDefinition() exceptions appear in the log with the plugin ID).
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
The ID is misspelled, the module providing the plugin is uninstalled, the cache was not rebuilt, or a derivative vanished.
Use |
The new plugin never appears in the list |
Wrong subdirectory ( |
|
The class has extra constructor arguments but does not implement |
|
The attribute class is missing or its namespace is wrong. On a plugin whose attribute lives in another, uninstalled module, discovery skips the file until the module is enabled. |
Deprecation notice about annotations |
A plugin class (or a manager, with only an annotation class) uses the old form. Convert to attributes before Drupal 12 (managers) and before Drupal 13 (plugin classes). |
The attribute rejects an argument |
Attributes accept only constant expressions plus |
A derivative does not update after a content change |
The manager’s cache tags do not include the source of the derivatives. Add a tag or call |
The settings form loses plugin values on AJAX |
The plugin form was built without a subform state, or |
|
Works but hides the dependency and blocks unit testing. Inject the service through the constructor. |
Configuration schema errors for plugin settings |
No schema for the plugin’s settings. Add a |
Version notes and corrections
| Topic | Note |
|---|---|
PHP attributes |
Available for plugins from Drupal 10.2.0; core types converted across 10.2, 10.3, 11.0, 11.1 and 11.2. Annotations still work and are deprecated; the manager-level fallback is unsupported in Drupal 12. |
Combined discovery |
|
Autowired plugins |
|
OOP hooks for alter hooks |
|
|
Abandoned upstream; core copied the used parts in 10.6 and 11.3 and plans to remove them in Drupal 13. |
Drupal 12 |
Plugin managers must supply an attribute class. Do not start a new plugin type with an annotation class. |
Where older material disagrees with the official documentation, the documentation wins:
-
Drupal 10 Development Cookbook (2023) writes plugins as annotations and shows a custom
Annotationclass; since 10.2 an attribute class extendingDrupal\Component\Plugin\Attribute\Pluginreplaces it, and the manager passes the attribute class to the parent constructor. -
The same book, and the older "Creating your own Plugin Manager" examples, call
\Drupal::service('plugin.manager.x')in classes. Inject the manager instead, and keep the static call to Drush one-liners and tests. -
Older plugin examples write
create()by hand in every plugin. Since 11.3PluginBaseautowires the constructor, so the boilerplate is only needed on 10.2 to 11.2. -
Material that uses
hook_block_info(),HookDiscoveryorDerivativeDiscoveryDecoratordescribes Drupal 7 or early Drupal 8 mechanisms; the container-aware decorator and attribute discovery replaced them. -
Some pages of the official documentation still show annotations (for example the Views and plugin-context pages); the attribute-based plugins page and the change records are the current guidance.