Joseph Crawford Using wordpress because he is lazy

27Jan/0919

Simple CakePHP CMS (Content Management System)

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 '';
    }
}
?>
</table>
<hr />
<?php
if(!empty($page_count)) {
    echo '<p><i>'.$page_count.' Total Pages</i>';
}
?>

/app/views/content/view.ctp

1
2
3
4
5
6
<?php
if(!empty($content)) {
    echo '<h2>'.$content['title'].'';
    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. I also plan on going over how to add FCKEditor to this soon covering both php and javascript methods of integration.

Comments (19) Trackbacks (0)
  1. Changing the title of pages created? Content appears in all

  2. Hi.. thanks for the tutorial.. but the sample link is broken..

  3. Thanks fot the tut man !

  4. Hi,

    Great tut, very clean and simple code. I was looking for a solution like this, although I would also like to be able to point to other controller/action combinations when creating ‘content’, so you can actually use the same system for non-CMS content. Any thoughts on this?


Leave a comment

(required)

No trackbacks yet.