The Drupal 8 plugin system - part 3

Checkout part 1 and part 2 for understanding the concept of plugins. In this installment, we will be

  1. Implementing a new plugin from existing plugin types.
  2. Implementing a new plugin type using the annotation based discovery mechanism.

As an exercise, let's first construct an imaginary scenario where the user of your Drupal site wants choose what they want for breakfast from a list of breakfast menu items. To add a dash of variety, all the breakfast items in the code are of South Indian culinary. You can checkout the code and suit yourself, change the breakfast items etc.

For this exercise, checkout the module code first and enable it.


$ git clone git@github.com:badri/breakfast.git

In order to select their breakfast item of choice, the user needs to be presented with a list of choices in their profile. The lame way is to create a list field in the user profile and add all the breakfast choices. This offers limited functionality and is not pluggable. We can do better than that. So, let's ahead and create a custom field called breakfast choice.

This functionality is there in the custom-field-no-plugin tag of the code you previously checked out. You can switch to that tag by:

$ git checkout -f custom-field-no-plugin

After you enable the breakfast module, go to the user profile and create a new field of type "breakfast choice". As the tag says, we haven't created any custom plugin type yet. But we do create a new plugin from the existing plugin types for our custom field. In fact, we create 3 new plugins(one each for the field type, field formatter and field widget). Our directory structure looks like this:

Custom field

All the breakfast menu items come from a single location, your custom field widget, the BreakfastWidget.php file.

  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
    $value = isset($items[$delta]->value) ? $items[$delta]->value : '';
      $options = array(
        'idly' => 'Idly',
        'dosa' => 'Dosa',
        'uppuma' => 'Uppuma',
      );

    $element = array(
      '#type' => 'select',
      '#options' => $options,
      '#default_value' => $value,
      '#multiple' => FALSE,
    );

    return array('value' => $element);
  }

Though it works, this is not a good design for 2 reasons:

  1. You are hardcoding in the presentation layer. Widgets define the way you present the input element in a form. You can't define your data there.
  2. It is not pluggable. Other developers have to open BreakfastWidget.php to add new Breakfast items.
  3. It cannot be extended. What if I want to add additional properties to my breakfast items, like images, ingradients or price? I cannot do this in the current setup.

We will address problems 1 and 2 for now. i.e., we add the ability to create new breakfast items outside of the Breakfast Widget file. We make breakfast items "pluggable". Other modules can add new breakfast items it were a plugin, which is exactly what we do next.

To get the plugin version of the module, do:

$ git checkout -f plugin-default-annotation

Now, our directory structure looks like this:

New plugin type structure

The BreakfastPluginManager is, as the name says, a service used to manage various breakfast plugins across modules. The plugin manager's constructor class deserves some explanation.

  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, LanguageManager $language_manager, ModuleHandlerInterface $module_handler) {
    $subdir = 'Plugin/Breakfast';

The $subdir tells the plugin manager where to look for Breakfast plugins in a module.

    $plugin_definition_annotation_name = 'Drupal\Component\Annotation\Plugin';

The $plugin_definition_annotation_name is the namespaced name of the annotation class which is used to annotate all Breakfast plugins. Plugin is the default annotation. We can define custom annotations as well, which will be the subject of the next installment in this series.

    parent::__construct($subdir, $namespaces, $module_handler, $plugin_definition_annotation_name);

    $this->alterInfo('breakfast_info');

    $this->setCacheBackend($cache_backend, 'breakfast_choice');
  }

alterInfo tells us that this plugin definition can be altered by implementing hook_breakfast_info_alter.

Plugin definitions are cached, which is why we need to run drush cr(the equivalent of drush cc all in D8) every time we alter the plugin definitions. The setCacheBackend defines the cache backend for our plugin. In the current context, we are not customizing it too much.

Another major change is the new file breakfast.services.yml. It contains metadata about the breakfast plugin manager service which we discussed above.

services:
  plugin.manager.breakfast:
    class: Drupal\breakfast\BreakfastPluginManager
    arguments: ['@container.namespaces', '@cache.default', '@language_manager', '@module_handler']

One or more services can be defined in the services.yml file. Each entry contains a machine name of the service, the class that implements the service and dependencies(if any) can be passed as arguments. The @ prefix for the arguments indicates that the corresponding argument is in itself a service.

The field type we added earlier hasn't changed, but the widget has been revamped. We no longer hardcode any breakfast items. Instead, we dynamically pull all plugin instances of type Breakfast.

Here's how:

    $options = array();
    $breakfast_items = \Drupal::service('plugin.manager.breakfast')->getDefinitions();
    foreach($breakfast_items as $plugin_id => $breakfast_item) {
      $options[$plugin_id] = $breakfast_item['label'];
    }

Any module can now define a new breakfast menu item and expect it to show up in the user profile's breakfast field dropdown. We've created 3 breakfast items in our module to illustrate this. Let's pick an example breakfast plugin, my favorite. Masala dosa.

masala dosa plugin

Image courtesy

err, I meant:

/**
 * Adds Masala Dosa to your Breakfast menu.
 *
 *
 * @Plugin(
 *   id = "masala_dosa",
 *   label = @Translation("Masala Dosa")
 * )
 */
class MasalaDosa extends PluginBase {
  // Nothing here yet. Just a placeholder class for a plugin
}

Nothing fancy there. Just a placeholder class and some metadata in the @Plugin annotation.

Phew! It took more time to add a masala dosa plugin that to make a masala dosa. Each breakfast item being a unique plugin instance sounds a bit like an overkill. We will address this and problem #3 detailed above(plugins having different properties like picture) in the next post!