Skip to main content

Drupal 8 Now: Object Oriented plugins in Drupal 7

Drupal 8's plugins system is the bees-knees, taking the lessons learnt from Drupal 7 and Ctools plugins.

But we want it now right?

So how can you write plugins for Drupal 7's ctools module that will be a breeze to update to Drupal 8?

Read on to find out more in this latest post in our Drupal 8 now series.

by lee.rowlands /

Drupal 8's plugin system

We've blogged and spoken at length about why we love Drupal 8's plugin system so much, but in case you missed all that some of the biggest wins are:

  • Object inheritance
  • Object interfaces
  • Single file encapsulation

So obviously there is no annotation system in Drupal 7's ctools but that doesn't mean you can't have all the goodness of first-class plugin objects in Drupal 7.

Laying the foundation

First you have to declare your plugin type. In Drupal 8 this entails creating a plugin manager service. In Drupal 7 this means implementing hook_ctools_plugin_type().

Continuing the robot examples from our previous articles, in this instance we're going to assume that our module defines a number of plugin-types that other modules can add to our robot. Lets pretend there's and emotion plugin type and and action plugin type. Our hook_ctools_plugin_type() for that use case would look like this:

/**
 * Implements hook_ctools_plugin_type().
 */
function robot_ctools_plugin_type() {
  $plugins['Robot\Emotion'] = array(
    'classes' => array('class'),
    'cache' => TRUE,
  );
  $plugins['Robot\Action'] = array(
    'classes' => array('class'),
    'cache' => TRUE,
  );
  return $plugins;
}

So we've defined two plugin types and we've used the Robot namespace to group them. The main reason for this will become clear soon.

Setting some basepoints

Interfaces

In order to make writing Emotion and Action plugins simple, we will create an EmotionInterface and an ActionInterface. Now because we're going to write this code as PSR-0 (PSR-4 would be fine too), these will be Drupal\robot\EmotionInterface and Drupal\robot\ActionInterface. And because they have several things in common, we might also write Drupal\robot\PluginInterface and place the common methods in there, having EmotionInterface and ActionInterface extend from the common PluginInterface

In these we'll create a contract for any implementing plugins, so that all of our code that interacts with these plugins. So our common plugin interface might look something like this:

namespace Drupal\robot;

/**
 * Defines an interface for plugins defining robot plugins.
 */
interface PluginInterface {

  /**
   * Sets the plugin configuration.
   *
   * @param array $configuration
   *   (optional) The plugin configuration. If an empty array is passed the
   *   plugin should set its own default configuration.
   */
  public function setConfiguration(array $configuration = array());

  /**
   * Gets the plugin configuration.
   *
   * @param string $key
   *   (Optional) The configuration key. Defaults to NULL. If not provided,
   *   should return all configuration values.
   *
   * @return array|mixed
   *   The configuration value or values.
   */
  public function getConfiguration($key = NULL);

  /**
   * Returns the plugin id.
   *
   * @return string
   *   The plugin id.
   */
  public function getId();

  /**
   * Returns the plugin title.
   *
   * @return string
   *   The plugin title.
   */
  public function getTitle();

  /**
   * Returns the settings form.
   *
   * @param array $form
   *   The entire form.
   * @param array $form_state
   *   The form state.
   *
   * @return array
   *   Form API array for the settings page.
   */
  public function settingsForm(&$form, &$form_state);

  /**
   * React to the settings form submission.
   *
   * @param array $form
   *   The entire form.
   * @param array $form_state
   *   The form state.
   */
  public function settingsFormSubmit(&$form, &$form_state);

  /**
   * Creates an instance of this plugin.
   *
   * @param string $id
   *   The plugin id.
   * @param array $configuration
   *   The plugin configuration.
   *
   * @return \Drupal\robot\PluginInterface
   *   An object implementing Drupal\robot\PluginInterface.
   */
  public static function createInstance($id, array $configuration);

}

You'll note we've also got a factory method in there - more on that later. So then lets say that ActionInterface has one more method like so:

namespace Drupal\robot;

/**
 * Defines an interface for plugins defining robot actions.
 */
interface ActionInterface extends PluginInterface {

  /**
   * Executes and action.
   *
   * @return bool
   *   The result of the action.
   */
  public function performAction();

}

Similarly the EmotionInterface might add another method

namespace Drupal\robot;

/**
 * Defines an interface for plugins defining robot emotions.
 */
interface EmotionInterface extends PluginInterface {

