CTools plugins system

When we are writing our own module we, as good developers, should allow other people extend/modify some parts of it. Yes we are talking about defining our own module's hooks. But what we can do if we need to "extend" our module in several places but we should be sured that other module that implements one hook should also implement another one? What we should do if we have a lot of such cases and we should take care about consistency of implementations of other modules? Also sometimes we would like user to decide what implementation to run (so we want some kind of administration page where we select what extention to be active).

One of the solutions for such situation is to define ctools plugins as the way to extend our module's functionality. In this article I would like to explain how to do this and how to take care about consistency.

First of all of course we need to have ctools as dependency. But truly to say it should not be a problem as nowadays it is nearly a must dependency for every project.

For the practical example we will take very simple task -- we write a form that calculates different operations with two numbers. Every operation should be implemented as plugin as we need it to do several tasks:

1. Validate input
2. Calculate operation
3. Display nice message with result

So lets see how we can define our "operation" plugin and how we should use it in our code.

To define the plugin we should implement hook_ctools_plugin_type.

<?php
/**
 * Implements hook_ctools_plugin_type().
 *
 * Has plenty options. See ctools/help/plugins-creating.html
 */
function example_ctools_plugin_type() {
  return array(
    'operation' => array(
      'use hooks' => TRUE,
    ),
  );
}
?>

This hook has a lot of various options. In order not to rewrite help document I would recommend to look at it when you will decide to create your own plugins.

Now lets see the code that uses plugins (main module).

<?php
/**
 * Form constructor for Calculations demo.
 */
function example_calculation($form, $form_state) {
  // Load all plugins type "operation".
  ctools_include('plugins');
  $operations = ctools_get_plugins('example', 'operation');
  $operation_options = array();
 
  foreach ($operations as $id => $operation) {
    $operation_options[$id] = $operation['label'];
  }
 
  if (empty($operation_options)) {
    $form['message'] = array(
      '#markup' => t('Sorry no operation plugins available in the system.'),
    );
    return $form;
  }
 
  $form['operations'] = array(
    '#type' => 'checkboxes',
    '#title' => t('Please choose Operations'),
    '#options' => $operation_options,
  );
 
  // Form elements...
 
  return $form;
}
?>

Our form has checkboxes of operations that are available in the system, two textfields for numbers and submit button. Every operation plugin has 'label' property that is shown as label of the checkbox.

This is how we validate the form:

<?php
/**
 * Validate handler.
 */
function example_calculation_validate($form, &$form_state) {
  $fv = $form_state['values'];
  $operations = array_filter($fv['operations']);
 
  foreach ($operations as $operation) {
    if ($instance = _example_get_instance($operation, $fv['number_a'], $fv['number_b'])) {
      $instance->validate();
    }
  }
}
?>

Here I would like to explain a bit more in details. Every plugin defines a class that perform main job for us: validate, calculate and show the message. Every class should inherit abstract class "example_operation" that allows us to ensure that plugin class is consistent.

Here how _example_get_instance() works:

<?php
function _example_get_instance($id, $number_a = NULL, $number_b = NULL) {
  $instances = &drupal_static(__FUNCTION__);
 
  if (!isset($instances[$id])) {
    ctools_include('plugins');
    $plugin = ctools_get_plugins('example', 'operation', $id);
    $class = ctools_plugin_get_class($plugin, 'handler');
    $instances[$id] = new $class($number_a, $number_b);
 
    // Check that plugin class has ingerited proper 'example_operation' class.
    if (!is_subclass_of($instances[$id], 'example_operation')) {
      $instances[$id] = NULL;
    }
  }
 
  return $instances[$id];
}
?>

So here we explicitly check whether plugin's class is inherited from our example_operation class and if not, we just don't return object.

Now lets take final look at our form processing -- submit handler:

<?php
function example_calculation_submit($form, &$form_state) {
  $fv = $form_state['values'];
  $operations = array_filter($fv['operations']);
 
  foreach ($operations as $operation) {
    if ($instance = _example_get_instance($operation, $fv['number_a'], $fv['number_b'])) {
      drupal_set_message($instance->resultMessage());
    }
  }
}
?>

This is very nice, but how we should implement plugins in our modules? There are two ways to do that. First is to implement hook_MODULE_PLUGIN. CTools automatically creates this hook for every plugin defined (there is an option to not accept hook implementation of plugins). In our case this is hook_example_operation.

