How to Create a Drupal Custom Plugin Type
Drupal plugins provide a structured way to make interchangeable pieces of functionality discoverable and reusable. Core uses plugin types for blocks, field widgets, image effects, search backends and many other components. Creating your own plugin type is useful when a module needs several implementations of the same behaviour without placing every conditional branch in one service.
A custom plugin type can support payment handlers, content processors, notification channels, import strategies or editorial actions. This guide explains the architecture, annotations and PHP attributes used by modern Drupal versions, discovery, plugin managers, derivers, testing and practical administration concerns for Drupal sites in Australia.
Choose A Plugin Architecture
Start by defining the behaviour that every plugin must provide. A plugin type should represent a genuine extension point, rather than a collection of unrelated classes. For example, a NotificationChannel plugin might expose send() and supports() methods, while each plugin handles email, SMS or a third-party provider.
Keep the contract small. The interface should describe actions that the manager or consuming service genuinely needs. If the interface becomes a mixture of configuration, rendering, validation and persistence, the plugin type is probably carrying too much responsibility. Separate those concerns into services or value objects where possible.
A plugin type normally includes four parts:
- An interface defining the required behaviour
- A plugin attribute or annotation containing discovery metadata
- A manager that discovers and instantiates plugins
- Concrete plugin classes implementing the interface
For a small module, a shared base class can reduce repeated code, but it should remain optional. Developers should be able to implement the interface directly when a plugin has unusual requirements.
Prepare The Custom Module
Assume the module is called site_tools, and the custom plugin type is called ContentProcessor. A practical directory layout might look like this:
site_tools/
site_tools.info.yml
site_tools.services.yml
src/
Plugin/
ContentProcessor/
ContentProcessorInterface.php
ContentProcessorBase.php
ContentProcessorManager.php
HtmlProcessor.php
The module’s .info.yml file must declare a compatible Drupal core version. In Drupal 10 and Drupal 11, use namespaces under Drupal\site_tools, PSR-4 class discovery, and dependency injection rather than global service calls.
Create the interface first:
<?php
namespace Drupal\site_tools\Plugin\ContentProcessor;
interface ContentProcessorInterface {
public function process(string $content, array $context = []): string;
public function getLabel(): string;
}
The process() method deliberately returns a string, making the example easy to use in a text-processing pipeline. In a production module, define whether the input is trusted markup, plain text or a render array. A clear contract prevents security mistakes when one implementation expects filtered HTML and another expects raw user input.
The base class can store common configuration:
<?php
namespace Drupal\site_tools\Plugin\ContentProcessor;
abstract class ContentProcessorBase implements ContentProcessorInterface {
public function __construct(
protected readonly array $configuration = [],
) {}
public function getLabel(): string {
return (string) ($this->configuration['label'] ?? 'Unnamed processor');
}
}
A base class is especially useful when plugins share configuration defaults. It should not become a hidden dependency container. Inject services into the manager or plugin constructor explicitly, and document the constructor signature used by every implementation.
Define Discovery Metadata
Drupal needs metadata to discover each plugin. In current Drupal development, PHP attributes are the preferred approach for new code. Create a plugin attribute class that extends Drupal’s Plugin attribute:
<?php
namespace Drupal\site_tools\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
#[\Attribute(\Attribute::TARGET_CLASS)]
class ContentProcessor extends Plugin {
public function __construct(
public readonly string $id,
public readonly string $label,
) {
parent::__construct($id, $label);
}
}
The attribute’s constructor can contain additional values such as a category, weight, provider or supported format. Keep metadata declarative. Values that change at runtime belong in configuration or a service, not in the discovery definition.
A concrete plugin then becomes straightforward:
<?php
namespace Drupal\site_tools\Plugin\ContentProcessor;
use Drupal\site_tools\Attribute\ContentProcessor;
#[ContentProcessor(
id: 'html_cleanup',
label: 'HTML cleanup',
)]
final class HtmlProcessor extends ContentProcessorBase {
public function process(string $content, array $context = []): string {
return trim(strip_tags($content, '<p><strong><em><ul><ol><li><a>'));
}
}
The attribute class and plugin class can use the same short name only if their namespaces remain clear. Some teams prefer ContentProcessorPlugin as the attribute name to avoid ambiguity. Whichever convention you choose, use an ID that is lowercase, stable and machine-friendly. Never change an ID casually after configuration has been exported to YAML.
Drupal still supports annotation-based plugin discovery in many existing modules. An annotation uses a docblock rather than a PHP attribute, but the manager architecture is similar. For a new Drupal 11 module, attributes reduce docblock parsing and align with current core code. If the module must support older core releases, check the supported discovery API before selecting one format.
Build The Plugin Manager
The plugin manager coordinates discovery and instantiation. It normally extends DefaultPluginManager, supplies the plugin namespace, and points Drupal to the relevant attribute class:
<?php
namespace Drupal\site_tools\Plugin\ContentProcessor;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\site_tools\Attribute\ContentProcessor;
final class ContentProcessorManager extends DefaultPluginManager {
public function __construct(
\Traversable $namespaces,
CacheBackendInterface $cache_backend,
ModuleHandlerInterface $module_handler,
) {
parent::__construct(
'Plugin/ContentProcessor',
$namespaces,
$module_handler,
ContentProcessorInterface::class,
ContentProcessor::class,
);
$this->alterInfo('site_tools_content_processor_info');
$this->setCacheBackend($cache_backend, 'site_tools_content_processor_plugins');
}
}
Register the manager as a service in site_tools.services.yml:
services:
plugin.manager.site_tools_content_processor:
class: Drupal\site_tools\Plugin\ContentProcessor\ContentProcessorManager
arguments:
- '@container.namespaces'
- '@cache.discovery'
- '@module_handler'
The constructor arguments shown here match the standard manager pattern, but exact signatures can vary between Drupal versions. Check the DefaultPluginManager API for the core version used by the site before copying code into a shared module.
| Concern | Recommended choice | Why it matters |
|---|---|---|
| Plugin contract | Dedicated interface | Ensures every implementation can be consumed consistently |
| Metadata | PHP attribute for new code | Fits modern Drupal discovery and keeps declarations close to classes |
| Manager | DefaultPluginManager |
Provides discovery, definitions and cached plugin creation |
| Runtime dependencies | Dependency injection | Improves testing and avoids static service lookups |
| Variants from configuration | Deriver | Creates dynamic definitions without duplicating plugin classes |
| Site-specific behaviour | Configuration or a separate service | Keeps plugin metadata stable and deployable |
You can retrieve definitions through the manager:
$definitions = $manager->getDefinitions();
$plugin = $manager->createInstance('html_cleanup', [
'label' => 'Clean article HTML',
]);
The manager should usually be consumed by another service, controller or form through dependency injection. Avoid calling \Drupal::service() in business logic, particularly when the plugin will be tested in isolation.
Add Derivers When Definitions Vary
A single plugin class can represent multiple discovered plugins through a deriver. This is useful when definitions depend on content types, configured providers or another extensible source. For example, one processor class could create a plugin definition for each configured text format.
A deriver implements ContainerDeriverInterface when it needs services, or DeriverBase when it does not. Its getDerivativeDefinitions() method receives the base definition and returns derivative IDs such as html_cleanup.basic_html and html_cleanup.restricted_html.
Use derivatives only when the variation is genuinely discoverable. A deriver that loads large amounts of configuration during every cache rebuild can make administration slow. It also needs a sensible cache strategy, because adding or removing a provider should invalidate plugin discovery.
For sites managed through configuration synchronisation, keep the source of those definitions exportable. This matters for agencies working across Sydney, Melbourne and Brisbane environments, where a local development configuration may otherwise produce a different plugin catalogue from production.
Consume Plugins Safely
A consuming service can inject the manager and select a plugin by ID:
<?php
namespace Drupal\site_tools;
use Drupal\site_tools\Plugin\ContentProcessor\ContentProcessorManager;
final class ContentPipeline {
public function __construct(
private readonly ContentProcessorManager $manager,
) {}
public function process(string $processor_id, string $content): string {
$plugin = $this->manager->createInstance($processor_id);
return $plugin->process($content);
}
}
Validate the requested ID before creating an instance when it comes from a form, URL or external integration. hasDefinition() allows a service to reject an unavailable plugin with a controlled exception or fallback. Do not assume that a plugin remains installed simply because it was selected in an old configuration export.
Helpful safeguards include:
- Check plugin definitions before instantiation
- Validate configuration with typed data or form constraints
- Escape or filter processed output according to its declared format
- Log provider failures without exposing credentials or personal data
A Drupal editor in Adelaide might select a processor through a configuration form, while a scheduled import runs the same plugin in a queue worker. Both callers should receive the same contract, but their error handling and execution time may differ. Long-running work belongs in queues rather than a browser request.
When the site serves multilingual audiences, plugin labels should use translatable strings and the plugin UI should respect the active language. A content team preparing Japanese campaign material may consult business phrase examples separately, but the Drupal plugin should still store stable IDs and translate only human-facing labels.
Test And Maintain The Plugin Type
Begin with unit tests for each implementation. Test normal input, empty values, malformed input and the exact output contract. If a processor filters markup, include links, attributes and disallowed tags in the fixture. Security tests should confirm that unsafe output cannot pass through merely because a plugin was selected by configuration.
Add a kernel test for discovery and service wiring. It should confirm that the manager finds the expected definition, creates the plugin, and applies the configured values. A failure here often indicates an incorrect namespace, plugin directory, attribute class or service argument.
Useful test boundaries include:
- Interface behaviour and output formatting
- Discovery of every expected plugin ID
- Invalid IDs and missing configuration
- Cache rebuilds after adding a plugin
- Access checks around administrative selection forms
Clear cache after creating or changing plugin metadata. Drupal caches discovery definitions, so a new class may not appear immediately in the UI. During development, use Drush commands appropriate to the project, such as drush cr, and verify that the module’s service definition is valid before investigating plugin code.
For Australian organisations, document operational details alongside the code. A plugin connecting to an SMS provider may need Australian mobile number handling, AEST scheduling and privacy considerations under the Privacy Act. An ecommerce integration may need GST-aware output and a provider that supports Australian billing. These are domain requirements, so keep them in configuration and services rather than hard-coding them into the discovery layer.
Deploy And Extend The System
Treat plugin IDs as API contracts. Once a plugin has been used in configuration, changing its ID can break forms, stored settings and imported configuration. If a rename is unavoidable, provide an update hook or migration that changes references safely.
Expose plugin selection through a configuration form rather than asking editors to type an ID. Build the options from $manager->getDefinitions(), sort by translated label, and save the selected ID with schema documentation. Add access permissions to the form and avoid exposing internal plugins that are intended only for automated processes.
A well-designed custom plugin type lets another module add a new implementation without modifying the original module. That is the main benefit of Drupal’s plugin system: discovery and contracts establish the extension point, while services, configuration and tests keep the behaviour dependable. Once the pattern is in place, the same approach can support import handlers, checkout rules, media transformations and editorial workflows across Australian Drupal projects.