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 #[Hook] attribute.

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".

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 src/Plugin), its metadata (attribute or annotation) class and its manager. "Block", "field formatter" and "object exporter" are plugin types.

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 plugin.manager.TYPE, that finds the plugins of one type and creates instances. It is the object a developer asks for "all the exporters" or "an exporter with this ID".

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 id, class, provider and the keys the attribute declares. Definitions are cached.

Factory

The object that turns a definition plus a configuration array into an instance. The default is the container factory, which calls create() when the plugin class implements ContainerFactoryPluginInterface and the plain constructor otherwise.

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 base:derivative.

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.

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.

flowchart LR A[PHP class with #91;ObjectExporter#93; attribute<br/>in src/Plugin/ObjectExporter] --> B[Discovery<br/>AttributeClassDiscovery] B --> C[Derivative decorator<br/>expands deriver classes] C --> D[processDefinition<br/>defaults and checks] D --> E[Alter hook<br/>hook_TYPE_info_alter] E --> F[(Cache backend<br/>per language, cache tags)] F --> G[Manager getDefinitions] G --> H[createInstance<br/>container factory] H --> I[Plugin instance]

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.

classDiagram class ObjectExporterManager { +getDefinitions() +createInstance(id, configuration) #alterInfo(hook) #setCacheBackend(backend, key, tags) } class DefaultPluginManager class ObjectExporter { +id +label +extension +mime_type +deriver } class Plugin class ObjectExporterInterface { <<interface>> +export(objects) } class ObjectExporterBase { <<abstract>> +label() } class CsvExporter class JsonExporter DefaultPluginManager <|-- ObjectExporterManager Plugin <|-- ObjectExporter ObjectExporterInterface <|.. ObjectExporterBase ObjectExporterBase <|-- CsvExporter ObjectExporterBase <|-- JsonExporter ObjectExporterManager ..> ObjectExporter : reads metadata ObjectExporterManager ..> ObjectExporterInterface : creates

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

Plugin/Block

Drupal\Core\Block\Attribute\Block

Condition

Plugin/Condition

Drupal\Core\Condition\Attribute\Condition

Action

Plugin/Action

Drupal\Core\Action\Attribute\Action

Field type, widget, formatter

Plugin/Field/FieldType, Plugin/Field/FieldWidget, Plugin/Field/FieldFormatter

Drupal\Core\Field\Attribute\FieldType, FieldWidget, FieldFormatter

Queue worker

Plugin/QueueWorker

Drupal\Core\Queue\Attribute\QueueWorker

Layout

Plugin/Layout

Drupal\Core\Layout\Attribute\Layout

Entity type (content, configuration)

(declared on the entity class)

Drupal\Core\Entity\Attribute\ContentEntityType, ConfigEntityType

Text filter

Plugin/Filter

Drupal\filter\Attribute\Filter

Views display

Plugin/views/display

Drupal\views\Attribute\ViewsDisplay

Media source

Plugin/media/Source

Drupal\media\Attribute\MediaSource

Migrate source

Plugin/migrate/source

Drupal\migrate\Attribute\MigrateSource

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 doctrine/annotations package is abandoned, core copies the parts it still needs into its own codebase. The copies are scheduled for removal in Drupal 13.

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.1 new is allowed in attribute arguments) but string concatenation with variables or function calls is not. Constants and ::class are.

  • Annotation keys that nested other annotations (@ContextDefinition(...), @Translation(...)) become new expressions.

  • 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-drupal reports 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)

Drupal\Component\Annotation\Plugin\Discovery\AnnotatedClassDiscovery. Deprecated since 11.2, unsupported in Drupal 12.

Attribute class followed by an annotation class

Drupal\Core\Plugin\Discovery\AttributeDiscoveryWithAnnotations. It tries the attribute first and falls back to the annotation of the class. Recommended for existing plugin types and used by core to stay compatible with plugins written in the old style.

Attribute class only

Drupal\Core\Plugin\Discovery\AttributeClassDiscovery, which extends the component class Drupal\Component\Plugin\Discovery\AttributeClassDiscovery. Recommended for new plugin types, such as the exporter below.

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);
  }

}

Common errors for a plugin of an existing type:

  • The class is in src/Plugin/Condition but the namespace says Plugins or 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;
  }

}

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 a TranslatableMarkup label is rendered in the language of the request.

  • getDefinition($plugin_id, $exception_on_invalid = TRUE) returns one definition and throws PluginNotFoundException for 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 $defaults into 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, and useCaches(FALSE) disables them, which is what tests of discovery do.

  • getFactory() returns the ContainerFactory. To change how plugins are built, override it.

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');
  }

}

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

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 a PluginException with 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 TranslatableMarkup is 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.

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;
  }

}

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);
  }

}

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 tag taxonomy_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 PluginNotFoundException when it is created. Use getDefinition($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 DefaultPluginManager. Scans src/Plugin/SUBDIR of every enabled module and theme (it reads the container.namespaces parameter).

YAML

Drupal\Core\Plugin\Discovery\YamlDiscovery. Plugins are entries in a MODULE.NAME.yml file in the module root; used for local tasks, local actions, menu links and contextual links. Good for plugins that are pure data and need no class of their own, because other modules add them without PHP.

Hook

Drupal\Component\Plugin\Discovery\HookDiscovery. The manager invokes hook_TYPE_info() and each implementation returns an array of definitions. A legacy style, now rare.

Static

Drupal\Component\Plugin\Discovery\StaticDiscovery. The manager registers its own plugins with setDefinition(). Suitable only when no other code may add plugins.

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.

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, as CsvExporter does).

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;
  }

}

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];
}

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.

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() (as ObjectExportBuilder does) or restricted to the options the form offered. A raw value passed to createInstance() throws PluginNotFoundException, 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

PluginNotFoundException: The "x" plugin does not exist

The ID is misspelled, the module providing the plugin is uninstalled, the cache was not rebuilt, or a derivative vanished. Use hasDefinition(); for stored IDs, a fallback plugin or getDefinition($id, FALSE).

The new plugin never appears in the list

Wrong subdirectory (Plugin/ObjectExporter must match the manager’s first argument), namespace or file name that does not match PSR-4, or a stale cache. Plugin files are never found by a rename alone.

ArgumentCountError when the plugin is created

The class has extra constructor arguments but does not implement ContainerFactoryPluginInterface, so the factory calls the plain three-argument constructor.

Class "Drupal\meridian_collection\Attribute\ObjectExporter" not found or a plugin silently skipped

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 new. A computed value, a function call or t() in the attribute is a fatal error. Use new TranslatableMarkup(...).

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 clearCachedDefinitions().

The settings form loses plugin values on AJAX

The plugin form was built without a subform state, or #tree is missing on the container, so values do not nest under the expected key.

\Drupal::service() inside a plugin class

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 type entry per plugin ID or a dynamic type.

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

AttributeDiscoveryWithAnnotations handles plugins of managers that pass both classes. Core’s deprecation message for the annotation path names Drupal 13.0.0 as the removal.

Autowired plugins

PluginBase::create() with autowired constructor arguments since 11.3. Older versions need an explicit create().

OOP hooks for alter hooks

#[Hook] methods in src/Hook since 11.1; the alter hooks of plugin managers use it like any other hook.

doctrine/annotations

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 Annotation class; since 10.2 an attribute class extending Drupal\Component\Plugin\Attribute\Plugin replaces 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.3 PluginBase autowires the constructor, so the boilerplate is only needed on 10.2 to 11.2.

  • Material that uses hook_block_info(), HookDiscovery or DerivativeDiscoveryDecorator describes 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.