Your job as a CMS developer is to make managing and publishing information as easy as possible. This challenge is compounded by the fact that the information can take many forms on the Web. There are as many solutions to this problem as there are CMSs, each with its own pros and cons.
One of the real benefits of developing with a framework like Zend Framework is that you don't have to worry about much of the low-level application logic. The framework provides a standardized API, which you can use as you focus on the specific needs of your project. In this chapter, we will follow this model to develop a flexible content management API that the entire CMS will use.
How you structure this data plays a large role in how straightforward data management is. There are several approaches, each with its own pros and cons.
Most traditional content management systems use a fairly standardized data structure that closely parallels the organization of written materials. This structure is broken down into sections and pages, which share some common characteristics, but they are unique entities in the database. Each additional form of content, such as a news article, would have its own table with its own properties.
There are obvious advantages to this approach. The most notable is simplicity. A database that is designed in parallel with the final output data structure is easier and more intuitive to work with. The code is simple and thus less error prone.
The foremost disadvantage is how this structure accommodates changes in the front-end format. Since the database is tightly coupled with the application logic, a simple change on the front end can require updates in every layer of the system. Over the course of time, this can lead to increasingly complex refactoring, which in turn can lead to more bugs. Eventually, such systems will need to be rewritten to accommodate new ways of publishing content.
Abstract data structures look at content in a different way. In an abstract system, content is content. The database's primary responsibility is to store and serve this content, not to make sense of it. This is solely the application's job.
Let's consider a common example to illustrate how this works. Say your CMS has two modules, one that manages news and one that manages events. On the front end of your site, news articles and events are unique items with unique properties. The key here is to look at the underlying data from another perspective, focusing on what these items have in common.
Looking at Table 5-1, it seems like a good idea to create a common content table that serves both news and events. This would work well initially but does not completely resolve the challenge of building a system that meets your current needs as well as those in the future.
The preceding example demonstrated that data can be standardized but did not allow for the fact that not all forms of data can fit into this standard structure. Say, for example, that you needed to add a location to the CMS events. Many news articles could use this field, but many would not. In a simple example like this, the problem may not have any practical impact; the articles that don't have a location could simply ignore this field. In a more complex, real-world example, this issue can compound and create more complex issues than the original structure resolves.
The traditional approach to this challenge is to create user-defined fields. A commercial CMS database I recently worked with had one large content table that had several extra fields of each data type: date_1, date_2, text_1
, and so on. This solution does allow for future growth but has many serious issues, including:
What do you do when you need more than two date fields?
How will the next developer who takes over the project know what the text_2
field is in different contexts?
One solution to this challenge is the node pattern (Figure 5-1). This consists of two components: pages that are the main container and content nodes that are the actual blocks of content. A given page can have any number of content nodes.
You may notice that the node pattern, as I refer to it, is very similar to the EAV database pattern. The one difference is the fact that the node pattern uses concrete tables, which can improve performance.
For example, a simple page might have two nodes, the headline and content. A more complicated example might be a blog post that has a headline, teaser, image, and content.
The key of this pattern is the fact that since any page can have any number of content nodes, the same database tables can serve the simple page, a blog page, or any other page. The abstract nature of the relationship between the CMS and its underlying data makes it possible to make substantial changes in your site structure without altering the database.
Pages can contain other pages using the structure that we are developing.
The data management system is the core of a CMS project. This system will manage all content for the CMS, so it is probably the most important aspect of the project. If you design it well, the CMS will be easy to work with and serve a wide range of needs; if not, you will inevitably be refactoring the system before long.
In the previous section, I went over how to create flexible data systems, focusing on the node pattern, which you will base the system on. Now that you understand the basic principles of the system, you need to determine how these abstract concepts can be used to manage the content of your CMS.
The first step is to decide exactly what information you want to make available in the page object. This data is then broken down into items that are going to be required for every page and items that may be variable. The items that are required by every page are managed by the page component; these include the name and content type. Then the headline, main content, and any other content blocks are managed by the content node component.
Content nodes are the core content containers, so they are a logical place to start.
The nodes table will be very simple because it will store the content as a list of key-value pairs, with one additional field to make the relationship to the page:
id
: The primary key
page_id
: The foreign key that is used to relate the content node to the page
node
: The key for the content node, such as headline or teaser
content
: The actual content data, usually HTML or text
Let's start by creating this table. Run the SQL script in Listing 5-1 to create a new table named content_nodes
.
Once this table is created, you need to create the model. First create the file for the model class. Then create a new file in application/models
named ContentNode.php
. Open this file, and follow the standard Zend model routine of creating a model class that extends Zend_Db_Table_Abstract
and then setting the table name property to content_nodes
(Listing 5-2).
Example 5.2. Setting Up the Base ContentNode
Model Class in application/models/ContentNode.php
<?php require_once 'Zend/Db/Table/Abstract.php'; require_once APPLICATION_PATH . '/models/Page.php'; class Model_ContentNode extends Zend_Db_Table_Abstract { /** * The default table name */ protected $_name = 'content_nodes'; } ?>
I like to consolidate create and update methods in my model classes; this saves a lot of repetitive coding. It works like this:
It will check if whether the page already has this key set; it will use this row if it exists, or it will create a new one if it does not.
The row's content is set to the new value.
The row is saved.
The advantage of this approach is it makes it easier to use the model. When you need to set a content node, you don't have to repeat the code to determine whether it needs to be created in each of your child classes. Add the setNode()
method shown in Listing 5-3 into the ContentNode.php
file.
Example 5.3. The setNode()
Method in application/models/ContentNode.php
public function setNode($pageId, $node, $value) { // fetch the row if it exists $select = $this->select(); $select->where("page_id = ?", $pageId); $select->where("node = ?", $node); $row = $this->fetchRow($select); //if it does not then create it if(!$row) { $row = $this->createRow(); $row->page_id = $pageId; $row->node = $node; } //set the content $row->content = $value; $row->save(); }
The Page
model class will serve as a higher-level interface to the CMS pages and their associated content nodes. You will start by creating the pages
table.
The pages
table will need to store information that every page must have:
id
: This is the primary key.
namespace
: This is the type of page, such as a news article.
parent_id
: This is the parent page.
name
: This is the name of the page.
date created
: This is the date/time the page was created.
To create the pages
table, use the SQL script shown in Listing 5-4.
Once this table is created, you can create the model. First create the file for the page's Model
class. Create a new file in application/models
named Page.php
. Open this file, follow the standard Zend model Model
routine of creating a Model
class that extends Zend_Db_Table_Abstract
, and then set the table name property to pages
(Listing 5-5).
Example 5.5. Setting Up the Base Page
Model Class in application/models/Page.php
require_once 'Zend/Db/Table/Abstract.php'; require_once APPLICATION_PATH . '/models/ContentNode.php'; class Model_Page extends Zend_Db_Table_Abstract { /** * The default table name */ protected $_name = 'pages'; }
Next you need to create a method to create a new page. This method will use the Zend_Db_Table
instance's createRow()
method. Once it creates the row, it will set the row's name, namespace (content type), parent ID, and date it was created. It will return the ID of the page that was just created (Listing 5-6).
Example 5.6. The createPage()
Method in application/models/Page.php
public function createPage($name, $namespace, $parentId = 0) { //create the new page $row = $this->createRow(); $row->name = $name; $row->namespace = $namespace; $row->parent_id = $parentId; $row->date_created = time(); $row->save(); // now fetch the id of the row you just created and return it $id = $this->_db->lastInsertId(); return $id; }
Now you need a method to update an existing page. This method will use the Zend_Db_Table
instance's find()
method to fetch the requested page. If it finds the row, then the first thing it will do is update each of the primary page fields. Once it has done this, it will unset these fields from the data array, leaving the non standard fields that will go in the content node table. Next it will cycle through the rest of the fields in the data array, setting each one as a content node (Listing 5-7).
Example 5.7. The udpatePage()
Method in application/models/ContentNode.php
public function updatePage($id, $data) { // find the page $row = $this->find($id)->current(); if($row) { // update each of the columns that are stored in the pages table $row->name = $data['name']; $row->parent_id = $data['parent_id']; $row->save(); // unset each of the fields that are set in the pages table unset($data['id']); unset($data['name']); unset($data['parent_id']); // set each of the other fields in the content_nodes table if(count($data) > 0) { $mdlContentNode = new Model_ContentNode(); foreach ($data as $key => $value) { $mdlContentNode->setNode($id, $key, $value); } } } else { throw new Zend_Exception('Could not open page to update!'), } }
Deleting a page is a simple process. The framework handles the cascading deletes, which will clean up the content_nodes
table for you when you delete the parent page (Listing 5-8).
One of the main factors that distinguishes a relational database from a spreadsheet or other list of data is that the rows in different tables can be related. Zend_Db_Table
provides a mechanism to define and work with these relationships.
When you are defining a one-to-many relationship, as in this case where one page has many content nodes, you need to do two things:
Page
model: Tell Zend_Db_Table
that the pages
table is dependent on the content_nodes
table.
ContentNode
model: Tell Zend_Db_Table
that the content_nodes
table is related to the pages
table, setting the columns that relate them, the reference table class (Page
in this case), and the reference columns, which are the columns in the reference table.
You define the relationship by setting the protected _referenceMap
property in the class declaration. In the case of the content_nodes
to pages
relationship, the content_nodes
table's parent_id
column is what relates it to the pages
table's id
column. When you delete a page, you want it to cascade delete all the related content nodes, and when you update the foreign key, you want it to restrict the update.
Add the _referenceMap
property directly after the _name
property in the ContentNode
model, as in Listing 5-9.
The Page
model has two relationships to control: the page to content_nodes
relationship and the page-to-page relationship.
The page to content_nodes
relationship is defined by the child table, which is content_nodes
in this case. You simply need to tell Zend_Db_Table
that the pages
table is dependent on the content_nodes
table.
The page-to-page relationship is handled by the parent_id
and id
fields. You define this using the same technique as you did with the ContentNode
model.
Add the dependentTables
and referenceMap
properties in Listing 5-10 to the top of your Page
model, directly below the $_name
property.
Example 5.10. The content_nodes
to page
and Page-to-Page References in application/models/Page.php
protected $_dependentTables = array('Model_ContentNode'), protected $_referenceMap = array( 'Page' => array( 'columns' => array('parent_id'), 'refTableClass' => 'Model_Page', 'refColumns' => array('id'), 'onDelete' => self::CASCADE, 'onUpdate' => self::RESTRICT ) );
Zend_Db_Table_Row
has a number of methods for fetching related records using the references you just set up. These include standard methods, such as findDependentRowset()
, as well as a set of magic methods; these are methods that are created on the fly.
The following code samples are examples only; you will create the actual methods in the Content Items
section, which follows.
To fetch all the content nodes for a given page, you first need to find the page row. Once you have that row, you use the findDependentRowset()
method, to which you pass the class name of the table you want it to search, as in the example in Listing 5-11.
Example 5.11. Fetching Dependent Rows
$mdlPage = new Model_Page(); $page = $mdlPage->find(1)->current(); $contentNodes = $page->findDependentRowset(' Model_ContentNode'),
To go the other direction and find a parent row for a given row, you use the findParentRow()
method. You pass this method the class name for the parent table as well, as in Listing 5-12.
Zend_Db_Table
supports cascading write operations, but this is intended only for database systems that do not support referential integrity, such as MySQL. You set these options in the referenceMap
and can set either to cascade or to restrict. If you set this to cascade, it will automatically update the child rows. If you set it to restrict, then it will throw an error when you attempt to modify a row with dependencies.
The Page
and ContentNode
model classes provide an abstraction layer between your application and the underlying database structure. This makes working with the data much more straightforward, but it still has room for improvement.
The issue is that over the course of creating this flexible data structure, you have made managing the data more complicated than it would be if there were a specific table tailored for each content type. This is a fair trade-off in my opinion, but there is also a way around this complexity. It involves creating another layer of abstraction on top of these models: content item objects. These objects extend a base abstract class that handles all the interaction with the Page
and ContentNode
models, giving you truly object-oriented data access.
The abstract content item class serves as the base for all the content items that you will create. It adds all the standard functions that the content items need; for example, the methods for loading content items, saving changes, fetching related items, and so on.
To get started, create a new folder in the library/CMS
folder named Content
and then another folder in Content
named Item
. Then create a new file in this folder named Abstract.php
.
Create a new class in this file named CMS_Content_Item_Abstract
. Since this class wraps a standard page, you need to add properties for each of the pages
table's columns to it. Make all the fields except namespace
public. The namespace
field will be set on an item level and should be protected so only child classes can change it. Also, add a protected property for the Page model
, which will be loaded in the constructor, so it needs to get loaded only once.
Once you set these up, add the constructor. This should create a new instance of the Page
model and set the pageModel
property. Then it should load the page if a page ID has been passed to it. Use the loadPageObject()
method, which you will create shortly (Listing 5-13).
Example 5.13. The Base CMS_Content_Item_Abstract
Class in library/CMS/Content/Item/Abstract.php
<?php abstract class CMS_Content_Item_Abstract { public $id; public $name; public $parent_id = 0;
protected $_namespace = 'page'; protected $_pageModel; public function __construct($pageId = null) { $this->_pageModel = new Page(); if(null != $pageId) { $this->loadPageObject(intval($pageId)); } } } ?>
You now need to set up the method to load the content items. This method will fetch the current item's (that is, the ID that you passed it) row in the database. If it finds the row, then it needs to validate that the row's namespace
field matches the content item's. Then it loads the base properties, which are the properties that you set in this abstract class and are stored in the pages
table. Next, the load method needs to fetch the content nodes and attempt to load each of these into class properties. It will do this using a _callSetterMethod()
method, which will call a _set
method for each node if it exists. This is done so you can manipulate the data that the content item uses.
You will need three additional functions for the loadPageObject()
method. It would be possible to simply add this logic to the loadPageObject()
, but that undermines the goal of creating reusable code and makes the code less readable. These are the methods you need to create:
The getInnerRow()
method is very straightforward; it simply wraps the Zend_Db_Table find()
method and sets the content item's inner row, which is the underlying data set (Listing 5-14).
Example 5.14. The getInnerRow()
Method in library/CMS/Content/Item/Abstract.php
protected function _getInnerRow ($id = null) { if ($id == null) { $id = $this->id; } return $this->_pageModel->find($id)->current(); }
The _getProperties()
method will utilize several global PHP methods for inspecting classes, including the get_class()
method, which returns the class name of an object, and the get_class_vars()
method, which returns an array of the properties for a class (Listing 5-15).
Example 5.15. The _getProperties()
Method in library/CMS/Content/Item/Abstract.php
protected function _getProperties() { $propertyArray = array(); $class = new Zend_Reflection_Class($this); $properties = $class->getProperties(); foreach ($properties as $property) { if ($property->isPublic()) { $propertyArray[] = $property->getName(); } } return $propertyArray; }
The _callSetterMethod()
will be a little more complex. You first need to establish the naming convention for the setter methods; in this case, I chose to prepend _set
to the camelCased content node name, so my_value
will get set with _setMyValue
, for example. Then you need to check whether the method exists. If it does, you pass it the data set, and if not, you return a message to the calling method. Note that it is considered a best practice to use class constants for any of these messages, so you will need to add a NO_SETTER
constant to the head of the class (Listing 5-16 and Listing 5-17).
Example 5.16. Setting the NO_SETTER
Constant in library/CMS/Content/Item/Abstract.php
const NO_SETTER = 'setter method does not exist';
Example 5.17. The _callSetterMethod()
in library/CMS/Content/Item/Abstract.php
protected function _callSetterMethod ($property, $data) { //create the method name $method = Zend_Filter::filterStatic($property, 'Word_UnderscoreToCamelCase'), $methodName = '_set' . $method; if (method_exists($this, $methodName)) { return $this->$methodName($data); } else { return self::NO_SETTER; } }
Now you have the base methods in place and can load your content items (Listing 5-18).
Example 5.18. The loadPageObject()
Method in library/CMS/Content/Item/Abstract.php
public function loadPageObject($id) { $this->id = $id; $row = $this->getInnerRow(); if($row) {
if($row->namespace != $this->_namespace) { throw new Zend_Exception('Unable to cast page type:' . $row->namespace . ' to type:' . $this->_namespace); } $this->name = $row->name; $this->parent_id = $row->parent_id; $contentNode = new Model_ContentNode(); $nodes = $row->findDependentRowset($contentNode); if($nodes) { $properties = $this->_getProperties(); foreach ($nodes as $node) { $key = $node['node']; if(in_array($key, $properties)) { // try to call the setter method $value = $this->_callSetterMethod($key, $nodes); if($value === self::NO_SETTER) { $value = $node['content']; } $this->$key = $value; } } } } else { throw new Zend_Exception("Unable to load content item"); } }
Next you need to create the utility methods. These are the methods that will make your life easier when you are working with the items, and you will likely add to them.
Initially, you will need to create a toArray()
method. The toArray()
method will first get the item's properties. Then it will go through these properties, building an array of the values of the public properties (Listing 5-19).
Now that you have the methods in place for loading and working with the content items, you are ready to create the methods to manipulate the underlying data. You need to create a method to insert a new row, update an existing row, and delete a row.
The insert and updated methods will be consolidated into a save()
method for convenience. By doing this, you will be able to create a new instance of the content item, set the values, and then call the save()
method, much in the same way that Zend_Db_Table_Row
works. The save()
method will determine whether the current item is a new item (by checking to see whether the ID is set) and then call the protected _insert()
or _update()
method appropriately (Listing 5-20).
Example 5.20. The save()
Method in library/CMS/Content/Item/Abstract.php
public function save() { if(isset($this->id)) { $this->_update(); } else { $this->_insert(); } }
The _insert()
method will call the Page
model's createPage()
method. Then it will set the current item's ID and call the _update()
method (Listing 5-21).
Example 5.21. The _insert()
Method in library/CMS/Content/Item/Abstract.php
protected function _insert() { $pageId = $this->_pageModel->createPage( $this->name, $this->_namespace, $this->parent_id); $this->id = $pageId; $this->_update(); }
The _update()
method will call the item's toArray()
method and then pass this to the Page
model's updatePage()
method (Listing 5-22).
Example 5.22. The_update()
Method in library/CMS/Content/Item/Abstract.php
protected function _update() { $data = $this->toArray(); $this->_pageModel->updatePage($this->id, $data); }
Finally, the delete()
method will validate that the current item is an existing row in the database (through the presence of the id
field) and call the Page
model's deletePage()
method if it is (Listing 5-23).
The page model will delete the related content nodes, since you turned cascading deletes on in the page model class.
Now that you have this base content item class, you can create new forms of content for your CMS project very easily, without altering the underlying model or database. You simply create a new class that extends CMS_Content_Item_Abstract
and add public properties for each of the data.
For example, say you are creating a module for a tour operator to display their trips. A trip would probably have fields for the title, short description, content, date, length, and cost. You also need to set the namespace, which is how the CMS differentiates between the different page types. So, your content item would look like the code in Listing 5-24.
Example 5.24. An Example Content Item for Trips
<?php class Trip extends CMS_Content_Item_Abstract { public $title; public $short_description; public $content; public $date; public $length; public $cost; protected $_namespace = 'trip'; } ?>
Then to create a new trip, you simply create a new instance of the trip, set each of the properties as necessary, and call the save()
method (Listing 5-25).
Example 5.25. Creating a New Example Trip
$trip = new Trip(); $trip->title = "Long Range Tuna Fishing"; $trip->short_description = "This trip will ..."; $trip->content = "More in depth content ..."; $trip->date = "September 15, 2009"; $trip->length = "15 Days"; $trip->cost = "$2,995"; $trip->save();
As you can see, the CMS_Content_Item_Abstract
class makes working with this data structure totally transparent.
In this chapter, you reviewed different patterns for CMS data. You then implemented the node pattern. With the database tables complete, you set up the Zend_Db_Table
models. Finally, you learned how to create content item objects that abstracts all of this, giving the end developer a very simple interface to the CMS data.