Since pretty much the second page of this book you've been reading about how important plugins are and how widely they are used in Drupal 8. I have backed that claim with references to "this or that" being a plugin in basically every chapter. However, I have not really explained how you can create your own custom plugin type. However, since our importer logic is a perfect candidate for plugins, I will do so here, and to exemplify the theory, we will implement an Importer plugin type.
The very first thing a plugin type needs is a manager service. This is responsible for bringing together two critical aspects of plugins (but not only): discovery and factory (instantiation). For these two tasks, it delegates to specialized objects. The most common method of discovery is through annotations (AnnotatedClassDiscovery), and the most common factory is the container-aware one—ContainerFactory. So, essentially, the manager is the central player that finds and processes all the plugin definitions and instantiates plugins. Also, it does so with the help of those other guys.
Many plugin types in Drupal 8, since they follow the defaults I mentioned before, use the DefaultPluginManager, or should I say, they extend this class. It provides them with the annotated discovery and container-aware factory. So that is what we will do as well and see how simple it is to create a plugin type manager.
Typically, it lives in the Plugin namespace of the module, so ours can look like this:
namespace Drupal\products\Plugin;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
/**
* Provides the Importer plugin manager.
*/
class ImporterManager extends DefaultPluginManager {
/**
* ImporterManager constructor.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Importer', $namespaces, $module_handler, 'Drupal\products\Plugin\ImporterInterface', 'Drupal\products\Annotation\Importer');
$this->alterInfo('products_importer_info');
$this->setCacheBackend($cache_backend, 'products_importer_plugins');
}
}
Aside from extending the DefaultPluginManager, we will need to override the constructor and re-call the parent constructor with some parameters specific to our plugins. This is the most important part, and in order, these are the following (omitting the ones that are simply passed through):
- The relative namespace where plugins of this type will be found—in this case, in the Plugin/Importer folder
- The interface each plugin of this type needs to implement—in our case, the Drupal\products\Plugin\ImporterInterface (which we have to create)
- The annotation class used by our plugin type (the one whose class properties map to the possible annotation properties found in the DocBlock above the plugin class)—in our case, Drupal\products\Annotation\Importer (which we have to create)
In addition to calling the parent constructor with these options, we will need to provide the "alter" hook for the available definitions. This will make it possible for other modules to implement this hook and alter the found plugin definitions. The resulting hook in our case is hook_products_importer_info_alter.
Lastly, we also provide a specific cache key for the backend responsible for caching the plugin definitions. This is for increased performance: as you should already know by now, creating a new plugin requires clearing the cache.
That's it with our manager. However, since this is a service, we will need to register it as such inside the products.services.yml file:
services:
products.importer_manager:
class: Drupal\products\Plugin\ImporterManager
parent: default_plugin_manager
As you can see, we inherit the dependencies (arguments) from the default_plugin_manager service instead of duplicating them here again. If you remember from Chapter 3, Logging and Mailing, this is a neat little trick in Drupal 8.
Now, since we referenced some classes in the manager, we will need to create them. Let's start with the annotation class:
namespace Drupal\products\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines an Importer item annotation object.
*
* @see \Drupal\products\Plugin\ImporterManager
*
* @Annotation
*/
class Importer extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The label of the plugin.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $label;
}
This class needs to extend Drupal\Component\Annotation\Plugin, which is the base class for annotations and already implements AnnotationInterface.
For our purpose, we keep it simple. All we need is a plugin ID and a label. If we wanted to, we could add more properties to this class and describe them. It's a standard practice to do so because otherwise there is no clear way to know which properties a plugin annotation can contain.
Next, let's also write the interface the plugins are required to implement:
namespace Drupal\products\Plugin;
use Drupal\Component\Plugin\PluginInspectionInterface;
/**
* Defines an interface for Importer plugins.
*/
interface ImporterInterface extends PluginInspectionInterface {
/**
* Performs the import. Returns TRUE if the import was successful or FALSE otherwise.
*
* @return bool
*/
public function import();
}
Again, we keep it simple. For now, our importer will have only one method specific to it: import(). However, it will have other methods specific to plugins, which can be found in the PluginInspectionInterface we are extending. These are getPluginId() and getPluginDefinition() and are also quite important as the system expects to be able to get this info from the plugins.
Next, plugins of any type need to extend PluginBase because it contains a host of mandatory implemented methods (such as the ones I mentioned before). However, it is also a best practice for the module that introduces a plugin type to also provide a base plugin class that plugins can extend. Its goal is to extend PluginBase and also provide all the necessary logic needed by all the plugins of this type. For example, when we create a new block, we extend BlockBase, which, somewhere down the line, extends PluginBase.
In our case, this base (abstract) class can look something like this:
namespace Drupal\products\Plugin;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Component\Plugin\PluginBase;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\products\Entity\ImporterInterface;
use Drupal\products\Plugin\ImporterInterface as ImporterPluginInterface;
use GuzzleHttp\Client;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for Importer plugins.
*/
abstract class ImporterBase extends PluginBase implements ImporterPluginInterface, ContainerFactoryPluginInterface {
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* @var \GuzzleHttp\Client
*/
protected $httpClient;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManager $entityTypeManager, Client $httpClient) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entityTypeManager;
$this->httpClient = $httpClient;
if (!isset($configuration['config'])) {
throw new PluginException('Missing Importer configuration.');
}
if (!$configuration['config'] instanceof ImporterInterface) {
throw new PluginException('Wrong Importer configuration.');
}
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('http_client')
);
}
}
We implement ImporterInterface (renamed to prevent collision) to require subclasses to have the import() method. However, we also make the plugins container aware and already inject some helpful services. One is the EntityTypeManager because we expect all importers to need it. The other is the Guzzle HTTP Client that we use in Drupal 8 to make PSR-7 requests to external resources.
Adding this here is a judgment call. We can imagine more than one plugin needing external requests, but if it turns out they don't, we should surely remove it and add it only in that specific plugin. The opposite also holds true. If in the third plugin implementation we identify another common service, we can remove it from the plugins and inject it here. All while watching out for backwards compatibility.
Before talking about those exceptions we're throwing in the constructor, it's important to know how the plugin manager creates a new instance of a plugin. It uses its createInstance() method, which takes a plugin ID as a first parameter and an optional array of plugin configuration as a second parameter. The relevant factory then passes that array of configuration to the plugin constructor itself as the second parameter. Oftentimes, this is empty. However, for our plugin type, we will need configuration to be passed to the plugin in the form of a configuration entity (which we have to create next). Without such an entity, we want the plugins to fail because they cannot work without the instructions found in this entity. So, in the constructor, we check whether $configuration['config'] is an instance of Drupal\products\Entity\ImporterInterface, which will be the interface our configuration entity will implement. Otherwise, we throw the exception because this plugin cannot work without it.
Our plugin type is complete for now. Obviously, we don't have any plugins yet, and before we create one, let's create the configuration entity type first.