Add new comment

Making namespaced callbacks work in Drupal 7 (without hacking core and with bound parameters)

Difficulty: 
Let's Rock

What is the best way to prepare for Drupal 8 and make your projects easy (and cheap) to migrate to D8? Start using Drupal 8 programming patterns now as much as D7 allows you to....

I guess that most of you are already doing that - and have done for a few years now - with custom crafted frameworks that, as much as possible, use modern design patterns not stuck in 20 y/o spaguetty code. D7 is spaguetty, your custom modules and code need not to be so.

In example, to add a block using the Fast Development Framework for Drupal 7 you simply create a class in your module such as this one:

class BlockCourseActions extends BlockGeneric implements IBlock {
  public static function Info() {
    return \t('Acciones de curso.');
  }

  public static function Cache() {
    return DRUPAL_CACHE_PER_PAGE;
  }

  public static function Classes() {
    return array(static::className(), 'embed', 'important');
  }
  
  public static function View() {
    $block = array();
    $block['subject'] = 'My Subject';
    $block['content'] = array(); // whatever
    return $block;
  }
}

No need to implement hooks, and everything related to your block is kept in a single class instead of being spread all over the place with procedural code. This block class is going to be auto discovered by the framework and  the proper hooks will be consumed to make this a working D7 block.

One of the issues of using sane programming design patterns in D7 is that Drupal 7 core (and contrib) usually rely on string based callbacks. For example to define a menu callback you would use something like this:

function MYMODULE_menu() {
    $items = array();
    $items[static::path_CatalogoCursoReservar('%entity_object')] = array(
      'title' => 'Inscripción al curso',
      'page callback' => 'my_spaguetty_global_function',
      'access callback' => TRUE,
    );
    return $items;
}

In this case 'my_spaguetty_global_function' would be used as a callback target. Sometimes you can even pass an ".inc" file to the caller so that it will know what script to include before calling your global function. 

But what if I want to define the callback in a static method inside a class and have some autoloading magic? You could guess this will work:

function MYMODULE_menu() {
    $items = array();
    $items[static::path_CatalogoCursoReservar('%entity_object')] = array(
      'title' => 'Inscripción al curso',
      // We would not really use a literal string here... but for the sake of simplicity...
      'page callback' => '\Drupal\MYMODULE\router\router1::MyMethod',
      'access callback' => TRUE,
    );
    return $items;
}

The truth is that it works depending on how the callback is consumed.

These are (some of) the possible callback consumption patterns:

$callback = '\Drupal\MYMODULE\MYCLASS::MyCallback';

// Method 1: function_exists + invoke
// Only works with global functions
if (function_exists($callback)) {
  $result = $callback();
}

// Method 2: direct invoke
// Only works with global functions
$result = $callback();

// Method 3: call user func or call_user_func_array
// Works with namespaced global functions
call_user_func($callback, $param1, $param2);

// Method 4: call_user_func_array
// Works with namespaced global functions
call_user_func_array($callback, array($param1, $param2))

The methods that do not work fail because:

  • Static methods in classes cannot be directly invoked, and need to be called with call_user_func or call_user_func_array. Otherwise autoloading will not work.
  • function_exists only works for global functions, and is_callable should be used instead

The solution is easy:

  • Replace all direct string invocations and replace them with a call_user_func() or call_user_fun_array()
  • Replace all calls to function_exists with is_callable.

Is there a way to workaround this situation without hacking core?

Isac, one of our team members, came up with a nice proof of concept code.

Place this helper function in the .module file of your custom framework:

/**
 * Helper to call FQN Methods
 * 
 * @param string $fqn FQN of a method
 * @return CallableHelper
 */
function fdf_call_fqn($fqn, ...$args){
  $result = new CallableHelper($fqn, $args);
  return $result;
}

This is what CallableHelper looks like:

namespace Drupal\fdf\Utilities;

class CallableHelper {

  private $callable = NULL;
  private $args = array();

  /**
   * Constructor
   * @param string $callable_name
   */
  function __construct($callable_name, array $args){
    if (!is_callable($callable_name)) {
      throw new \Exception('Method cannot be used as a callback. Make sure it is static and public!');
    }
    $this->callable = $callable_name;
    $this->args = $args;
  }

  /**
   * @return mixed
   */
  public function __invoke(&...$args) {
    $result = call_user_func_array($this->callable, array_merge($this->args, $args));
    return $result;
  }
}

You can now use any static method in any class as a callback without worrying how the consuming code is doing it's job. You can even pass custom pre bound parameters for it, as long as they are serializable types (no closures!).

For example, to build a form element using FAPI with an element validation function placed in a static function you can now use this:

    $form['campusconfig']['formacion_url'] = array(
      '#type' => 'textfield',
      '#title' => t('URL Base del sitio'),
      '#size' => 60,
      '#maxlength' => 255,
      '#required' => TRUE,
      '#rules' => array('url'),
       // You'd rather use \Drupal\MYMODULE\MYCLASS::class with a custom verifier. 
      '#element_validate' => array(fdf_call_fqn('\Drupal\MYMODULE\MYCLASS::MYVALIDATIONCALLBACK')),
      '#element_validation' => array('url'),
    );

UPDATE: For major resons we had to backport this code to PHP 5.5

The only incompatible parts are the variadic arguments usage (...$args)

Most of the conversion is trivial, except for the Invoke() method in CallableHelper - in order to maintain the pass by reference behaviour.

  public function __invoke(&$p0 = NULL, &$p1 = NULL, &$p2 = NULL, &$p3 = NULL, &$p4 = NULL, &$p5 = NULL, &$p6 = NULL, &$p7 = NULL, &$p8 = NULL, &$p9 = NULL, &$p10 = NULL, &$p11 = NULL) {
    // This mess is not necessary if you use variadic arguments (...$args)
    // but we need this to be PHP 5.5 compatible. Some callbacks require
    // arguments to work by reference.
    $arguments = array();
    foreach ($this->args as &$arg) {
      $arguments[] = &$arg;
    }
    for ($x = 0; $x < func_num_args(); $x++) {
      eval("\$arguments[] = &\$p{$x};");
    }
    $result = call_user_func_array($this->callable, $arguments);
    return $result;
  }