So I've been learning CakePHP the last few days. Bit by bit I've been trying to port a lecagy admininistration app to Cake. 'Secretly' linking menuitems to finished Cake parts as we go. And I must say: I'm pretty excited. I did run into a disturbing conclusion though. I estimated the legacy app will have over 300 Models & Controllers once finished. That could easily add up to (300 x 4 =) 1200 views. And here I am, creating a maintenance hell while trying to solve one!

Most of my views are very similar. They're all about managing data in a table. Kind of like phpmyadmin but more from a business point-of-view. So I thought: Why not share views between different controllers.

Note that I'm** not **working on your average 2.0 site here so your mileage may vary. Extremely.

Don't duplicate views. Share them

This approach basically allows me to only create models & controllers, but no

views. Still, I have the flexibility to create custom views for the remaining

  • let's say - 30 special cases. I just create a view file in the same directory as I would normally: Cake would find it, and use it instead of falling back on the default view. Simple right?

To achieve that, these were my steps.

N.B. The code that I provide here are stripped versions. I don't want to bother you with my specifics.

1. Enrich Models

Added information to the models explaining what fields are important, if they have special rules. They will be interpreted by the AppController and then fed to the default views in the form of a $showFields array.

2. AppController Fallback

This is the most important. We need to tell the AppController to switch to the default view if a custom view cannot be found:

<?php
class AppController extends Controller {
    /**
     * Set up all required view data just before viewing
     */
    function beforeRender() {
        /*
         * Switch to default view if specific controller view cannot be
         * translates: app/views/dev_issues/index.ctp [NOT FOUND!]
         * to:         app/views/default/index.ctp
         *
         * Enabling you to only create views if you have very deviating ones, and have all
         * standard objects handled by a dynamic default view.
         */

        $viewPath = reset(Configure::read('viewPaths'));
        $subDir   = isset($this->subDir) && !is_null($this->subDir) ? $this->subDir . DS : null;
        $name     = $this->action;
        $name     = str_replace('/', DS, $name);
        $name     = $this->viewPath . DS . $subDir . Inflector::underscore($name);
        $path     = $viewPath . $name . $this->ext;
        if (!file_exists($path)) {
            // Change the viewPath so it will now try to load $this->action
            // from the 'default' directory
            $this->viewPath = 'default';
        }

        // Next I retrieve some Model->schema() information, enrich it,
        // and then I provide it to the view like so: $this->set('showFields', $showFields);
        // then the the 'default' views can iterate it and do some magic
    }
?>

(parts of this code were taken from the Cake core, and may need to be optimised for this situation)

3. Create Default Views

Of course we can't skip this step. I created an '/app/views/default' directory, and some common actions. Just as if default were a normal controller, and so we have the following layout:

/views
  /default
    index.ctp
    view.ctp
    add.ctp
    edit.ctp

I Added code to those views so they can dynamically iterate through any imporant Model fields they encounter in $showFields which is set by the AppController. For instance, my edit.ctp contains the following code:

<?php
// Iterate through all the fields that are interesting in UI
// the $showFields is basically a modified Model->schema(), set by the
?>

AppController

<?php
foreach ($showFields as $fieldName=>$fieldData) {
    echo $form->input($fieldName, $fieldData['formOptions']);
}

// All important fields will be automagically drawn
?>

4. Resolve Helper

To avoid adding too much logic to views, I store some logic in helpers.

For example I created the ResolveHelper, it's called from views, and will translate any field value to a more meaningful one. This is done according to rules in the Model like it's relation to parent Models.

Fat AppController

Because my Models are so similar there will be 300 Controllers, each having at least their own index, view, edit & add method:

<?php
Class DevIssuesController extends AppController {

    function index() {

    }

    function view($id = null) {

    }

    function edit($id = null) {

    }

    function add() {

    }

}
?>

Now that's a lot of duplication right there. 300 index() methods? 300 add() methods? I don't think so.

So I moved out the most basic Controller methods (index, view, and the like) to the app_controller.php file, which in fact, all other controllers are based on.

This actually behaves in the same way as shared views. Basically: AppController->index() will be called for every Controller unless you specifically set one like DevIssuesController->index(). This is just OOP nature that we take advantage of.

So basically I only add some common methods to my AppController and be done with it. For the 30 Controllers that are too exceptional to be served by these common methods, I can still write custom methods inside them. I can even call parent::index(); to still make use of the AppController logic, and expand the method with additional logic as well.

Of course these common methods will need some extra routines so they can handle the common tasks decoupled from specific models. Keys in this approach are:

  • Allow for some configuration in your Models
  • Stick to conventions

In my case, I ended up with a common edit method something like this:

<?php
function edit($id = null) {
    if (empty($this->MyModel)) {
        $this->Session->setFlash(__('Generic AppController->edit cannot be' .
            'used for this Controller. Please create a method: '.
                $this->name.'->'.__FUNCTION__.'()', true));
    }

    if (!$id && empty($this->data)) {
        $this->Session->setFlash(__('Invalid ' . $this->MyModel->title . '', true));
        return $this->redirect(array('action'=>'index'));
    }
    if (!empty($this->data)) {
        if ($this->MyModel->save($this->data)) {
            $this->Session->setFlash(__('The '.$this->MyModel->title.' has been saved', true));
            $this->redirect(array('action'=>'index'));
        } else {
            $this->Session->setFlash(__('The '.$this->MyModel->title.'could not be saved. Please, try again.', true));
        }
    }

    // Set Main data
    if (empty($this->data)) {
        $this->data = $this->MyModel->read(null, $id);
    }

    $data = array();

    $this->set($data);
}
?>

I may need to expand it a bit in the future to enable support for dependending Models, etc. But that's all pretty straight forward. And the point is: I will only need to make these changes in one place. And all other 300 objects could profit from it.

What About Baking?

Baking is really good if you stick to Cake's conventions.

If you're working with legacy data like me, you may define things like $useTable, $primaryKey, and $foreignKey. But you may found that these properties are pretty much ignored by the baking process.

Resulting in text fields where you would expect select boxes, and id: '21032' where you would expect: 'Kevin'.

Besided, I would still end up with 1200+ views that I cannot change from that point forward, because all would be lost on the next bake. Also, the views may respond differently depending on environment variables like logged in user.

What About Scaffolding?

Well scaffolding is not at all recommended for production use, and I feel my current approach gives me more flexibility. Because by adding a bit of configuration to models I really have fine-grained control over how the views behave for different models. Reducing the need for exceptions. Reducing the need for duplication.

Downsides

Some people may feel that I'm coupling MVC more than I should: a Model knows about Controller aspects (though not much, I try to use the controller to enforce it's logic on the Model as much as possible).

While I am aware of these dangers, I feel this approach will also allow me to create the 'legacy app' 50 times faster and so it really solves more problems for me than it creates.

Besides, this is only true for the default system. Nothing stops me from supplying Cake with additional real views, Controllers with real methods, and they will be fully adhering to MVC.

Conclusion

So while I don't usually like my Cake DRY, I'm very happy with the way this is going!

Now, though I may have some programming skills, I'm new to Cake, so I really welcome insightful comments on all this.