I have a custom entity type implemented, this is essentially identical to what the Model module provides, with extra integrations.

I'm wanting to use panels and page manager to template my entity pages, much like how page manager provides the node_view which overrides the node/%node path, I want to override my custom entity paths.

What ctools plugins and hooks do I need to implement to gain this functionality?

1 Answer 1


We can achieve this quite easily by using node_view as a guide. First though you must implement hook_ctools_plugin_directory() in your module file to register the ctools plugins:

 * Implements hook_ctools_plugin_directory().
function my_entity_ctools_plugin_directory($owner, $plugin_type) {
  if ($owner == 'page_manager' || $owner == 'ctools' || $owner == 'panels') {
    switch ($plugin_type) {
      case 'tasks':
      case 'content_types':
          return 'plugins/' . $plugin_type;

This tells ctools that we are implementing plugins under the plugins directory inside our module folder and the plugins will be in their own directories. Since I am only implementing "tasks" I want to only return for that plugin type. The "task" plugin type is what we will use to implement our custom entity view.

After implementing this we just have to create the .inc file to implement our task. The file must be named name_of_plugin.inc, so for this we will use "my_entity_view.inc"

And here's the code for that, note that function names are made up of the following keys: module_name_plugin_name_hook(). I found it quite easy to follow this code, the hook_my_entity_task_tasks() is what all the other callbacks hang off. After implementing this, clear cache and your override should appear in /admin/structure/pages


 * @file
 * Handle the 'my_entity view' override task.
 * This plugin overrides my_entity/%my_entity and reroutes it to the page manager, where
 * a list of tasks can be used to service this request based upon criteria
 * supplied by access plugins.

 * Specialized implementation of hook_my_entity_task_tasks(). See api-task.html for
 * more information.
function my_entity_my_entity_view_page_manager_tasks() {
  return array(
    // This is a 'page' task and will fall under the page admin UI
    'task type' => 'page',

    'title' => t('my_entity template'),

    'admin title' => t('my_entity template'),
    'admin description' => t('When enabled, this overrides the default Drupal behavior for displaying my_entitys at <em>my_entity/%my_entity</em>. If you add variants, you may use selection criteria such as my_entity type or language or user access to provide different views of my_entitys. If no variant is selected, the default Drupal my_entity view will be used. This page only affects my_entitys viewed as pages, it will not affect my_entitys viewed in lists or at other locations. Also please note that if you are using pathauto, aliases may make a my_entity to be somewhere else, but as far as Drupal is concerned, they are still at my_entity/%my_entity.'),
    'admin path' => 'my_entity/%my_entity',

    // Menu hooks so that we can alter the my_entity/%my_entity menu entry to point to us.
    'hook menu' => 'my_entity_my_entity_view_menu',
    'hook menu alter' => 'my_entity_my_entity_view_menu_alter',

    // This is task uses 'context' handlers and must implement these to give the
    // handler data it needs.
    'handler type' => 'context',
    'get arguments' => 'my_entity_my_entity_view_get_arguments',
    'get context placeholders' => 'my_entity_my_entity_view_get_contexts',

    // Allow this to be enabled or disabled:
    'disabled' => variable_get('my_entity_my_entity_view_disabled', TRUE),
    'enable callback' => 'my_entity_my_entity_view_enable',
    'access callback' => 'my_entity_my_entity_view_access_check',

 * Callback defined by my_entity_my_entity_view_my_entity_tasks().
 * Alter the my_entity view input so that my_entity view comes to us rather than the
 * normal my_entity view process.
function my_entity_my_entity_view_menu_alter(&$items, $task) {
  if (variable_get('my_entity_my_entity_view_disabled', TRUE)) {

  // Override the my_entity view handler for our purpose.
  $callback = $items['my_entity/%my_entity']['page callback'];
  if ($callback == 'my_entity_page_view' || variable_get('my_entity_override_anyway', FALSE)) {
    $items['my_entity/%my_entity']['page callback'] = 'my_entity_my_entity_view_page';
    $items['my_entity/%my_entity']['file path'] = $task['path'];
    $items['my_entity/%my_entity']['file'] = $task['file'];
  else {
    // automatically disable this task if it cannot be enabled.
    variable_set('my_entity_my_entity_view_disabled', TRUE);
    if (!empty($GLOBALS['my_entity_enabling_my_entity_view'])) {
      drupal_set_message(t('Page manager module is unable to enable my_entity/%my_entity because some other module already has overridden with %callback.', array('%callback' => $callback)), 'error');

  // @todo override my_entity revision handler as well?

 * Entry point for our overridden my_entity view.
 * This function asks its assigned handlers who, if anyone, would like
 * to run with it. If no one does, it passes through to Drupal core's
 * my_entity view, which is my_entity_page_view().
function my_entity_my_entity_view_page($my_entity) {
  // Load my task plugin
  $task = page_manager_get_task('my_entity_view');

  // Load the my_entity into a context.

  // We need to mimic Drupal's behavior of setting the my_entity title here.
  $uri = entity_uri('my_entity', $my_entity);
  // Set the my_entity path as the canonical URL to prevent duplicate content.
  drupal_add_html_head_link(array('rel' => 'canonical', 'href' => url($uri['path'], $uri['options'])), TRUE);
  // Set the non-aliased path as a default shortlink.
  drupal_add_html_head_link(array('rel' => 'shortlink', 'href' => url($uri['path'], array_merge($uri['options'], array('alias' => TRUE)))), TRUE);
  $contexts = ctools_context_handler_get_task_contexts($task, '', array($my_entity));

  $output = ctools_context_handler_render($task, '', $contexts, array($my_entity->id));
  if ($output != FALSE) {
    return $output;

  $function = 'my_entity_page_view';
  foreach (module_implements('my_entity_override') as $module) {
    $call = $module . '_my_entity_override';
    if (($rc = $call('my_entity_view')) && function_exists($rc)) {
      $function = $rc;

  // Otherwise, fall back.
  return $function($my_entity);

 * Callback to get arguments provided by this task handler.
 * Since this is the my_entity view and there is no UI on the arguments, we
 * create dummy arguments that contain the needed data.
function my_entity_my_entity_view_get_arguments($task, $subtask_id) {
  return array(
      'keyword' => 'my_entity',
      'identifier' => t('my_entity being viewed'),
      'id' => 1,
      'name' => 'entity_id:my_entity',
      'settings' => array(),

 * Callback to get context placeholders provided by this handler.
function my_entity_my_entity_view_get_contexts($task, $subtask_id) {
  return ctools_context_get_placeholders_from_argument(my_entity_my_entity_view_get_arguments($task, $subtask_id));

 * Callback to enable/disable the page from the UI.
function my_entity_my_entity_view_enable($cache, $status) {
  variable_set('my_entity_my_entity_view_disabled', $status);

  // Set a global flag so that the menu routine knows it needs
  // to set a message if enabling cannot be done.
  if (!$status) {
    $GLOBALS['my_entity_enabling_my_entity_view'] = TRUE;

 * Callback to determine if a page is accessible.
 * @param $task
 *   The task plugin.
 * @param $subtask_id
 *   The subtask id
 * @param $contexts
 *   The contexts loaded for the task.
 * @return
 *   TRUE if the current user can access the page.
function my_entity_my_entity_view_access_check($task, $subtask_id, $contexts) {
  $context = reset($contexts);
  return my_entity_access('view', $context->data);
  • Looks like your hook_ctools_plugin_directory() will not see the tasks folder, may amend that. Commented Mar 17, 2014 at 23:38
  • @JasonSmith cheers, must have been a copy paste error :) Commented Mar 18, 2014 at 3:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.