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
`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(' ', $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.













hey, how do you detect enters in the comments
like this
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.
Thanks, Joseph. These tutorials so far really are great starting points.
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?
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.
@Joseph
Excellent and very usefull approach. Thanks Joseph.
@rudy
??? I dont understand