Extending MVC on Drupal
Related articles: Building a site on Drupal using MVC and Drupal – nice ideas and bad coding
I’ve already described an approach of using MVC in Drupal . It wasn’t bad for the beginning I hope. Though in real life you face requirements which hardly can be fulfilled with such a simplicity. In, more or less, complex application you ain’t going to be satisfied by the only controller (page controller) to serve the whole bunch of components presented on the page. So, let’s assume we have a page full of components and each of them can behave differently. It means we need controllers and views for them. Reminds of Java portlets, doesn’t it? Such components are often referred as modules in IA, but I don’t dare to call them so in this context to avoid any confusion because of Drupal modules. So, let’s go with widgets.
So, when having widgets on a page, dispatched separately, we can interact directly with them. Let’s say, user accesses a page in browser and during page generation all the widget controllers invoked, widget views rendered. Then user via pagination bar requests the next page of the list displayed on a widget. AJAX-request comes on the same widget controller and the same widget view is being rendered in the response. It’s wonderful, we have no duplication of code and excellent re-usability.
Drupal though, as I mentioned before, can be associated with PAC paradigm. They also have sort of abstractions spread on Drupal modules and each of them contains control and presentation layer. One abstraction can affect others though its parent. Putting it in other words, modules have handler function called when they are invoked. Those can update global and local environment. They can render their output using defined theme. For example, contact form module attaches the form to content abstraction. It renders the form using form theme. When the form submitted the module handler is called to send out submitted data. When you request the page, you see at the content abstraction placeholder contact form of a particular state (it can contain validation messages or send processing results). Other module Contact form blocks updates abstraction of a specified block. The block can be placed in a region (header, left sidebar, right sidebar and so on). As you see, described widgets can be achieved by Drupal’s PAC. But I would rather do not, unless you are ok with checking all the modules, wondering which one responded to the request and walking though theme templates on every level of theming.
What I’m proposing once again is hooking into page module (phptemplate_preprocess_page of /sites/default/themeName/template.php) with MVC dispatcher. That is the very point where Drupal is fully bootstrapped and is about to render requested page based on template suggestions.
function phptemplate_preprocess_page(%26$vars)
{
// Every details page can be caught by template page-<ContentType>-details
if ($vars['node'] %26%26 $vars['node']->type != 'page') {
$vars['template_files'][] = "page-" . $vars['node']->type . "-details";
}
$vars["view"] = new View_View($vars);
$app = new Lib_Application();
$app->run($vars["view"]);
}
Since we call here for View_View class, it is expected autoloader is enabled. Keeping in mind I would like it as here as well as in Drupal modules there very place to include it would be in configuration file (sites/default/settings.php)
include_once 'mvcBootstrap.php';
sites/default/mvcBootstrap.php:
<?php
define("THEME_PATH", realpath(dirname(__FILE__) . "/"));
// Path to your front-end theme
define("TPL_PATH", realpath(dirname(__FILE__) . "/themes/garland"));
if (!class_exists('Lib_Autoloader')) {
include THEME_PATH . "/Lib/Autoloader.php";
new Lib_Autoloader();
}
View_View class I described in previous article and it didn’t change much since that. Just dispatch method was added:
/**
* Dispath and render requested application
*
* @param string $request
*/
public function dispatch($request)
{
$module = new Lib_Application();
$widget = $module->run($this, $request);
// Local namespace
$view = $this;
include TPL_PATH . "/" . $request . ".phtml";
}
Though I’ve rewritten dispatcher from the scratch. Ok, let see what we want from the dispatcher. We have 2 use cases:
- Drupal provides us with template suggestions, which should be mapped to controllers
- We making a request to be dispatched directly from a template (for a widget) So, in the first case we have array of suggestions like page-front or page-news-details and we map them to the controllers: page-front -> Controller_PageFront::indexAction and page-news-details -> Controller_PageNews::detailsAction. When template name contains more than 2 parts, the last part goes to the action method name, others to the controller class name. Otherwise, all 2 parts make the class name, and default action method (indexMethod) is used.
When we dispatch a widget controller from View, we request something like widget/example-of-list. It is mapped to Controller_Widget::exampleOfListAction.
Here is the code of Lib/Application.php:
<?php
class Lib_Application
{
const TPL_DELIMITER = "-";
const URI_PART_DELIMITER = '-';
const URI_DELIMITER = '/';
const CLASS_NAME_DELIMITER = "_";
const CLASS_WRAPPER_TPL = "Controller_%s";
const METHOD_WRAPPER_TPL = "%sAction";
/**
* Helper to wrap method name
* @param string $name
* @return string
*/
private function _wrapMethod($name)
{
return sprintf(self::METHOD_WRAPPER_TPL, $name);
}
/**
* Helper to wrap class name
* @param string $name
* @return string
*/
private function _wrapClass($name)
{
return sprintf(self::CLASS_WRAPPER_TPL, $name);
}
/**
* Helps to tranform array(part, part, part) into array(Part, Part, Part)
* or array(part-part, part, part) into array(PartPart, Part, Part)
* @param mixed $item
* @param int $index
* @param boolean $ommitFirst user parameter
*/
static private function _ucFirstCb(%26$item, $index)
{
$item = ucfirst($item);
// Now check for array(part-part, part, part)
if (strpos($item, self::URI_PART_DELIMITER) !== false) {
$parts = explode(self::URI_PART_DELIMITER, $item);
array_walk($parts, array("Lib_Application", "_ucFirstCb"));
$item = implode("", $parts);
}
}
/**
* The router is responsible for mapping
* the template suggestions to a controllers and actions
* .
* @param array $templates
* @return array of Lib_Dispatcher
*/
public function routeTemplates(array $templates)
{
$res = array();
if (!$templates) {
throw new Lib_Application_Exception("There is nothing to route");
}
foreach ($templates as $template) {
$parts = explode(self::TPL_DELIMITER, $template);
if (($parts[0] == 'page' %26%26 count($parts) > 2) // requests like page-news-view
|| ($parts[0] != 'page' %26%26 count($parts) > 1)) { // AJAX requests like widget-some..
$action = array_pop($parts);
} else {
// Action not defined, thus the default action is used
$action = "index";
}
array_walk($parts, array("Lib_Application", "_ucFirstCb"));
$res[] = new Lib_Dispatcher(
$this->_wrapClass(implode("", $parts))
, $this->_wrapMethod($action));
}
return $res;
}
/**
* The router is responsible for mapping
* the request to a controller and action
*
* @param string $request
* @return Lib_Dispatcher
*/
public function routeRequest($request)
{
$parts = explode(self::URI_DELIMITER, $request);
array_walk($parts, array("Lib_Application", "_ucFirstCb"));
$action = array_pop($parts);
$action[0] = strtolower($action[0]);
return new Lib_Dispatcher(
$this->_wrapClass(implode(self::CLASS_NAME_DELIMITER, $parts))
, $this->_wrapMethod($action));
}
/**
* Runs application: either page or requsted module
*
* @param View_View $view
* @param string $request
* @param mixed $params
* @return stdObject
*/
public function run(%26$view, $request = null, $params = null)
{
if ($request == null) {
$map = $this->routeTemplates($view->template_files);
foreach ($map as $call) {
$call->dispatch($view, $params);
}
} else {
return $this->routeRequest($request)->dispatch($view, $params);
}
return null;
}
}
Lib/Dispatcher.php:
<?php
class Lib_Dispatcher
{
const DEFAULT_ACTION_METHOD = 'indexAction';
public $class;
public $method;
/**
*
* @param string $className
* @param string $methodName
*/
public function __construct($className, $methodName)
{
$this->class = $className;
$this->method = $methodName;
}
/**
* Dispatch to a controller/action
*
* @param View_View $view
* @param mixed $params
* @return stdObject
*/
public function dispatch(%26$view, $params = null)
{
$className = $this->class;
$methodName = $this->method;
if (class_exists($className)) {
$ctrl = new $className($view, $params);
if (method_exists($ctrl, $methodName)) {
return $ctrl->$methodName();
} elseif (method_exists($ctrl, self::DEFAULT_ACTION_METHOD)) {
return $ctrl->indexAction();
}
}
}
}
Now we can create controller for pages like Controller/PageFront.php and controllers for widgets like Controller/Widget.php
Page controllers assign data to the View object
$model = new Model_Test();
$this->view->test = $model->get();
They are accessible in templates like
<?= $view->test ?>
Widget controllers just return local widget data and it is accessible in templates via $widget container.
sites/default/themes/garland/widget/example-of-list.phtml:
<ul>
<? foreach ($widget as $item) { ?>
<li><?=$item ?></li>
<? } ?>
</ul>
Now you can take this package files bearing the MVC implementation described here. Try it on your Drupal instance. I hope, it will help you in your further Drupal development as it helps to me.