  /**
   * Makes the robot feel the given emotion plugin.
   *
   * @return string
   *   The result of feeling the emotion.
   */
  public function feel();

}

Base classes

So a lot of the elements of our interface will most likely be identical between the plugins, so it makes sense to have a base class providing the guts of the interface. For the interface above this might look like so

namespace Drupal\robot;

/**
 * A base plugin for robot plugins.
 */
abstract class PluginBase {

  /**
   * The plugin id.
   *
   * @var string
   */
  protected $id;

  /**
   * The plugin title.
   *
   * @var string
   */
  protected $title;

  /**
   * The plugin configuration.
   *
   * @var array
   */
  protected $configuration = array();

  /**
   * Default configuration.
   *
   * @var array
   */
  protected $defaultConfiguration = array(
    'weight' => 0,
  );

  /**
   * Constructs the robot plugin object.
   *
   * @param string $id
   *   The plugin id.
   * @param array $configuration
   *   (optional) The plugin configuration.
   */
  public function __construct($id, array $configuration = array()) {
    $this->id = $id;
    $this->setConfiguration($configuration);
  }

  /**
   * {@inheritdoc}
   */
  public static function createInstance($id, array $configuration) {
    return new static($id, $configuration);
  }

  /**
   * {@inheritdoc}
   */
  public function setConfiguration(array $configuration = array()) {
    if (empty($configuration)) {
      $this->configuration = $this->defaultConfiguration;
    }
    else {
      $this->configuration = $configuration;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getConfiguration($key = NULL) {
    if ($key) {
      if (isset($this->configuration[$key])) {
        return $this->configuration[$key];
      }
      return isset($this->defaultConfiguration[$key]) ? $this->defaultConfiguration[$key] : FALSE;
    }
    return $this->configuration;
  }

  /**
   * {@inheritdoc}
   */
  public function getId() {
    return $this->id;
  }

  /**
   * {@inheritdoc}
   */
  public function getTitle() {
    return $this->title;
  }

  /**
   * {@inheritdoc}
   */
  public function settingsFormSubmit(&$form, &$form_state) {
    // Empty definition by default.
  }

}

So now we're starting to form a pretty solid set of base classes and interfaces. Now whilst these deal with a hypothetical robot situation, if you look back at the code, the base class and interface are essentially generic. You can re-use these for any plugin implementation in Drupal 7. Only the two robot interfaces hold any specific code. So while the above might look verbose - you could essentially borrow nearly all of it from a previous implementation.

Defining plugins

So now we've got some nice glue code in place, how does a module define an implementation of one of these plugin types?

Declaring the plugin directory

Now because we've got the concept of namespaces in our plugin-types (the \ between Robot and the plugin type), our hook_ctools_plugin_directory() implementation needs to be a bit more creative, but not drastically so. Let's pretend we're creating a module called bad_robot that defines some bad emotions and actions.

/**
 * Implements hook_ctools_plugin_directory().
 */
function bad_robot_ctools_plugin_directory($module, $type) {
  if ($module == 'robot') {
    return 'lib/Drupal/bad_robot/Plugin/' . str_replace("\\", DIRECTORY_SEPARATOR, $type);
  }
}

So what this does is map our plugin directory to a PSR-0 compliant folder. So our plugins for robot actions will live in modules/bad_robot/lib/Drupal/bad_robot/Plugin/Robot/Action/* and our emotions will live in modules/bad_robot/lib/Drupal/bad_robot/Plugin/Robot/Emotion/*.

Declaring our plugins

So now that we've told ctools where to look for our plugins, how do we actually declare them. Unfortunately we don't have access to annotations like in Drupal 8, so we do need a second file to hold the definition.

So lets say bad_robot module defines one emotions 'Hate' and two actions 'Crush' and 'Kill'. So in that case our file structure will look like this:

So our actual plugin classes are Kill.php, Crush.php, Hate.php etc but we need a small .inc file to tell ctools where to find these plugin objects. Take bad_robot_action_kill.inc - which would contain the following:

/**
 * Defines a kill plugin.
 */
$plugin = array(
  'class' => '\Drupal\bad_robot\Plugin\Robot\Action\Kill',
);

Note the file name is the plugin id (bad_robot_action_kill) and the $plugin definition just defines the class to use.

Creating plugin instances

So we have our defintions and our interfaces etc. What we need now is to actually instantiate our plugins. Again borrowing from Drupal 8 we helpfully added a public factory method to our interface. In Drupal 8, many plugins are tied to config entities via the notion of a PluginBag. A PluginBag is essentially a property on a config entity that handles creating an instance of a plugin based on some configuration stored in a config entity. In Drupal 7 we don't have these exact concepts but we have something very similar - ctools exportables. So to store the configuration of a plugin, we'd define a ctools exportable, using an object (of course) and on that object we'd have properties for storing the plugin configuration. The process of setting up a ctools exportable for this use-case is left as an exercise to the reader, but there is some example code in the Google DFP module, which is built using the principals of this Drupal 8 Now series.

So our robot example that might look something like this:

namespace Drupal\robot;

/**
 * A class for defining a robot exportable.
 */
class Robot implements RobotInterface {

  /**
   * The emotion plugins configuration.
   *
   * @var array
   */
  public $emotions = array();

  /**
   * The action plugins configuration.
   *
   * @var array
   */
  public $actions = array();

  /**
   * Constructs a robot object.
   *
   * @param object|NULL $config
   *   Configuration.
   */
  public function __construct($config = NULL) {
    // Init object values, which could be serialized in the database.
    $unserialize = array('actions', 'emotions');
    if ($config) {
      foreach (get_object_vars($config) as $key => $value) {
        if (in_array($key, $unserialize) && !is_array($value)) {
          $value = unserialize($value);
        }
        $this->{$key} = $value;
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getEmotionInstances($enabled_only = FALSE) {
    return $this->getPluginInstances('emotions', 'Robot\Emotion', $enabled_only);

  }

  /**
   * {@inheritdoc}
   */
  public function getActionInstances($enabled_only = FALSE) {
    return $this->getPluginInstances('actions', 'Robot\Action', $enabled_only);
  }

  /**
   * Gets plugins instances.
   *
   * @param string $property
   *   Property to load the plugins from.
   * @param string $type
   *   Plugin type.
   * @param bool $enabled_only
   *   (optional) TRUE to return only enabled plugins. Defaults to FALSE.
   *
   * @return \Drupal\robot\PluginInterface[]
   *   An array of \Drupal\robot\PluginInterface objects.
   */
  protected function getPluginInstances($property, $type, $enabled_only = FALSE) {
    $instances = array();
    foreach ($this->getPlugins($type) as $plugin_id => $plugin) {
      $class = $plugin['class'];
      if (!empty($this->{$property}[$plugin_id])) {
        $config = $this->{$property}[$plugin_id];
        $instance = $class::createInstance($plugin_id, $config);
      }
      elseif (!$enabled_only) {
        $instance = $class::createInstance($plugin_id, array('weight' => 100));
      }
      else {
        continue;
      }
      $instances[] = $instance;
    }
    return $instances;
  }

  /**
   * Load the ctools plugins.
   *
   * @param string $type
   *   The plugin type.
   *
   * @return array
   *   An array of ctools plugin items.
   */
  protected function getPlugins($type) {
    ctools_include('plugins');
    return ctools_get_plugins('robot', $type);
  }

}

So with your ctools exportable in place (not shown) you can then do something like this

// Barry is a robot exportable.
$barry = robot_load('barry');
// Make Barry feel something.
foreach ($barry->getEmotionInstances() as $emotion) {
  $emotion->feel();
}

So what are the next steps?

Upgrading to Drupal 8

So when the time comes to upgrade our robot module to Drupal 8 - what do we need to change? Well the answer is not much.

We already have PSR-0 code, we already have base interfaces and plugin types. Firstly we need to remove our hook_ctools_plugin_type() declaration and replace it with a plugin manager service. Given there is a fairly solid base plugin manager class in Drupal core, this really only means extending from that and overriding the relevant parts.

The other piece of work is removing the small .inc files and replacing them with annotations. So we'd need a new annotation type for our plugins (which would largely inherit from the base Plugin annotation) and then to move the definitions to the annotation at the top of each class.

Unit testing

As you've probably guessed, writing our code in this object-oriented fashion means we can unit test a lot of our plugin code. But for that, you'll need to wait for Part 5 in our Drupal 8 series

So what do you think? Are you going to use OO plugins in Drupal 7 next time you need to declare a plugin-type?

Edit

Christopher Skene points out that we can remove the need for the .inc files by changing the hook_ctools_plugin_type() to include:

'extension' => 'php'

and then adding the $plugin definition to the top of each plugin class. So this brings us even closer to Drupal 8!