Issues

ZF-614: Controllers in subdirectories (or any different directory) / Modules

Description

I would like to touch this subject again. I think I have found a solution which should suit everybody as it's very easy to manage from a user standpoint and also easy to implement.

=============================================================================== Framework user standpoint

It's a concept of named modules. Let's work on a use case first - let's bootstrap our application with additional directories in the standard way:


require_once 'Zend/Controller/Front.php';
$controller = Zend_Controller_Front::getInstance();

$dirs = array(
    'default' => '/home/martel/WWW/test/controllers',
    'forum'   => '/home/martel/WWW/test/controllers/forum',
    'admin'   => '/home/martel/WWW/test/controllers/admin',
    'other'   => '/var/www/even/outside/root/dir'
);

$controller->setControllerDirectory($dirs);
echo $controller->dispatch();

As you see, it's an already working way of setting controller dirs. Nothing is or needs to be changed up to this point. We can access all the dirs with:

http://localhost/test/index.php/…

But what happens when you want to use a NewsController in 'default' as well s 'admin' directories? Former is used to display news to the users and latter to administer the site.

So let's assume we have an additional parameter named 'module'. And if we would like to access the admin module specifically, we would use the following URIs:

With standard URL Scheme: http://localhost/test/index.php/…

Or with RewriteRouter (route: ':module/:controller:/:action'): http://localhost/test/admin/news/add

This way we can access any directory structure by using named modules. And moreover, we can have controllers named exactly the same in different directiories - something that is not possible with current codebase.

But it's still a backwards compatible solution - we can access the controllers without relying on modules. In that case dispatcher would iterate through all the defined controller dirs like it is being done right now.

Now let's get to the implementation.

=============================================================================== Framework Code

It's really easy thing to do as setControllerDir is already setting directories by the key, so it's only a matter of modifying _getController() method of the dispatcher to be aware of modules (new code is marked by pluses):


protected function _getController($request, $directories = null, $module = null)
{
     ...

     $className = $this->formatControllerName($controllerName);

     /**
      * Determine if controller is dispatchable
      */
     $dispatchable = false;

+    if ($module !== null) {
+        $dispatchable = Zend::isReadable($directory[$module]);
+    } else {
         foreach ($directories as $directory) {
             $dispatchable = Zend::isReadable($directory ...);
             if ($dispatchable) break;
         }
+    }

     return $dispatchable ? $className : false;
}

And that's pretty much it. Of course we would still have to add formatModuleName family of methods (similar to controller and action names), an optional module parameter to addControllerDir to make it store directories under specified key and finally a delegate methods in Front Controller.

public function addControllerDirectory($path, $module = null)

Finally it would probably be a good idea to move "Determine if controller is dispatchable" block out to it's own method. It will allow for easier subclassing and will meet Rob's request:

http://nabble.com/%24_GET%2C%24_POST-and-Zend_Cont…

=============================================================================== Backwards compatibility

This implementation is fully backwards compatible but maybe it would be better to drop backwards compatibility in order to clean the code? I mean make one default module instead of directory iteration, etc. And we're already shooting our foots with parameter ordering (parameters renamed to better show what's on my mind):


public function addControllerDirectory($dir, $name)
public function addRoute($name, $route)

Comments

I like this idea a lot -- goes in line somewhat with changes I've made to the Zend_View helper and filter paths recently.

One note: you can actually reference controllers in subdirectories by using a hyphen or underscore: http://example.com/admin-news/edit will use the Admin_NewsController found in Admin/NewsController.php. However, I like the idea of specifying a module instead of using the hyphen or underscore character.

A few comments: I'd change your _getController() signature to read _getController($request, $directories = null, $module = 'default'), as I think it's more likely that there won't be a module and less likely that there won't be directories.

Additionally, I'd like to propose that, for BC purposes, if the array passed to setControllerDirectory() is indexed and not associative, the string 'default' be used as the module for each.

Otherwise, all of this makes sense to me, and gives some good flexibility.

This is actually a little more intrusive than it looks. The logic will have to account for multiple paths under the same module, and the ability to pass a module name to setControllerDirectory. Additionally, in looking through the code, I'm thinking that the paths need to be cached in the controller and passed to the dispatcher just prior to dispatching. I'm going to start re-factoring today.

Maybe it would be easier to make one directory per module?

The current code in the dispatcher loops through the array and simply normalizes the paths sent. The problem is: what if the array is non-associative?

As I think about it, though, the logic remains the same: it's basically a FIFO array unless a module is requested, in which case that module gets precedence.

I still want to do the caching in the front controller, so that the order in which items are set in the front controller doesn't become an issue (right now, if you call setControllerDirectory() prior to setting a custom dispatcher, the settings for the controller directory won't be pushed into the new dispatcher).

To answer your question (regarding non-associative array). With the current codebase the array is mixed, so you can have associative as well as numeric indexes on the directories:


$dirs = array(
    'controllers',
    'forum' => 'controllers/forum',
    'admin' => 'controllers/admin'
);

$controller->setControllerDirectory($dirs);
$controller->addControllerDirectory('controllers/default1');
$controller->addControllerDirectory('controllers/default2');

var_dump($controller->getControllerDirectory());

This code results in:


array(5) {
  [0]=>
  string(11) "controllers"
  ["forum"]=>
  string(17) "controllers/forum"
  ["admin"]=>
  string(17) "controllers/admin"
  [1]=>
  string(20) "controllers/default1"
  [2]=>
  string(20) "controllers/default2"
}

To keep backwards compatibility, I would scan all the dirs when module is not present and only one if programmer specifically and conciously chooses the module thru an URL. Alternatively, we can asume that all integer-indexed values would be in "default" module.

Or we can break compatibility and REQUIRE an associative array.

The call is yours :)

I've been implementing and testing, and I'm beginning to wonder if the module (other than 'default' or numerical module names) should be used as a prefix for the class. The reason I mention it is that without this, we can start running into naming collisions, and it becomes difficult (actually, impossible) for a class in one module to extent that in another module if they have the same name.

As it is, testing without the prefix means that I cannot reference the controller anywhere else in the test class prior to the modules tests.

I'm going to sit on the changes a bit before committing them; anybody with feedback, please send it here!

I have code working now. However... one thing not discussed is how to specify the module in the URI. Right now, by default, you can only do it with:


http://localhost/controller/action/…

I could add another default route in the RewriteRouter to match something like http://localhost/module/controller/action, but that then conflicts with any other routes with three arguments (such as :controller/:action/:id) as well as the current default route.

I'll commit the code for now (module support in dispatcher), but we'll need to figure out how and where to specify modules in the routing process. [ZF-617] addresses some of this, and I'll take the discussion to there.

Dispatcher changes (and front controller) are in subersoin as of revision 2172. There are still unanswered questions about how to handle routing, but those will be dealth with in ZF-617.