<?php
function multiple_divide_example_operation() {
  return array(
    'multiple' => array(
      'label' => t('Multiple'),
      'handler' => array(
        'class' => 'example_multiple_operation',
      ),
    ),
    'divide' => array(
      'label' => t('Divide'),
      'handler' => array(
        'class' => 'example_divide_operation',
      ),
    ),
  );
}
?>

Here we implement two operation plugins: provide label and handler class (we will come back on classes a bit later).

Another way to implement plugin is to define folder where ctools should look for files that are plugin implementations (hook_ctools_plugin_directory):

<?php
/**
 * Implements hook_ctools_plugin_directory().
 */
function sum_ctools_plugin_directory($module, $plugin) {
  if (($module == 'example') && ($plugin == 'operation')) {
    return 'plugins/operation';
  }
}
?>

This means that module sum tells ctools to look at plugins/operation folder for plugins implementation. Here is file that will be found:

<?php
/**
 * Operation plugin for Example module.
 *
 * Calculate sum of two numbers.
 */
 
$plugin = array(
  'label' => t('Sum'),
  'handler' => array(
    'class' => 'example_sum_operation',
  ),
);
 
class example_sum_operation extends example_operation {
  public function calculate() {
    return $this->a + $this->b;
  }
}
?>

So in case of file implementation we should provide $plugin variable as array of properties. Of course best to do it in the beginning of the file.

These two ways to implement plugins are very convenient and used. For example panels uses file based plugins, but feeds module recommends implementation via hook. I would say that if you expect one module to provide a lot of plugin implementations it is better to have them as separate files as hook implementation array will really look too big. But it is up to you how to do this.

Now lets come back to classes. You already see that in case of sum operation class just implements method calculate().

This is how abstract class looks like:

<?php
abstract class example_operation {
  // Numbers we make calculations on.
  protected $a;
  protected $b;
 
  /**
   * Save arguments locally.
   */
  function __construct($a = 0, $b = 0) {
    $this->a = $a;
    $this->b = $b;
  }
 
  /**
   * Validate arguments. Return error message if validation failed.
   */
  public function validate() {}
 
  /**
   * Main operation. Calculate operation and return the result.
   */
  public function calculate() {}
 
  /**
   * Return result string for the operation.
   */
  public function resultMessage() {
    return t('Result of !operation with arguments !argument_a and !argument_b is !result.', array(
      '!operation' => get_class($this),
      '!argument_a' => $this->a,
      '!argument_b' => $this->b,
      '!result' => $this->calculate(),
    ));
  }
}
?>

So in order to provide operation that will work we really just need to inherit this abstract class and have calculate method in place (this is how "sum" operation is implemented).

For divide operation we have also implemented validate and resultMessage methods:

<?php
class example_divide_operation extends example_operation {
  public function validate() {
    if (empty($this->b)) {
      form_set_error('number_b', t('Can\'t divide by zero!'));
    }
  }
 
  public function calculate() {
    return $this->a / $this->b;
  }
 
  public function resultMessage() {
    return t('!argument_a divided by !argument_b is !result.', array(
      '!argument_a' => $this->a,
      '!argument_b' => $this->b,
      '!result' => $this->calculate(),
    ));
  }
}
?>

For multiplication operation we implemented only calculate method but haven't inherited class from our base abstract class to ensure it won't be working:

<?php
class example_multiple_operation {
  public function calculate() {
    return $this->a * $this->b;
  }
 
  public function resultMessage() {
    return t('Multiply !argument_a on !argument_b is !result.', array(
      '!argument_a' => $this->a,
      '!argument_b' => $this->b,
      '!result' => $this->calculate(),
    ));
  }
}
?>

One of the most important thing about defining your own plugins system is documentation. As it can be very hard for other developers to get themself familiar how plugin works and what it should implement in order to work. In this case defining plugins as classes that should inherit some abstract class is quite convenient as we can put our documentation in comments about methods of abstract class.

I am sure that this artificial example can be implemented easier but I hope it made clearer for you how to define your own ctools plugins and use them. A lot of modules use this system and surely you will need to write your plugins implementation for other modules.

Example module is attached.

This article is based on my presentation during DrupalCamp Donetsk 2011. Slides are available on http://www.slideshare.net/ygerasimov/drupal-camp-donetsk-c-tools

Comments

very nice, thank you. Really looking forward to more ctools stuff :-)

I clicked on this yesterday... and today I ran into a problem that I needed to use it for, and it ended up only taking 15 minutes instead of hours. Thanks for sharing!