Simple CakePHP CMS (Content Management System)

January 27th, 2009 | Tags: , , ,

I have recently been working on a way to create a simple CMS (Content Mangement System) for my CakePHP websites that will allow the site owners to log in and maintain their own website. I needed a way for them to create, edit and delete pages. So I came up with this content controller and route component system. It isn’t anything plug-n-play like WordPress but I don’t always need a very advanced setup.

It basically allows you to create nested pages that have content stored in a MySQL table. Then creates custom routes to keep the URLs looking nice. When requested the content is retrieved and placed inside a very basic view and sent to the end user.

One more thing. This tutorial uses the route component I provided in a previous post.

Database

So lets get started. Here is the MySQL table that I created in my database. It has fields for various things commonly found on web pages. Feel free to modify this if you want but if you do you will need to adjust everything else to fit.

/app/config/sql/content.sql

CREATE TABLE IF NOT EXISTS `content` (
    `id` int(11) unsigned NOT NULL auto_increment,
    `slug` varchar(20) NOT NULL,
    `parent_id` int(11) NOT NULL default '0',
    `lft` int(11) default NULL,
    `rght` int(11) default NULL,
    `keywords` tinytext,
    `description` tinytext,
    `title` tinytext NOT NULL,
    `body` text NOT NULL,
    `created` datetime default NULL,
    `updated` datetime default NULL,
    PRIMARY KEY  (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=latin1 AUTO_INCREMENT=1;

Okay everything looks pretty standard here except for a couple of things. First the parent_id, lft, rght fields. This is similar to the tables used in the Acl component. Those are in there because we will be using the Tree behavior in Cake. Something I haven’t seen many people using or talking about. I will explain more about that later. The only thing to note here is be absolutely sure that your parent_id field is NOT NULL and default ‘0′. If you don’t do this and some of your entries are put in with parent_id set to NULL they will not be returned consistently (very frustrating). The second thing is the slug which is actually very simple. That is just the string we will use for the url to keep everything looking pretty.

Content Model

Once you have created your table you will probably need a model for it eh?

/app/models/content.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php
class Content extends AppModel {
    var $name = 'Content';
    var $useTable = 'content';
    var $actsAs = array('Tree');
    var $validate = array(
        'title' => array(
            'rule' => 'notEmpty',
            'required' => true,
            'allowEmpty' => false,
            'message' => 'Title is required'
        ),
        'slug' => array(
            'empty' => array(
                'rule' => 'notEmpty',
                'required' => true,
                'allowEmpty' => false,
                'message' => 'Slug is required'
            ),
            'reg' => array(
                'rule' => array('custom', '/^[A-Za-z0-9_-]+$/'),
                'required' => true,
                'allowEmpty' => true,
                'message' => 'Only letters, numbers, underscores and hyphens are allowed'
            ),
            'test' => array(
                'rule' => array('comparison', '!=', 'test'),
                'required' => true,
                'allowEmpty' => true,
                'message' => 'That value is not allowed'
            )
        )
    );
}
?>

There are only two special things to note here. First being the var $actsAs = array(‘Tree’); which just tells cake that this is an MPTT table and we will be using the Tree behavior. Second is the validation for slug. There is a regular expression to make sure it is URL friendly. By the way if there happens to be any regex gurus out there that know of an easier way to do the above check I’d be glad to see it. I’m not – so that is the best I could come up with on short notice.

2008-01-28 Fix: Just found out that if you create a page with the slug “test” it will error on the page because of Cake’s built in unit testing stuff. So I added a validation rule to not allow it.

Content Controller

Here is the content controller. I recommend using some type of authentication on the admin pages. Here I am using Cake’s built in Auth component as I covered in an earlier post.

/app/controllers/content_controller.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
<?php
class ContentController extends AppController {
    var $name = 'Content';
    var $components = array('Route');


    function beforeFilter() {
        parent::beforeFilter();
        $this->Auth->allow('view');
    }
   
    function _getPath($id) {
        $path = '';
        $pages = $this->Content->getpath($id);
        foreach($pages as $i) {
            $path .= '/'.$i['Content']['slug'];
        }
        return $path;
    }

    function _getListDepth($data) {
        $result = array();
        foreach ($data as $i) {
            $newData = $i;
            $newData['Content']['depth'] = count($this->Content->getpath($i['Content']['id'])) - 1;
            unset($newData['Content']['body']);
            unset($newData['children']);
            $result[] = $newData;
            if(is_array($i['children'])) {
                $children = $this->_getListDepth($i['children']);
                foreach($children as $k) {
                    $result[] = $k;
                }
            }
        }
        return $result;
    }
   
    function view($id) {
        $content = $this->Content->findById($id);
        if(empty($content)) {
            $this->Session->setFlash('Invalid content request', 'default', array('class' => 'bad'));
            $this->redirect('/');
        }else{
            $this->keywords = $content['Content']['keywords'];
            $this->description = $content['Content']['description'];
            $this->set('content', $content['Content']);
        }
    }

    function index() {
        $pages = $this->Content->find('threaded', array('order' => 'title'));
        if(empty($pages)) {
            $this->Session->setFlash('There are no pages to display', 'default', array('class'=>'bad'));
        }else{
            $this->set('page_count', count($pages));
            $this->set('pages', $this->_getListDepth($pages));
        }
    }
   
    function add() {
        $parents = $this->Content->find('threaded', array('order' => 'title'));
        $this->set('parents', $this->_getListDepth($parents));
        if(!empty($this->data)) {
            if($this->Content->save($this->data)) {
                $page_id = $this->Content->getLastInsertId();
                $route = "Router::connect('".$this->_getPath($page_id)."', array('controller' => 'content', 'action' => 'view', '".$page_id."'));";
                $this->Route->add($route);
                $this->Session->setFlash('Page Created', 'default', array('class'=>'good'));
                $this->redirect('index');
            }
        }
    }
   
    function edit($id = null) {
        $parents = $this->Content->find('threaded', array('order' => 'title'));
        $this->set('parents', $this->_getListDepth($parents));
        if(!empty($this->data)) {
            if($this->data['Content']['parent_id'] == $this->data['Content']['id']) {
                $this->Session->setFlash('This page can not be its own parent', 'default', array('class' => 'bad'));
            }else{
                if($this->Content->save($this->data)) {
                    if($this->data['Content']['old_parent_id'] != $this->data['Content']['parent_id']) {
                        $route = "Router::connect('".$this->data['Content']['old_path']."', array('controller' => 'content', 'action' => 'view', '".$this->data['Content']['id']."'));";
                        $this->Route->remove($route);
                        $route = "Router::connect('".$this->_getPath($this->data['Content']['id'])."', array('controller' => 'content', 'action' => 'view', '".$this->data['Content']['id']."'));";
                        $this->Route->add($route);
                    }
                    $this->Session->setFlash('Page Updated', 'default', array('class'=>'good'));
                    $this->redirect('index');
                }
            }
        }else{
            $page = $this->Content->findById($id);
            if(empty($page)) {
                $this->Session->setFlash('Invalid Page ID', 'default', array('class'=>'bad'));
                $this->redirect('index');
            }else{
                $this->data = $page;
                $this->data['Content']['old_parent_id'] = $page['Content']['parent_id'];
                $this->data['Content']['old_path'] = $this->_getPath($page['Content']['id']);
            }
        }
    }
   
    function delete($id) {
        $page = $this->Content->findById($id);
        if(empty($page)) {
            $this->Session->setFlash('Invalid Page ID', 'default', array('class'=>'bad'));
        }else{
            $path = $this->_getPath($id);
            if($this->Content->del($id)) {
                $route = "Router::connect('".$path."', array('controller' => 'content', 'action' => 'view', '".$page['Content']['id']."'));";
                $this->Route->remove($route);
                $this->Session->setFlash('Page Deleted', 'default', array('class'=>'good'));
            }else{
                $this->Session->setFlash('Failed to delete page', 'default', array('class'=>'bad'));
            }
        }
        $this->redirect('index');
    }
}
?>

beforeFilter()

As I mentioned earlier I am using the Auth component as I covered in an earlier post. If you read that you will know why I included this. Also don’t forget to include Auth in the components variable inside the app controller.

_getPath()

This function is used to get the path of our page that will be used when creating/removing routes with the Route component.

_getListDepth()

This is basically to break down the possibly deep multi-dimensional array the find(‘threaded’) method will return and add the depth of each item in the array to the array. This will make it easy to create lists in the views without having to write an additional helper to handle the recursion.

view()

This function basically takes the content id passed to it from the route and sets the view with the returned data.

index(), add(), edit() & delete()

These are just admin pages. Not much new here.

Content Views

Here are all of the views. To save you some copy and pasting I will provide all this code in a zip file at the bottom of the post.

/app/views/content/add.ctp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<h2>New Page</h2>
<?php
echo $form->create('Content', array('url' => '/content/add'));
echo '<div>';
echo '  <label for="ContentParentId">Parent</label>';
echo '  <select name="data[Content][parent_id]" id="ContentParentId">';
echo '      <option value="0"'.((@$this->data['Content']['parent_id'] == 0) ? ' selected' : '').'>None</option>';
if(!empty($parents)) {
    foreach($parents as $i) {
        echo '<option value="'.$i['Content']['id'].'" style="padding-left: '.($i['Content']['depth'] + 1).'em;"'.((@$this->data['Content']['parent_id'] == $i['Content']['id']) ? ' selected' : '').'>'.$i['Content']['title'].'</option>';
    }
}
echo '  </select>';
echo '</div>';
echo $form->input('title', array('size' => 60));
echo $form->input('slug', array('size' => '20', 'after' => ' The Slug is the word used for the URL of this page.'));
echo $form->input('body', array('rows' => '10', 'cols' => '70'));
echo '<div><input type="submit" value="Save" /> or '.$html->link('Cancel', array('action' => 'index')).'</div>';
echo '<fieldset>';
echo '  <legend>META Data</legend>';
echo $form->input('keywords', array('size' => '60'));
echo $form->input('description', array('rows' => '3', 'cols' => '65'));
echo '</fieldset>';
echo $form->end();
?>

/app/views/content/edit.ctp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<h2>Edit Page</h2>
<?php
echo $form->create('Content', array('url' => '/admin/content/edit'));
echo '<div>';
echo '  <label for="ContentParentId">Parent</label>';
echo '  <select name="data[Content][parent_id]" id="ContentParentId">';
echo '      <option value="0"'.((@$this->data['Content']['parent_id'] == 0) ? ' selected' : '').'>None</option>';
if(!empty($parents)) {
    foreach($parents as $i) {
        echo '<option value="'.$i['Content']['id'].'" style="padding-left: '.($i['Content']['depth'] + 1).'em;"'.((@$this->data['Content']['parent_id'] == $i['Content']['id']) ? ' selected' : '').'>'.$i['Content']['title'].'</option>';
    }
}
echo '  </select>';
echo '</div>';
echo $form->hidden('old_parent_id');
echo $form->hidden('old_path');
echo $form->input('title', array('size' => 60));
echo $form->input('body', array('rows' => '10', 'cols' => '70'));
echo '<div><input type="submit" value="Save" /> or '.$html->link('Cancel', array('action' => 'index')).'</div>';
echo '<fieldset>';
echo '  <legend>META Data</legend>';
echo $form->input('keywords', array('size' => '60'));
echo $form->input('description', array('rows' => '3', 'cols' => '65'));
echo $form->hidden('slug');
echo $form->hidden('id');
echo '</fieldset>';
echo $form->end();
?>

/app/views/content/index.ctp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<h2>Pages</h2>
<div>
    <?php echo $html->link('Add New Page', array('action' => 'add')); ?>
</div>
<hr />
<table width="100%">
    <tr>
        <th width="60">ID</th>
        <th>Title</th>
        <th width="130">Slug</th>
        <th width="180">Last Updated</th>
        <th width="180">Created</th>
        <th width="150"></th>
    </tr>
<?php
if(!empty($pages)) {
    $count = 0;
    foreach ($pages as $i) {
        $count++;
        echo '<tr>';
        echo '  <td>'.$i['Content']['id'].'</td>';
        echo '  <td>'.str_repeat('&nbsp;&nbsp; ', $i['Content']['depth']).$i['Content']['title'].'</td>';
        echo '  <td>'.$i['Content']['slug'].'</td>';
        echo '  <td>'.date('M. jS, Y g:ia', strtotime($i['Content']['updated'])).'</td>';
        echo '  <td>'.date('M. jS, Y g:ia', strtotime($i['Content']['created'])).'</td>';
        echo '  <td class="actions">';
        echo $html->link('View', array('admin' => false, 'action' => 'view', $i['Content']['id']), array('target' => '_blank')).' | ';
        echo $html->link('Edit', array('action' => 'edit', $i['Content']['id'])).' | ';
        echo $html->link('Delete', array('action' => 'delete', $i['Content']['id']), null, 'Are you sure you want to delete this page?').' | ';
        echo '  </td>';
        echo '</tr>';
    }
}
?>
</table>
<hr />
<?php
if(!empty($page_count)) {
    echo '<p><i>'.$page_count.' Total Pages</i></p>';
}
?>

/app/views/content/view.ctp

1
2
3
4
5
6
<?php
if(!empty($content)) {
    echo '<h2>'.$content['title'].'</h2>';
    echo $content['body'];
}
?>

App Controller

There are a couple of things that we need to do in the app controller. First add two variables for keywords and description. Then create a beforeRender function to set them in your layout. I left all of the layout view stuff out of here but if you have any questions about it feel free to ask. So your app controller should end up looking something like this:

/app/app_controller.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
class AppController extends Controller {
    var $helpers = array('Html', 'Form', 'Session');
    var $components = array('Auth');
    var $keywords;
    var $description;
   
    function beforeFilter() {
        $this->Auth->loginAction = array('admin' => false, 'plugin' => null, 'controller' => 'users', 'action' => 'login');
        $this->Auth->logoutRedirect = array('admin' => false, 'plugin' => null, 'controller' => 'users', 'action' => 'login');
        $this->Auth->loginRedirect = array('admin' => false, 'plugin' => null, 'controller' => 'pages', 'action' => 'index');
        $this->Auth->allow('display');
    }
   
    function beforeRender() {
        $this->set('keywords', $this->keywords);
        $this->set('description', $this->description);
    }
}
?>

Summary

That should get you started. I will keep updating this article for the next few days but if you have any suggestions I would be glad to hear them. You can download all of these files here. I also plan on going over how to add FCKEditor to this soon covering both php and javascript methods of integration.

  1. rudy
    May 16th, 2009 at 05:03
    Reply | Quote | #1

    hey, how do you detect enters in the comments

    like this

  2. May 16th, 2009 at 10:32
    Reply | Quote | #2

    That is a regular text field thing. When you hit enter it inputs a newline character \n. Then in your display code you can use the nl2br php function to convert newline characters to html br tags.

  3. July 12th, 2009 at 10:14
    Reply | Quote | #3

    Thanks, Joseph. These tutorials so far really are great starting points.

  4. seth
    December 10th, 2009 at 11:37
    Reply | Quote | #4

    Thank you for the great set of cakephp articles! They’re very straightforward and helpful for a novice like myself.

    The only part of the CMS here that I’m uncertain about is the fact that it appears you’re deviating from the ‘Convention over Configuration’ model by naming your controller ‘content_controller’ and ‘ContentController’ instead of ‘contents_controller’ and ‘ContentsController.

    I understand that ‘content’ can be used to describe singular pieces of ‘content’, and well as the whole collection of ‘content’ on your site; but are there any pitfalls I should watch out for when using what appears to be a non-standard naming convention in my app?

  5. December 12th, 2009 at 11:59
    Reply | Quote | #5

    Hello Seth,
    There should not be any pitfalls as long as you specify your ‘Content’ model in the controller. Otherwise Cake will look for a ‘Contents’ model and give you an error. I really wanted to call that the pages controller but that would conflict with the one built into Cake. I struggled with whether to call it Contents or Content and like you pointed out Content can be a plural of itself. Since the Cake naming conventions are to use plurals for the controller names it should still be correct it just goes beyond the language abilities of cake. I normally do stick with the conventional methods. But personally I like things to make sense in the url and this was one of those cases.
    Anyway, thanks for the comment and I’m glad some of this has helped you some.

  6. Pedro
    February 21st, 2010 at 04:11
    Reply | Quote | #6

    @Joseph
    Excellent and very usefull approach. Thanks Joseph.

  7. Perry
    February 21st, 2010 at 04:11
    Reply | Quote | #7

    @rudy
    ??? I dont understand

TOP