How to Add Article Categories to Your CMS

Learn how to modify a PHP and MySQL content management system to include support for article categories. Full CMS code download included.

How to Add Article Categories to Your CMS

Since publishing my tutorial Build a CMS in an Afternoon with PHP and MySQL, many readers have asked how to add more features to the CMS. I thought I'd answer some of these questions by writing additional tutorials that build on the original simple CMS.

In this tutorial, you'll learn how to add article categories to the CMS. Categories give your site more flexibility: as well as listing all articles on the homepage, you can create separate section pages of your site, with each section page listing the articles belonging to a particular category.

For example, our original CMS demo lumps all types of article — news, reviews, and interviews — together on both the homepage and the archive page. By creating separate News, Reviews, and Interviews article categories in our CMS, we can then create individual archive pages for news, reviews, and interviews in our site.

You can see how this looks by clicking the View Demo button above. Notice that each article title on the homepage has a category name below it (Interviews, Reviews or News). Click a category to view its archive page, which lists all articles in that category, along with the description of the category at the top of the page.

The plan

We're going to start with the basic CMS code from Build a CMS in an Afternoon with PHP and MySQL, and modify it to include the features needed to support categories. Here are the steps you'll work through:

  1. Modify the database
  2. Build the Category class
  3. Modify the Article class to handle categories
  4. Modify index.php to handle category display
  5. Modify admin.php to handle listing, adding, editing, deleting and assigning categories
  6. Modify the front-end templates and stylesheet to handle category display
  7. Modify the back-end templates to handle listing, adding, editing, deleting and assigning categories

Ready to add categories to your CMS? Let's go!

Step 1: Modify the database

Safe

The first thing we need to do is enhance the CMS's MySQL database to support article categories. We need to create a new categories table in the database, and modify the existing articles table to include the ID of the category associated with each article.

Open up the tables.sql file from the original CMS, and make the changes highlighted in the code below:

DROP TABLE IF EXISTS categories;
CREATE TABLE categories
(
  id              smallint unsigned NOT NULL auto_increment,
  name            varchar(255) NOT NULL,                      # Name of the category
  description     text NOT NULL,                              # A short description of the category

  PRIMARY KEY     (id)
);

DROP TABLE IF EXISTS articles;
CREATE TABLE articles
(
  id              smallint unsigned NOT NULL auto_increment,
  publicationDate date NOT NULL,                              # When the article was published
  categoryId      smallint unsigned NOT NULL,                 # The article category ID
  title           varchar(255) NOT NULL,                      # Full title of the article
  summary         text NOT NULL,                              # A short summary of the article
  content         mediumtext NOT NULL,                        # The HTML content of the article

  PRIMARY KEY     (id)
);

As you can see, we've added a new table, categories, to store article categories. Each category has a unique ID field (id), a name to identify the category (name), and a short description of the category for displaying on the category archive page (description).

We've also modified the articles table to include a categoryId field, which we'll use to associate each article with a corresponding category.

A field that links one table to another like this is known as a foreign key.

What if you already have articles in your CMS?

If you load the above tables.sql file into MySQL then it will delete any existing articles table in your cms database, and recreate the articles table from scratch. This will delete any articles already in your CMS. Not ideal!

In this situation, you want to modify the articles table while retaining the existing data in the table. Fortunately, MySQL makes this easy, thanks to its ALTER TABLE statement. In simple terms, ALTER TABLE works like this:

ALTER TABLE tableName ADD fieldName fieldDefinition AFTER existingFieldName

So if you have existing articles in your CMS, you'll need to create your new categories table and modify your existing articles table. To do this, change your tables.sql file to the following:

DROP TABLE IF EXISTS categories;
CREATE TABLE categories
(
  id              smallint unsigned NOT NULL auto_increment,
  name            varchar(255) NOT NULL,                      # Name of the category
  description     text NOT NULL,                              # A short description of the category

  PRIMARY KEY     (id)
);

ALTER TABLE articles ADD categoryId smallint unsigned NOT NULL AFTER publicationDate;

Applying the changes

Now that you've edited your tables.sql file, you need to incorporate the changes into your MySQL database.

If you already have a cms database containing articles then make sure you back it up first before applying the following changes!

If you don't have an existing cms database then you first need to create one, as described in Step 1 of the previous article:

mysql -u username -p create database cms; exit

Now you can load your tables.sql file into MySQL to make the changes to the database:

mysql -u username -p cms < tables.sql

Enter your password when prompted, and press Enter. MySQL reads your tables.sql file and runs the commands inside it, creating and/or modifying your tables inside your cms database.

To check that your changes have been made, first login to MySQL:

mysql -u username -p cms

Then use the SHOW TABLES and EXPLAIN commands to check your table schemas in MySQL:


mysql> show tables;
+---------------+
| Tables_in_cms |
+---------------+
| articles      |
| categories    |
+---------------+
2 rows in set (0.00 sec)

mysql> explain articles;
+-----------------+----------------------+------+-----+---------+----------------+
| Field           | Type                 | Null | Key | Default | Extra          |
+-----------------+----------------------+------+-----+---------+----------------+
| id              | smallint(5) unsigned | NO   | PRI | NULL    | auto_increment |
| publicationDate | date                 | NO   |     | NULL    |                |
| categoryId      | smallint(5) unsigned | NO   |     | NULL    |                |
| title           | varchar(255)         | NO   |     | NULL    |                |
| summary         | text                 | NO   |     | NULL    |                |
| content         | mediumtext           | NO   |     | NULL    |                |
+-----------------+----------------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

mysql> explain categories;
+-------------+----------------------+------+-----+---------+----------------+
| Field       | Type                 | Null | Key | Default | Extra          |
+-------------+----------------------+------+-----+---------+----------------+
| id          | smallint(5) unsigned | NO   | PRI | NULL    | auto_increment |
| name        | varchar(255)         | NO   |     | NULL    |                |
| description | text                 | NO   |     | NULL    |                |
+-------------+----------------------+------+-----+---------+----------------+
3 rows in set (0.00 sec)

mysql> 

Notice the new categoryId field inside the articles table, as well as the brand new categories table.

You've now set up your CMS database so that it's ready to work with categories. Time to write some code!

Step 2: Build the Category class

Cogs

Just as you previously created an Article class to store and retrieve articles in the database, you now need to create a Category class to do the same job for categories.

Within your cms folder, you'll find your classes folder. Inside that classes folder, create a new file called Category.php, and add the following code to it:

<?php

/**
 * Class to handle article categories
 */

class Category
{
  // Properties

  /**
  * @var int The category ID from the database
  */
  public $id = null;

  /**
  * @var string Name of the category
  */
  public $name = null;

  /**
  * @var string A short description of the category
  */
  public $description = null;


  /**
  * Sets the object's properties using the values in the supplied array
  *
  * @param assoc The property values
  */

  public function __construct( $data=array() ) {
    if ( isset( $data['id'] ) ) $this->id = (int) $data['id'];
    if ( isset( $data['name'] ) ) $this->name = preg_replace ( "/[^\.\,\-\_\'\"\@\?\!\:\$ a-zA-Z0-9()]/", "", $data['name'] );
    if ( isset( $data['description'] ) ) $this->description = preg_replace ( "/[^\.\,\-\_\'\"\@\?\!\:\$ a-zA-Z0-9()]/", "", $data['description'] );
  }


  /**
  * Sets the object's properties using the edit form post values in the supplied array
  *
  * @param assoc The form post values
  */

  public function storeFormValues ( $params ) {

    // Store all the parameters
    $this->__construct( $params );
  }


  /**
  * Returns a Category object matching the given category ID
  *
  * @param int The category ID
  * @return Category|false The category object, or false if the record was not found or there was a problem
  */

  public static function getById( $id ) {
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $sql = "SELECT * FROM categories WHERE id = :id";
    $st = $conn->prepare( $sql );
    $st->bindValue( ":id", $id, PDO::PARAM_INT );
    $st->execute();
    $row = $st->fetch();
    $conn = null;
    if ( $row ) return new Category( $row );
  }


  /**
  * Returns all (or a range of) Category objects in the DB
  *
  * @param int Optional The number of rows to return (default=all)
  * @param string Optional column by which to order the categories (default="name ASC")
  * @return Array|false A two-element array : results => array, a list of Category objects; totalRows => Total number of categories
  */

  public static function getList( $numRows=1000000, $order="name ASC" ) {
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $sql = "SELECT SQL_CALC_FOUND_ROWS * FROM categories
            ORDER BY " . mysql_escape_string($order) . " LIMIT :numRows";

    $st = $conn->prepare( $sql );
    $st->bindValue( ":numRows", $numRows, PDO::PARAM_INT );
    $st->execute();
    $list = array();

    while ( $row = $st->fetch() ) {
      $category = new Category( $row );
      $list[] = $category;
    }

    // Now get the total number of categories that matched the criteria
    $sql = "SELECT FOUND_ROWS() AS totalRows";
    $totalRows = $conn->query( $sql )->fetch();
    $conn = null;
    return ( array ( "results" => $list, "totalRows" => $totalRows[0] ) );
  }


  /**
  * Inserts the current Category object into the database, and sets its ID property.
  */

  public function insert() {

    // Does the Category object already have an ID?
    if ( !is_null( $this->id ) ) trigger_error ( "Category::insert(): Attempt to insert a Category object that already has its ID property set (to $this->id).", E_USER_ERROR );

    // Insert the Category
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $sql = "INSERT INTO categories ( name, description ) VALUES ( :name, :description )";
    $st = $conn->prepare ( $sql );
    $st->bindValue( ":name", $this->name, PDO::PARAM_STR );
    $st->bindValue( ":description", $this->description, PDO::PARAM_STR );
    $st->execute();
    $this->id = $conn->lastInsertId();
    $conn = null;
  }


  /**
  * Updates the current Category object in the database.
  */

  public function update() {

    // Does the Category object have an ID?
    if ( is_null( $this->id ) ) trigger_error ( "Category::update(): Attempt to update a Category object that does not have its ID property set.", E_USER_ERROR );
   
    // Update the Category
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $sql = "UPDATE categories SET name=:name, description=:description WHERE id = :id";
    $st = $conn->prepare ( $sql );
    $st->bindValue( ":name", $this->name, PDO::PARAM_STR );
    $st->bindValue( ":description", $this->description, PDO::PARAM_STR );
    $st->bindValue( ":id", $this->id, PDO::PARAM_INT );
    $st->execute();
    $conn = null;
  }


  /**
  * Deletes the current Category object from the database.
  */

  public function delete() {

    // Does the Category object have an ID?
    if ( is_null( $this->id ) ) trigger_error ( "Category::delete(): Attempt to delete a Category object that does not have its ID property set.", E_USER_ERROR );

    // Delete the Category
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $st = $conn->prepare ( "DELETE FROM categories WHERE id = :id LIMIT 1" );
    $st->bindValue( ":id", $this->id, PDO::PARAM_INT );
    $st->execute();
    $conn = null;
  }

}

?>

This class is very similar to the original Article class, and a fair bit simpler. It contains three properties that map to the fields in the categories table — id, name and description — and a constructor method, __construct(), that creates a new Category object holding the values passed to the constructor. The class also contains a storeFormValues() method for storing the data from the submitted category edit form; methods for retrieving a single category by ID and a list of categories; and methods to insert, update, and delete the category in the database.

We need to include our new Category class file in our code files, so that the CMS code can access it. To do this, include it from within the config.php file inside your cms folder, in the same way that you included Article.php in the original CMS:

<?php
ini_set( "display_errors", true );
date_default_timezone_set( "Australia/Sydney" );  // http://www.php.net/manual/en/timezones.php
define( "DB_DSN", "mysql:host=localhost;dbname=cms" );
define( "DB_USERNAME", "username" );
define( "DB_PASSWORD", "password" );
define( "CLASS_PATH", "classes" );
define( "TEMPLATE_PATH", "templates" );
define( "HOMEPAGE_NUM_ARTICLES", 5 );
define( "ADMIN_USERNAME", "admin" );
define( "ADMIN_PASSWORD", "mypass" );
require( CLASS_PATH . "/Article.php" );
require( CLASS_PATH . "/Category.php" );

function handleException( $exception ) {
  echo "Sorry, a problem occurred. Please try later.";
  error_log( $exception->getMessage() );
}

set_exception_handler( 'handleException' );
?>

Step 3: Modify the Article class

Cogs

As well as creating the new Category class, we need to modify the existing Article class to handle categories. Here's the updated Article.php class file. I've highlighted the lines of code so you can see what's been added. Replace the code in your existing cms/classes/Article.php file with this new code:

<?php

/**
 * Class to handle articles
 */

class Article
{
  // Properties

  /**
  * @var int The article ID from the database
  */
  public $id = null;

  /**
  * @var int When the article is to be / was first published
  */
  public $publicationDate = null;

  /**
  * @var int The article category ID
  */
  public $categoryId = null;

  /**
  * @var string Full title of the article
  */
  public $title = null;

  /**
  * @var string A short summary of the article
  */
  public $summary = null;

  /**
  * @var string The HTML content of the article
  */
  public $content = null;


  /**
  * Sets the object's properties using the values in the supplied array
  *
  * @param assoc The property values
  */

  public function __construct( $data=array() ) {
    if ( isset( $data['id'] ) ) $this->id = (int) $data['id'];
    if ( isset( $data['publicationDate'] ) ) $this->publicationDate = (int) $data['publicationDate'];
    if ( isset( $data['categoryId'] ) ) $this->categoryId = (int) $data['categoryId'];
    if ( isset( $data['title'] ) ) $this->title = preg_replace ( "/[^\.\,\-\_\'\"\@\?\!\:\$ a-zA-Z0-9()]/", "", $data['title'] );
    if ( isset( $data['summary'] ) ) $this->summary = preg_replace ( "/[^\.\,\-\_\'\"\@\?\!\:\$ a-zA-Z0-9()]/", "", $data['summary'] );
    if ( isset( $data['content'] ) ) $this->content = $data['content'];
  }


  /**
  * Sets the object's properties using the edit form post values in the supplied array
  *
  * @param assoc The form post values
  */

  public function storeFormValues ( $params ) {

    // Store all the parameters
    $this->__construct( $params );

    // Parse and store the publication date
    if ( isset($params['publicationDate']) ) {
      $publicationDate = explode ( '-', $params['publicationDate'] );

      if ( count($publicationDate) == 3 ) {
        list ( $y, $m, $d ) = $publicationDate;
        $this->publicationDate = mktime ( 0, 0, 0, $m, $d, $y );
      }
    }
  }


  /**
  * Returns an Article object matching the given article ID
  *
  * @param int The article ID
  * @return Article|false The article object, or false if the record was not found or there was a problem
  */

  public static function getById( $id ) {
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $sql = "SELECT *, UNIX_TIMESTAMP(publicationDate) AS publicationDate FROM articles WHERE id = :id";
    $st = $conn->prepare( $sql );
    $st->bindValue( ":id", $id, PDO::PARAM_INT );
    $st->execute();
    $row = $st->fetch();
    $conn = null;
    if ( $row ) return new Article( $row );
  }


  /**
  * Returns all (or a range of) Article objects in the DB
  *
  * @param int Optional The number of rows to return (default=all)
  * @param int Optional Return just articles in the category with this ID
  * @param string Optional column by which to order the articles (default="publicationDate DESC")
  * @return Array|false A two-element array : results => array, a list of Article objects; totalRows => Total number of articles
  */

  public static function getList( $numRows=1000000, $categoryId=null, $order="publicationDate DESC" ) {
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $categoryClause = $categoryId ? "WHERE categoryId = :categoryId" : "";
    $sql = "SELECT SQL_CALC_FOUND_ROWS *, UNIX_TIMESTAMP(publicationDate) AS publicationDate
            FROM articles $categoryClause
            ORDER BY " . mysql_escape_string($order) . " LIMIT :numRows";

    $st = $conn->prepare( $sql );
    $st->bindValue( ":numRows", $numRows, PDO::PARAM_INT );
    if ( $categoryId ) $st->bindValue( ":categoryId", $categoryId, PDO::PARAM_INT );
    $st->execute();
    $list = array();

    while ( $row = $st->fetch() ) {
      $article = new Article( $row );
      $list[] = $article;
    }

    // Now get the total number of articles that matched the criteria
    $sql = "SELECT FOUND_ROWS() AS totalRows";
    $totalRows = $conn->query( $sql )->fetch();
    $conn = null;
    return ( array ( "results" => $list, "totalRows" => $totalRows[0] ) );
  }


  /**
  * Inserts the current Article object into the database, and sets its ID property.
  */

  public function insert() {

    // Does the Article object already have an ID?
    if ( !is_null( $this->id ) ) trigger_error ( "Article::insert(): Attempt to insert an Article object that already has its ID property set (to $this->id).", E_USER_ERROR );

    // Insert the Article
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $sql = "INSERT INTO articles ( publicationDate, categoryId, title, summary, content ) VALUES ( FROM_UNIXTIME(:publicationDate), :categoryId, :title, :summary, :content )";
    $st = $conn->prepare ( $sql );
    $st->bindValue( ":publicationDate", $this->publicationDate, PDO::PARAM_INT );
    $st->bindValue( ":categoryId", $this->categoryId, PDO::PARAM_INT );
    $st->bindValue( ":title", $this->title, PDO::PARAM_STR );
    $st->bindValue( ":summary", $this->summary, PDO::PARAM_STR );
    $st->bindValue( ":content", $this->content, PDO::PARAM_STR );
    $st->execute();
    $this->id = $conn->lastInsertId();
    $conn = null;
  }


  /**
  * Updates the current Article object in the database.
  */

  public function update() {

    // Does the Article object have an ID?
    if ( is_null( $this->id ) ) trigger_error ( "Article::update(): Attempt to update an Article object that does not have its ID property set.", E_USER_ERROR );
   
    // Update the Article
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $sql = "UPDATE articles SET publicationDate=FROM_UNIXTIME(:publicationDate), categoryId=:categoryId, title=:title, summary=:summary, content=:content WHERE id = :id";
    $st = $conn->prepare ( $sql );
    $st->bindValue( ":publicationDate", $this->publicationDate, PDO::PARAM_INT );
    $st->bindValue( ":categoryId", $this->categoryId, PDO::PARAM_INT );
    $st->bindValue( ":title", $this->title, PDO::PARAM_STR );
    $st->bindValue( ":summary", $this->summary, PDO::PARAM_STR );
    $st->bindValue( ":content", $this->content, PDO::PARAM_STR );
    $st->bindValue( ":id", $this->id, PDO::PARAM_INT );
    $st->execute();
    $conn = null;
  }


  /**
  * Deletes the current Article object from the database.
  */

  public function delete() {

    // Does the Article object have an ID?
    if ( is_null( $this->id ) ) trigger_error ( "Article::delete(): Attempt to delete an Article object that does not have its ID property set.", E_USER_ERROR );

    // Delete the Article
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $st = $conn->prepare ( "DELETE FROM articles WHERE id = :id LIMIT 1" );
    $st->bindValue( ":id", $this->id, PDO::PARAM_INT );
    $st->execute();
    $conn = null;
  }

}

?>

Let's take a look at the additions we've made to the Article class:

  • A new categoryId property
    In order to associate an article with a category, we add an integer categoryId property to store the ID of the article's category. We also modify the constructor method, __construct(), to store the new categoryId property in newly-created Article objects.
  • The modified getList() method
    Our original getList() method retrieves all articles in the database (optionally limited to a maximum number of records). Since we want our CMS to be able to display a list of articles in a particular category, we modify getList() to accept an optional $categoryId argument. If present, only articles in that category are returned.

    If $categoryId is supplied, we create a $categoryClause string containing a WHERE clause to retrieve just articles with a categoryId field that matches the supplied categoryId value. We then modify the SQL SELECT statement to include this $categoryClause variable. We also add a new call to $st->bindValue() to bind the supplied $categoryId value to the SQL statement before executing it.
  • Modified insert() and update() methods
    Finally, we modify the Article class's insert() and update() methods to accommodate the new categoryId field. We modify the SQL INSERT and UPDATE statements in each method, and also add new calls to bindValue() to pass the object's $categoryId property to the SQL statements.

Step 4: Modify the front-end index.php script

Welcome

The next step is to make some additions to index.php — the script that displays the front-end pages of the site — so that it can handle the display of categories. Here's the modified index.php file with the changes highlighted — replace the old index.php file in your cms folder with this new code:

<?php

require( "config.php" );
$action = isset( $_GET['action'] ) ? $_GET['action'] : "";

switch ( $action ) {
  case 'archive':
    archive();
    break;
  case 'viewArticle':
    viewArticle();
    break;
  default:
    homepage();
}

function archive() {
  $results = array();
  $categoryId = ( isset( $_GET['categoryId'] ) && $_GET['categoryId'] ) ? (int)$_GET['categoryId'] : null;
  $results['category'] = Category::getById( $categoryId );
  $data = Article::getList( 100000, $results['category'] ? $results['category']->id : null );
  $results['articles'] = $data['results'];
  $results['totalRows'] = $data['totalRows'];
  $data = Category::getList();
  $results['categories'] = array();
  foreach ( $data['results'] as $category ) $results['categories'][$category->id] = $category;
  $results['pageHeading'] = $results['category'] ?  $results['category']->name : "Article Archive";
  $results['pageTitle'] = $results['pageHeading'] . " | Widget News";
  require( TEMPLATE_PATH . "/archive.php" );
}

function viewArticle() {
  if ( !isset($_GET["articleId"]) || !$_GET["articleId"] ) {
    homepage();
    return;
  }

  $results = array();
  $results['article'] = Article::getById( (int)$_GET["articleId"] );
  $results['category'] = Category::getById( $results['article']->categoryId );
  $results['pageTitle'] = $results['article']->title . " | Widget News";
  require( TEMPLATE_PATH . "/viewArticle.php" );
}

function homepage() {
  $results = array();
  $data = Article::getList( HOMEPAGE_NUM_ARTICLES );
  $results['articles'] = $data['results'];
  $results['totalRows'] = $data['totalRows'];
  $data = Category::getList();
  $results['categories'] = array();
  foreach ( $data['results'] as $category ) $results['categories'][$category->id] = $category; 
  $results['pageTitle'] = "Widget News";
  require( TEMPLATE_PATH . "/homepage.php" );
}

?>

Let's take a look at each of the changed functions in index.php:

  • archive()
    The original archive() function simply displayed a list of all articles in the CMS. Here, we've adapted the archive() function to accept an optional categoryId query string parameter. If categoryId is supplied, the function retrieves the corresponding Category object by calling Category::getById() and, if retrieved successfully, it passes the category ID to the Article::getList() function to retreive just the articles in the supplied cateogory.

    We've also added code to the archive() function to retreive all the categories in the database and store them in $results['categories'], keyed by category ID. Our archive page template, archive.php, will use this array to display the name of the category that each article is in.

    Finally, we create a $results['pageHeading'] variable containing either the category name (if a categoryId was supplied), or the text "Article Archive". We'll display this value in the heading within the archive page. We also create a $results['pageTitle'] variable to use in the page's <title> element. This is simply the page heading with the site name, "Widget News", tacked onto the end.
  • viewArticle()
    We've made just one addition to viewArticle(): We retrieve the Category object associated with the article by calling Category::getById(), passing in the article's $categoryId property. We store the resulting Category object in $results['category']. We'll use this object to display the name of the article's category in the viewArticle.php template.
  • homepage()
    Finally, we add three lines to the homepage() function. Like the corresponding code added to archive(), this additional code calls Category::getList() to retrieve all the categories in the CMS, then stores the categories in $results['categories'], keyed by category ID, so that we can display the name of each article's category on the site homepage.

Step 5: Modify the back-end admin.php script

Lock

The admin.php file contains all the admin functions for the CMS. We need to make some changes and additions to this file to handle article categories.

Here's the new admin.php file with the changes highlighted. Replace the old admin.php file in your cms folder with this new code:

<?php

require( "config.php" );
session_start();
$action = isset( $_GET['action'] ) ? $_GET['action'] : "";
$username = isset( $_SESSION['username'] ) ? $_SESSION['username'] : "";

if ( $action != "login" && $action != "logout" && !$username ) {
  login();
  exit;
}

switch ( $action ) {
  case 'login':
    login();
    break;
  case 'logout':
    logout();
    break;
  case 'newArticle':
    newArticle();
    break;
  case 'editArticle':
    editArticle();
    break;
  case 'deleteArticle':
    deleteArticle();
    break;
  case 'listCategories':
    listCategories();
    break;
  case 'newCategory':
    newCategory();
    break;
  case 'editCategory':
    editCategory();
    break;
  case 'deleteCategory':
    deleteCategory();
    break;
  default:
    listArticles();
}


function login() {

  $results = array();
  $results['pageTitle'] = "Admin Login | Widget News";

  if ( isset( $_POST['login'] ) ) {

    // User has posted the login form: attempt to log the user in

    if ( $_POST['username'] == ADMIN_USERNAME && $_POST['password'] == ADMIN_PASSWORD ) {

      // Login successful: Create a session and redirect to the admin homepage
      $_SESSION['username'] = ADMIN_USERNAME;
      header( "Location: admin.php" );

    } else {

      // Login failed: display an error message to the user
      $results['errorMessage'] = "Incorrect username or password. Please try again.";
      require( TEMPLATE_PATH . "/admin/loginForm.php" );
    }

  } else {

    // User has not posted the login form yet: display the form
    require( TEMPLATE_PATH . "/admin/loginForm.php" );
  }

}


function logout() {
  unset( $_SESSION['username'] );
  header( "Location: admin.php" );
}


function newArticle() {

  $results = array();
  $results['pageTitle'] = "New Article";
  $results['formAction'] = "newArticle";

  if ( isset( $_POST['saveChanges'] ) ) {

    // User has posted the article edit form: save the new article
    $article = new Article;
    $article->storeFormValues( $_POST );
    $article->insert();
    header( "Location: admin.php?status=changesSaved" );

  } elseif ( isset( $_POST['cancel'] ) ) {

    // User has cancelled their edits: return to the article list
    header( "Location: admin.php" );
  } else {

    // User has not posted the article edit form yet: display the form
    $results['article'] = new Article;
    $data = Category::getList();
    $results['categories'] = $data['results'];
    require( TEMPLATE_PATH . "/admin/editArticle.php" );
  }

}


function editArticle() {

  $results = array();
  $results['pageTitle'] = "Edit Article";
  $results['formAction'] = "editArticle";

  if ( isset( $_POST['saveChanges'] ) ) {

    // User has posted the article edit form: save the article changes

    if ( !$article = Article::getById( (int)$_POST['articleId'] ) ) {
      header( "Location: admin.php?error=articleNotFound" );
      return;
    }

    $article->storeFormValues( $_POST );
    $article->update();
    header( "Location: admin.php?status=changesSaved" );

  } elseif ( isset( $_POST['cancel'] ) ) {

    // User has cancelled their edits: return to the article list
    header( "Location: admin.php" );
  } else {

    // User has not posted the article edit form yet: display the form
    $results['article'] = Article::getById( (int)$_GET['articleId'] );
    $data = Category::getList();
    $results['categories'] = $data['results'];
    require( TEMPLATE_PATH . "/admin/editArticle.php" );
  }

}


function deleteArticle() {

  if ( !$article = Article::getById( (int)$_GET['articleId'] ) ) {
    header( "Location: admin.php?error=articleNotFound" );
    return;
  }

  $article->delete();
  header( "Location: admin.php?status=articleDeleted" );
}


function listArticles() {
  $results = array();
  $data = Article::getList();
  $results['articles'] = $data['results'];
  $results['totalRows'] = $data['totalRows'];
  $data = Category::getList();
  $results['categories'] = array();
  foreach ( $data['results'] as $category ) $results['categories'][$category->id] = $category;
  $results['pageTitle'] = "All Articles";

  if ( isset( $_GET['error'] ) ) {
    if ( $_GET['error'] == "articleNotFound" ) $results['errorMessage'] = "Error: Article not found.";
  }

  if ( isset( $_GET['status'] ) ) {
    if ( $_GET['status'] == "changesSaved" ) $results['statusMessage'] = "Your changes have been saved.";
    if ( $_GET['status'] == "articleDeleted" ) $results['statusMessage'] = "Article deleted.";
  }

  require( TEMPLATE_PATH . "/admin/listArticles.php" );
}


function listCategories() {
  $results = array();
  $data = Category::getList();
  $results['categories'] = $data['results'];
  $results['totalRows'] = $data['totalRows'];
  $results['pageTitle'] = "Article Categories";

  if ( isset( $_GET['error'] ) ) {
    if ( $_GET['error'] == "categoryNotFound" ) $results['errorMessage'] = "Error: Category not found.";
    if ( $_GET['error'] == "categoryContainsArticles" ) $results['errorMessage'] = "Error: Category contains articles. Delete the articles, or assign them to another category, before deleting this category.";
  }

  if ( isset( $_GET['status'] ) ) {
    if ( $_GET['status'] == "changesSaved" ) $results['statusMessage'] = "Your changes have been saved.";
    if ( $_GET['status'] == "categoryDeleted" ) $results['statusMessage'] = "Category deleted.";
  }

  require( TEMPLATE_PATH . "/admin/listCategories.php" );
}


function newCategory() {

  $results = array();
  $results['pageTitle'] = "New Article Category";
  $results['formAction'] = "newCategory";

  if ( isset( $_POST['saveChanges'] ) ) {

    // User has posted the category edit form: save the new category
    $category = new Category;
    $category->storeFormValues( $_POST );
    $category->insert();
    header( "Location: admin.php?action=listCategories&status=changesSaved" );

  } elseif ( isset( $_POST['cancel'] ) ) {

    // User has cancelled their edits: return to the category list
    header( "Location: admin.php?action=listCategories" );
  } else {

    // User has not posted the category edit form yet: display the form
    $results['category'] = new Category;
    require( TEMPLATE_PATH . "/admin/editCategory.php" );
  }

}


function editCategory() {

  $results = array();
  $results['pageTitle'] = "Edit Article Category";
  $results['formAction'] = "editCategory";

  if ( isset( $_POST['saveChanges'] ) ) {

    // User has posted the category edit form: save the category changes

    if ( !$category = Category::getById( (int)$_POST['categoryId'] ) ) {
      header( "Location: admin.php?action=listCategories&error=categoryNotFound" );
      return;
    }

    $category->storeFormValues( $_POST );
    $category->update();
    header( "Location: admin.php?action=listCategories&status=changesSaved" );

  } elseif ( isset( $_POST['cancel'] ) ) {

    // User has cancelled their edits: return to the category list
    header( "Location: admin.php?action=listCategories" );
  } else {

    // User has not posted the category edit form yet: display the form
    $results['category'] = Category::getById( (int)$_GET['categoryId'] );
    require( TEMPLATE_PATH . "/admin/editCategory.php" );
  }

}


function deleteCategory() {

  if ( !$category = Category::getById( (int)$_GET['categoryId'] ) ) {
    header( "Location: admin.php?action=listCategories&error=categoryNotFound" );
    return;
  }

  $articles = Article::getList( 1000000, $category->id );

  if ( $articles['totalRows'] > 0 ) {
    header( "Location: admin.php?action=listCategories&error=categoryContainsArticles" );
    return;
  }

  $category->delete();
  header( "Location: admin.php?action=listCategories&status=categoryDeleted" );
}

?>

Let's look at each of the changes to admin.php in turn:

  • Additions to the switch block
    We need to add some new functionality to admin.php to handle listing, creating, editing and deleting categories. To this end, we add listCategories, newCategory, editCategory and deleteCategory case blocks to the switch block at the top of the file. These blocks call various functions to handle categories. We'll look at these new functions in a moment.
  • Changes to newArticle(), editArticle() and listArticles()
    We make a small addition to each of these functions in order to retrieve the list of all categories in the database, for use in the Edit Article form and List Articles page. We store the retrieved categories in the $results['categories'] variable. For listArticles(), we also key the categories by category ID, to make it easy for the listArticles.php template to access categories by ID.
  • listCategories()
    This new function displays a list of all categories to the administrator. It works in much the same way as the listArticles() function. It pulls all the categories from the database by calling Category::getList(), then stores them in the $results['categories'] array. It also records the total number of categories in the $results['totalRows'] variable, and stores the page title in $results['pageTitle']. It then checks for various error or status codes passed in the query string, and sets the value of $results['errorMessage'] or $results['statusMessage'] accordingly. Finally, it includes the listCategories.php template file to display the categories list page.
  • newCategory()
    This lets the administrator add a new category to the database, much as newArticle() adds a new article. If the user has submitted the category edit form then the function creates a new Category object, populates it with the form data, calls insert() to insert the category into the database, and redirects to the categories list page, displaying a "changes saved" message. If the user clicked the form's Cancel button to cancel their edits then the function simply redirects to the category list. If the user hasn't yet submitted the form then the function creates a new empty Category object to use for the form, stores it in $results['category'], and includes the editCategory.php template to display the category edit form.
  • editCategory()
    This function edits an existing category in the database, allowing the user to change the category's name and/or description. It follows the same pattern as editArticle(). If the edit form has been submitted, it loads the category from the database, stores the new form values in the Category object, and updates the category in the database by calling the update() method. If the user cancelled their edits then the function redirects to the category list. If the user hasn't yet posted the form then the function loads the category specified by the categoryId query string parameter, stores it in $results['category'], and includes the editCategory.php template to display the populated edit form.
  • deleteCategory()
    This is the last new function we've added to admin.php, and it lets the administrator delete a category from the database. It's called when the user clicks the Delete This Category link on the Edit Category page. First it retrieves the category specified by the categoryId query string parameter (displaying an error if the category wasn't found). Then it checks to see if there are any articles in this category; if there are then it displays an error message and exits. If there aren't any articles in the category then the function deletes the category and redirects to the category list, displaying a "category deleted" message.

Step 6: Modify the front-end templates and stylesheet

Category Archive screenshot

Now that we've added category support to the database and main PHP code, we need to modify the templates to handle categories. First of all, let's alter the front-end templates so that they can display category names, as well as show category archive pages.

1. homepage.php

We'll make a small change to the homepage.php template — which displays the site home page — so that each article's category is displayed below its title. Here's the modified file with changes highlighted — replace your old cms/templates/homepage.php file with this code:

<?php include "templates/include/header.php" ?>

      <ul id="headlines">

<?php foreach ( $results['articles'] as $article ) { ?>

        <li>
          <h2>
            <span class="pubDate"><?php echo date('j F', $article->publicationDate)?></span><a href=".?action=viewArticle&amp;articleId=<?php echo $article->id?>"><?php echo htmlspecialchars( $article->title )?></a>
            <?php if ( $article->categoryId ) { ?>
            <span class="category">in <a href=".?action=archive&amp;categoryId=<?php echo $article->categoryId?>"><?php echo htmlspecialchars( $results['categories'][$article->categoryId]->name )?></a></span>
            <?php } ?>
          </h2>
          <p class="summary"><?php echo htmlspecialchars( $article->summary )?></p>
        </li>

<?php } ?>

      </ul>

      <p><a href="./?action=archive">Article Archive</a></p>

<?php include "templates/include/footer.php" ?>

As you can see, the added code checks to see if the article is in a category by looking at its $categoryId property. If it is, the code inserts a <span> element into the page, containing the category name. It does this by looking up the correct Category object in the supplied $results['categories'] array based on the category's ID, then displaying the category's name property.

The code also wraps the category name in a link that points to the category's archive page, so that the visitor can easily view other articles in the same category.

2. archive.php

The archive.php template displays the article archive. In the original CMS, the archive simply listed all articles. However, in this version of the CMS, the archive() function in index.php can also list just the articles in a given category. Therefore we need to make a couple of changes to the template to accommodate this.

Here's the modified template. Replace your old cms/templates/archive.php file with the following code:

<?php include "templates/include/header.php" ?>

      <h1><?php echo htmlspecialchars( $results['pageHeading'] ) ?></h1>
<?php if ( $results['category'] ) { ?>
      <h3 class="categoryDescription"><?php echo htmlspecialchars( $results['category']->description ) ?></h3>
<?php } ?>

      <ul id="headlines" class="archive">

<?php foreach ( $results['articles'] as $article ) { ?>

        <li>
          <h2>
            <span class="pubDate"><?php echo date('j F Y', $article->publicationDate)?></span><a href=".?action=viewArticle&amp;articleId=<?php echo $article->id?>"><?php echo htmlspecialchars( $article->title )?></a>
<?php if ( !$results['category'] && $article->categoryId ) { ?>
            <span class="category">in <a href=".?action=archive&amp;categoryId=<?php echo $article->categoryId?>"><?php echo htmlspecialchars( $results['categories'][$article->categoryId]->name ) ?></a></span>
<?php } ?>            
          </h2>
          <p class="summary"><?php echo htmlspecialchars( $article->summary )?></p>
        </li>

<?php } ?>

      </ul>

      <p><?php echo $results['totalRows']?> article<?php echo ( $results['totalRows'] != 1 ) ? 's' : '' ?> in total.</p>

      <p><a href="./">Return to Homepage</a></p>

<?php include "templates/include/footer.php" ?>

To start with, we've modified the <h1> heading so that it displays the value of $results['pageHeading'], rather than just "Article Archive". This is because our new archive() function dynamically creates the page heading, depending on the category being viewed.

Next, we've added some code that checks whether we're displaying an archive for a particular category — as opposed to an archive of all articles — by looking for a $results['category'] variable. If the variable is found then we're displaying a category archive, so the code displays a heading containing the category's description.

Further down the template, within the loop that displays each article, we've made one last addition. This new code is triggered if we're displaying an archive of all articles — rather than a category archive — and if the current article has a category. In this scenario, the code adds a <span> containing the category name, linked to the category archive so the visitor can explore more articles in the category.

3. viewArticle.php

viewArticle.php is the template that displays a single article page. Here's the changed file — replace your old cms/templates/viewArticle.php file with this code:

<?php include "templates/include/header.php" ?>

      <h1 style="width: 75%;"><?php echo htmlspecialchars( $results['article']->title )?></h1>
      <div style="width: 75%; font-style: italic;"><?php echo htmlspecialchars( $results['article']->summary )?></div>
      <div style="width: 75%;"><?php echo $results['article']->content?></div>
      <p class="pubDate">Published on <?php echo date('j F Y', $results['article']->publicationDate)?>
<?php if ( $results['category'] ) { ?>
        in <a href="./?action=archive&amp;categoryId=<?php echo $results['category']->id?>"><?php echo htmlspecialchars( $results['category']->name ) ?></a>
<?php } ?>
      </p>

      <p><a href="./">Return to Homepage</a></p>

<?php include "templates/include/footer.php" ?>

As you can see, we've just made one small addition to this file that displays the article's category at the bottom of the article. The category name is linked to the corresponding category archive page.

4. The stylesheet

We need to make a few small tweaks to the stylesheet file, style.css, in order to style the category names and descriptions in the homepage and archive page.

Here's the new style.css file with the changes highlighted. Save it over your old style.css file in your cms folder:

/* Style the body and outer container */

body {
  margin: 0;
  color: #333;
  background-color: #00a0b0;
  font-family: "Trebuchet MS", Arial, Helvetica, sans-serif;
  line-height: 1.5em;
}

#container {
  width: 960px;
  background: #fff;
  margin: 20px auto;
  padding: 20px;
  -moz-border-radius: 5px;
  -webkit-border-radius: 5px;
  border-radius: 5px;
}


/* The logo and footer */

#logo {
  display: block;
  width: 300px;
  padding: 0 660px 20px 0;
  border: none;
  border-bottom: 1px solid #00a0b0;
  margin-bottom: 40px;
}

#footer {
  border-top: 1px solid #00a0b0;
  margin-top: 40px;
  padding: 20px 0 0 0;
  font-size: .8em;
}


/* Headings */

h1 {
  color: #eb6841;
  margin-bottom: 30px;
  line-height: 1.2em;
}

h2, h2 a {
  color: #edc951;
}

h2 a {
  text-decoration: none;
}

h3.categoryDescription {
  margin-top: -20px;
  margin-bottom: 40px;
}


/* Article headlines */

#headlines {
  list-style: none;
  padding-left: 0;
  width: 75%;
}

#headlines li {
  margin-bottom: 2em;
  clear: both;
}

.pubDate {
  font-size: .8em;
  color: #eb6841;
  text-transform: uppercase;
}

#headlines .pubDate {
  display: block;
  width: 100px;
  padding-top: 4px;
  float: left;
  font-size: .5em;
  vertical-align: middle;
}

#headlines.archive .pubDate {
  width: 130px;
}

.summary {
  padding-left: 100px;
}

#headlines.archive .summary {
  padding-left: 130px;
}

.category {
  font-style: italic;
  font-weight: normal;
  font-size: 60%;
  color: #999;
  display: block;
  line-height: 2em;
}

.category a {
  color: #999;
  text-decoration: underline;
}


/* "You are logged in..." header on admin pages */

#adminHeader {
  width: 940px;
  padding: 0 10px;
  border-bottom: 1px solid #00a0b0;
  margin: -30px 0 40px 0;
  font-size: 0.8em;
}


/* Style the form with a coloured background, along with curved corners and a drop shadow */

form {
  margin: 20px auto;
  padding: 40px 20px;
  overflow: auto;
  background: #fff4cf;
  border: 1px solid #666;
  -moz-border-radius: 5px;
  -webkit-border-radius: 5px;  
  border-radius: 5px;
  -moz-box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
  -webkit-box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
  box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
}


/* Give form elements consistent margin, padding and line height */

form ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

form ul li {
  margin: .9em 0 0 0;
  padding: 0;
}

form * {
  line-height: 1em;
}


/* The field labels */

label {
  display: block;
  float: left;
  clear: left;
  text-align: right;
  width: 15%;
  padding: .4em 0 0 0;
  margin: .15em .5em 0 0;
}


/* The fields */

input, select, textarea {
  display: block;
  margin: 0;
  padding: .4em;
  width: 80%;
}

input, textarea, .date {
  border: 2px solid #666;
  -moz-border-radius: 5px;
  -webkit-border-radius: 5px;    
  border-radius: 5px;
  background: #fff;
}

input {
  font-size: .9em;
}

select {
  padding: 0;
  margin-bottom: 2.5em;
  position: relative;
  top: .7em;
}

textarea {
  font-family: "Trebuchet MS", Arial, Helvetica, sans-serif;
  font-size: .9em;
  height: 5em;
  line-height: 1.5em;
}

textarea#content {
  font-family: "Courier New", courier, fixed;
}
  

/* Place a border around focused fields */

form *:focus {
  border: 2px solid #7c412b;
  outline: none;
}


/* Display correctly filled-in fields with a green background */

input:valid, textarea:valid {
  background: #efe;
}


/* Submit buttons */

.buttons {
  text-align: center;
  margin: 40px 0 0 0;
}

input[type="submit"] {
  display: inline;
  margin: 0 20px;
  width: 12em;
  padding: 10px;
  border: 2px solid #7c412b;
  -moz-border-radius: 5px;
  -webkit-border-radius: 5px;  
  border-radius: 5px;
  -moz-box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
  -webkit-box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
  box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
  color: #fff;
  background: #ef7d50;
  font-weight: bold;
  -webkit-appearance: none;
}

input[type="submit"]:hover, input[type="submit"]:active {
  cursor: pointer;
  background: #fff;
  color: #ef7d50;
}

input[type="submit"]:active {
  background: #eee;
  -moz-box-shadow: 0 0 .5em rgba(0, 0, 0, .8) inset;
  -webkit-box-shadow: 0 0 .5em rgba(0, 0, 0, .8) inset;
  box-shadow: 0 0 .5em rgba(0, 0, 0, .8) inset;
}


/* Tables */

table {
  width: 100%;
  border-collapse: collapse;
}

tr, th, td {
  padding: 10px;
  margin: 0;
  text-align: left;
}

table, th {
  border: 1px solid #00a0b0;
}

th {
  border-left: none;
  border-right: none;
  background: #ef7d50;
  color: #fff;
  cursor: default;
}

tr:nth-child(odd) {
  background: #fff4cf;
}

tr:nth-child(even) {
  background: #fff;
}

tr:hover {
  background: #ddd;
  cursor: pointer;
}


/* Status and error boxes */

.statusMessage, .errorMessage {
  font-size: .8em;
  padding: .5em;
  margin: 2em 0;
  -moz-border-radius: 5px;
  -webkit-border-radius: 5px;
  border-radius: 5px; 
  -moz-box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
  -webkit-box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
  -box-shadow: 0 0 .5em rgba(0, 0, 0, .8);
}

.statusMessage {
  background-color: #2b2;
  border: 1px solid #080;
  color: #fff;
}

.errorMessage {
  background-color: #f22;
  border: 1px solid #800;
  color: #fff;
}

These tweaks include adding an h3.categoryDescription selector to style the category description at the top of the archive page; tweaking the #headlines styles to accommodate category names; and adding a .category class for displaying the category names in the homepage and archive page.

Step 7: Modify the back-end templates

Categories List screenshot

The last step is to tweak the back-end admin templates. We need to add a couple of templates to handle listing, adding, editing and deleting article categories. We also need to alter the Articles List page to display article categories; modify the article edit form so that the administrator can assign a category to an article; and tweak the admin page header to include an Edit Categories menu option.

Add listCategories.php

First, we'll create listCategories.php, the template to display the list of categories in the database. This is very similar to listArticles.php, the article list template.

Here's the code — save it as listCategories.php inside your cms/templates/admin folder:

<?php include "templates/include/header.php" ?>
<?php include "templates/admin/include/header.php" ?>

      <h1>Article Categories</h1>

<?php if ( isset( $results['errorMessage'] ) ) { ?>
        <div class="errorMessage"><?php echo $results['errorMessage'] ?></div>
<?php } ?>


<?php if ( isset( $results['statusMessage'] ) ) { ?>
        <div class="statusMessage"><?php echo $results['statusMessage'] ?></div>
<?php } ?>

      <table>
        <tr>
          <th>Category</th>
        </tr>

<?php foreach ( $results['categories'] as $category ) { ?>

        <tr onclick="location='admin.php?action=editCategory&amp;categoryId=<?php echo $category->id?>'">
          <td>
            <?php echo $category->name?>
          </td>
        </tr>

<?php } ?>

      </table>

      <p><?php echo $results['totalRows']?> categor<?php echo ( $results['totalRows'] != 1 ) ? 'ies' : 'y' ?> in total.</p>

      <p><a href="admin.php?action=newCategory">Add a New Category</a></p>

<?php include "templates/include/footer.php" ?>

This template is pretty straightforward. It includes the standard page header and admin header files, displays an "Article Categories" header along with any error or status message, then displays a table of categories in the database. It loops through each category in the $results['categories'] array, outputting a table row containing the category name. The table row is linked to admin.php?action=editCategory, passing in the category ID, so the administrator can edit the category by clicking the table row.

Finally, the template displays the total number of categories in the database, adds a link to let the administrator add a new category, and includes the page footer template.

Add editCategory.php

editCategory.php displays the category edit form, allowing the administrator to add a new category or edit an existing category. It follows the same basic pattern as editArticle.php, although it's a bit simpler.

Save the following code as editCategory.php inside your cms/templates/admin folder:

<?php include "templates/include/header.php" ?>
<?php include "templates/admin/include/header.php" ?>

      <h1><?php echo $results['pageTitle']?></h1>

      <form action="admin.php?action=<?php echo $results['formAction']?>" method="post">
        <input type="hidden" name="categoryId" value="<?php echo $results['category']->id ?>"/>

<?php if ( isset( $results['errorMessage'] ) ) { ?>
        <div class="errorMessage"><?php echo $results['errorMessage'] ?></div>
<?php } ?>

        <ul>

          <li>
            <label for="name">Category Name</label>
            <input type="text" name="name" id="name" placeholder="Name of the category" required autofocus maxlength="255" value="<?php echo htmlspecialchars( $results['category']->name )?>" />
          </li>

          <li>
            <label for="description">Description</label>
            <textarea name="description" id="description" placeholder="Brief description of the category" required maxlength="1000" style="height: 5em;"><?php echo htmlspecialchars( $results['category']->description )?></textarea>
          </li>

        </ul>

        <div class="buttons">
          <input type="submit" name="saveChanges" value="Save Changes" />
          <input type="submit" formnovalidate name="cancel" value="Cancel" />
        </div>

      </form>

<?php if ( $results['category']->id ) { ?>
      <p><a href="admin.php?action=deleteCategory&amp;categoryId=<?php echo $results['category']->id ?>" onclick="return confirm('Delete This Category?')">Delete This Category</a></p>
<?php } ?>

<?php include "templates/include/footer.php" ?>

The template includes the usual header files, then displays the contents of $results['pageTitle'], which will either be "New Article Category" or "Edit Article Category". It then creates a form that submits to admin.php, passing the value of $results['formAction'] ("newCategory" or "editCategory") in the action parameter.

The form itself includes a hidden field, categoryId, to track the ID of the currently-edited article (if any); any error message that needs to be displayed; fields for the category name and description; and Save Changes and Cancel buttons.

Finally, the template includes a link to delete the currently-edited category, as well as the page footer template.

Tweak listArticles.php

We need to make some small changes to the Articles List template to accommodate categories. Here's the new template with changes highlighted — save this code over your old cms/templates/admin/listArticles.php file:

<?php include "templates/include/header.php" ?>
<?php include "templates/admin/include/header.php" ?>

      <h1>All Articles</h1>

<?php if ( isset( $results['errorMessage'] ) ) { ?>
        <div class="errorMessage"><?php echo $results['errorMessage'] ?></div>
<?php } ?>


<?php if ( isset( $results['statusMessage'] ) ) { ?>
        <div class="statusMessage"><?php echo $results['statusMessage'] ?></div>
<?php } ?>

      <table>
        <tr>
          <th>Publication Date</th>
          <th>Article</th>
          <th>Category</th>
        </tr>

<?php foreach ( $results['articles'] as $article ) { ?>

        <tr onclick="location='admin.php?action=editArticle&amp;articleId=<?php echo $article->id?>'">
          <td><?php echo date('j M Y', $article->publicationDate)?></td>
          <td>
            <?php echo $article->title?>
          </td>
          <td>
            <?php echo $results['categories'][$article->categoryId]->name?>
          </td>
        </tr>

<?php } ?>

      </table>

      <p><?php echo $results['totalRows']?> article<?php echo ( $results['totalRows'] != 1 ) ? 's' : '' ?> in total.</p>

      <p><a href="admin.php?action=newArticle">Add a New Article</a></p>

<?php include "templates/include/footer.php" ?>

As you can see, we've added a new Category column to the articles list. Within the loop to display the article rows, the code looks up the article's associated Category object by ID in the $results['categories'] array, and outputs the category's name.

In this template — as well as in editArticle.php — we've replaced the old hard-coded adminHeader div with a templates/admin/include/header.php include at the top of the template. This makes it easier for us to tweak the admin header, which we'll do in a moment.

Tweak editArticle.php

We also need to modify editArticle.php — the article edit form — to allow the administrator to assign a category to an article.

Here's the new template with changes highlighted. Replace your existing cms/templates/admin/editArticle.php file with this one:

<?php include "templates/include/header.php" ?>
<?php include "templates/admin/include/header.php" ?>

      <h1><?php echo $results['pageTitle']?></h1>

      <form action="admin.php?action=<?php echo $results['formAction']?>" method="post">
        <input type="hidden" name="articleId" value="<?php echo $results['article']->id ?>"/>

<?php if ( isset( $results['errorMessage'] ) ) { ?>
        <div class="errorMessage"><?php echo $results['errorMessage'] ?></div>
<?php } ?>

        <ul>

          <li>
            <label for="title">Article Title</label>
            <input type="text" name="title" id="title" placeholder="Name of the article" required autofocus maxlength="255" value="<?php echo htmlspecialchars( $results['article']->title )?>" />
          </li>

          <li>
            <label for="summary">Article Summary</label>
            <textarea name="summary" id="summary" placeholder="Brief description of the article" required maxlength="1000" style="height: 5em;"><?php echo htmlspecialchars( $results['article']->summary )?></textarea>
          </li>

          <li>
            <label for="content">Article Content</label>
            <textarea name="content" id="content" placeholder="The HTML content of the article" required maxlength="100000" style="height: 30em;"><?php echo htmlspecialchars( $results['article']->content )?></textarea>
          </li>

          <li>
            <label for="categoryId">Article Category</label>
            <select name="categoryId">
              <option value="0"<?php echo !$results['article']->categoryId ? " selected" : ""?>>(none)</option>
            <?php foreach ( $results['categories'] as $category ) { ?>
              <option value="<?php echo $category->id?>"<?php echo ( $category->id == $results['article']->categoryId ) ? " selected" : ""?>><?php echo htmlspecialchars( $category->name )?></option>
            <?php } ?>
            </select>
          </li>

          <li>
            <label for="publicationDate">Publication Date</label>
            <input type="date" name="publicationDate" id="publicationDate" placeholder="YYYY-MM-DD" required maxlength="10" value="<?php echo $results['article']->publicationDate ? date( "Y-m-d", $results['article']->publicationDate ) : "" ?>" />
          </li>


        </ul>

        <div class="buttons">
          <input type="submit" name="saveChanges" value="Save Changes" />
          <input type="submit" formnovalidate name="cancel" value="Cancel" />
        </div>

      </form>

<?php if ( $results['article']->id ) { ?>
      <p><a href="admin.php?action=deleteArticle&amp;articleId=<?php echo $results['article']->id ?>" onclick="return confirm('Delete This Article?')">Delete This Article</a></p>
<?php } ?>

<?php include "templates/include/footer.php" ?>

Here we've added an Article Category field to the form. This is a select menu containing all the categories in the database, pulled from the $results['categories'] array. Each option element stores the category's ID in its value attribute, and displays the category's name. If editing an existing article, the code also checks if the current option's category ID matches the article's current category ID; if it does then it adds the selected attribute to pre-select the option.

The select element also includes a "(none)" option at the top of the list, with a value of zero. This allows the administrator to create an article that isn't associated with any category.

Tweak the admin header

The last thing we need to change is the header displayed in the CMS admin pages. Currently this includes a small menu with Edit Articles and Log Out links. We need to add an Edit Categories link to the menu so that the administrator can view, add, edit and delete categories.

In the existing CMS, this header is hard-coded into each of the admin templates, listArticles.php and editArticle.php. Rather than having to add the new option to each template, we'll move the header markup to a separate header.php file. We then only have to make the change — and any future header changes — in one place.

We've already changed the listArticles.php and editArticle.php templates to include this header file in the steps above, and our new listCategories.php and editCategories.php templates already include the header file too. So all we need to do is create the header file.

So create an include folder inside your cms/templates/admin folder, and save the following code as header.php inside this new include folder:

      <div id="adminHeader">
        <h2>Widget News Admin</h2>
        <p>You are logged in as <b><?php echo htmlspecialchars( $_SESSION['username']) ?></b>. <a href="admin.php?action=listArticles">Edit Articles</a> <a href="admin.php?action=listCategories">Edit Categories</a> <a href="admin.php?action=logout"?>Log Out</a></p>
      </div>

As you can see, this is the markup that was previously hard-coded in the listArticles.php and editArticle.php templates, with a new "Edit Categories" option that links to admin.php?action=listCategories.

Try it out!

Edit Article screenshot

Congratulations! You've now enhanced your CMS to support article categories. Now you're ready to try it out. Follow these steps:

  1. Log in
    Open your browser and visit the base URL of your CMS — for example, http://localhost/cms/. Click the Site Admin link in the footer, and log in.
  2. Create categories
    Click the Edit Categories link in the menu at the top of the page to view the list of categories, which will be empty to start with. Click Add a New Category at the bottom of the page to add new categories. You can also edit existing categories by clicking them in the list.
  3. Assign articles to categories
    Now click Edit Articles at the top of the page to add and edit your articles. When you create a new article, or edit an existing article, you can put the article into one of your categories using the Article Category menu in the form.
  4. View the results
    Click the Widget News logo at the top of the page to view the site front-end. Notice the category name that appears below each article's headline. You can click the category name to browse all articles in that category. Try clicking an article headline to view the article — notice that the category name appears at the end of the article. Again, you can click the category name to browse that category's archive page.
  5. You can also try out the demo on our server too! (Bear in mind that the demo is read-only, so you can't save changes to articles or categories.)

    Summary

    In this tutorial, you've taken the original content management system from my first tutorial and extended it to support article categories. You've:

    • Modified the MySQL database to add a categories table, as well as a categoryId field in the articles table.
    • Created a new Category PHP class to store and retrieve categories.
    • Modified the Article class to support the categoryId field, as well as retrieve a list of articles in a given category.
    • Altered the index.php script so that it can display category archives, as well as display category names on the homepage, archive, and View Article pages.
    • Modified the admin.php script to allow the administrator to list, add, edit and delete categories, as well as assign categories to articles.
    • Modified the front-end templates and stylesheet to display article categories on the homepage, archive pages and article pages.
    • Added back-end templates to handle category listing and editing, and tweaked the article list template and edit form so that the administrator can put articles into categories. You also tweaked the admin header to include an "Edit Categories" option.

    Now that your CMS has been enhanced with categories, you can use it to create more varied websites that have several different sections of content. Enjoy!

    What other features would you like to add to this CMS? A visitor comments system? Pagination on the homepage and article pages? Pagination within articles themselves? Post your feature request in the comments below, and maybe I'll write a tutorial on it next time! -Matt

    Learn PHP With Ease!

    Written by Matt Doyle — ELATED's resident Web programming expert — Beginning PHP 5.3 is a complete introduction to PHP, covering everything in these tutorials and lots more besides. Find out how to:

    • Set up PHP on your computer
    • Use strings, arrays, functions and objects
    • Create interactive Web forms
    • Handle cookies and sessions
    • Work with files on the server
    • Build database-driven sites with MySQL
    • Send emails from your scripts
    • Create images on the fly with PHP
    • Work with regular expressions
    • Write robust, secure PHP applications

    ...and lots more!

    “What a pleasure it's been spending hours and hours studying PHP with this magical book.” — Lulio, Florida
    “The book is not only great for learning, but I find myself using it constantly as a reference as well!” — David A. Stoltz

    Buy Beginning PHP 5.3 now from Amazon.comBeginning PHP 5.3 or Amazon.co.ukBeginning PHP 5.3.

    Follow Elated

    Related articles

    Responses to this article

    20 most recent responses (oldest first):

    03-Mar-12 09:38
    Also, how do you add comments for each article?
    03-Mar-12 10:25
    When I edit an article from my dashboard, in publication date, i'm having to give a date ahead.

    Example- i need to give 4 feb 2012 to display 3 feb 2012...
    03-Mar-12 10:27
    Also, when i edit article, some wierd \\\\\\\\ start appearing...Please help...
    05-Mar-12 00:29
    @adityasaky: You'll find answers to all of your questions by searching through the topic for the original tutorial:

    http://www.elated.com/forums/topic/5114/
    05-Mar-12 13:26
    Was just thinking, another useful feature would be allowing customers to post to testimonials. ie, Have a special post page which allows posting under a specific categoryId, with complete sanitation of the input. This no doubt, becomes very dangerous.
    26-Mar-12 20:47
    @Matt
    hi matt, hope u doing good.
    i went through the forum , to check wheather my question was answered before.

    Our CMS get the news articles first. order by date DESC.
    but what about if publish 10 articles a day and i want that the news article displayed at the top of the page by time. For example i published 10 articles today, date is same. But different hours and i want to see the news articles by time (hour) displayed at the top.

    As we have already got date field in our database, i wonder wheather i could modify or need another additional field which holds my timestamp.
    headlines.
    >Microsoft anounces IE 9 date published and time.
    >Microsoft anounces IE 10 date published and time.
    >Microsoft anounces IE 11 date published and time.
    Thanks
    jj
    04-Apr-12 12:49
    Hello there,
    I need someones brilliant knowledge and mind to help me out!

    The thing is, I want to display all articles and categories as it is in this tutorial, but i want to exclude the articles and categories with the ID 1 and ID 2 (both modified to the same ID in DB).

    Beginner as I am, i tried this in Article.php:

    public static function getList( $numRows=1000000, $categoryId=null, $order="publicationDate DESC" ) {
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $categoryClause = $categoryId ? "WHERE categoryId = :categoryId" : "";
    $sql = "SELECT SQL_CALC_FOUND_ROWS *, UNIX_TIMESTAMP(publicationDate) AS publicationDate
    FROM articles $categoryClause [b]WHERE (id > 2)[/b]
    ORDER BY " . mysql_escape_string($order) . " LIMIT :numRows";


    It works in the homepage.php and Article.php but not when i'm accessing a category, like 'images'.

    What did I do wrong this time?
    04-Apr-12 12:51
    From the top of my head your code seems fine. but remember that categories are handled by categories.php in classes, you should put that code in there
    jj
    04-Apr-12 19:23
    @metalsniper63

    Hmm.. Yes sir, you are totally correct with the 'categories.php'-thingy.

    But,
    Instead of manipulating the DB in the wrong way, and have a inflexible website with a "not so good idea after all", I decided to just create a new function like Categories, but call it Info. And it works

    The only thing now is that I can not get data to display without the foreach{}.

    I've got the code:

    function viewInfo() {
    $results = array();
    $data = Info::getList();
    $results['staticinfo'] = $data['results'];
    $results['pageTitle'] = "Info | Kanske";
    require( TEMPLATE_PATH . "/viewInfo.php" );}

    in index.php.

    Its working, but as i'm a real beginner (this is my absolute first PHP project), I have a hard time gettin' my head around it, hehe.

    Well, if a smart person with allot of knowledge sees this, give me a hint or something, caus I'm stuck xD

    Best regards,
    JJ
    05-Apr-12 05:38
    @mubuuba: Modify your publicationDate database column to be a datetime type instead of date:

    http://dev.mysql.com/doc/refman/5.5/en/datetime.html

    Then modify the calls to the PHP date() function inside the templates (homepage.php etc) to also display the time in the format you want, as well as the date:

    http://php.net/manual/en/function.date.php
    05-Apr-12 06:21
    @jj: I don't totally understand what you want to do, but your code looks like it will run OK. Maybe you could post the problem along with your complete code in a new topic, and we can take a closer look:

    http://www.elated.com/forums/authoring-and-programming/topic/new/
    hch
    16-Apr-12 20:05
    Hi Matt,
    Thank you very much for the tutorials.

    I have several days trying to upload images from editArticle but I could not. It seems that I can not change the enctype to multipart / form-data.

    I hope you can help me with this.
    Greetings and Thank You!
    04-May-12 05:05
    @hch: This is how you do it:


    <form enctype="multipart/form-data" ...>
    12-Jun-12 11:22
    Hi, how can you make it so that each article can have multiple categories EG sports, News. and Computing,news. So that both articles would appear in news but only then appear in the sub category they belong, I need something that can have up to 5 categories. Any help appreciated.

    Ian
    15-Jun-12 03:51
    @snookian: Many ways to do that. If you're happy with a maximum of 5 cats per article then I'd probably just add 5 fields to your articles table (category1Id, category2Id, category3Id, category4Id, category5Id). Otherwise you'll need an "articleCategories" link table to link each article with its categories in the categories table, which becomes a bit more complex.
    27-Jun-12 02:16
    can you explain with xampp?please...
    04-Dec-12 09:11
    Hi, i've been implementing this tutorial into mysite and all the php is working fine, however I have two versions of my site one for mobile one for desktop so if a mobile browser is detected it will go to the mobile verison of my site now ive modified all the template files to suite my mobile design problem now is the desktop version is using the same template files im new to templating with php how would I go about sorting this out?
    18-Dec-12 18:50
    @lyall: I'd probably edit config.php and replace the TEMPLATE_PATH constant with TEMPLATE_PATH_DESKTOP and TEMPLATE_PATH_MOBILE. Then add a simple getTemplatePath() utility function to config.php that detects the browser type and returns the appropriate template path. Then replace all instances of TEMPLATE_PATH in the code with getTemplatePath().
    30-Apr-13 08:15
    @matt, Please help! Im super stumped. I want the description for each category to appear once you have selected the category.
    So i want a input box under each category drop down list like so:



    <li>
    <label for="categoryId">Article Category</label>
    <select name="categoryId">
    <option value="0"<?php echo !$results['article']->categoryId ? " selected" : ""?>>(none)</option>
    <?php foreach ( $results['categories'] as $category ) { ?>
    <option value="<?php echo $category->id?>"<?php echo ( $category->id == $results['article']->categoryId ) ? " selected" : ""?>><?php echo htmlspecialchars( $category->name )?></option>
    <?php } ?>
    </select>
    </li>

    <li>
    <label for="description">Description</label>
    <input type="text" name="description" id="description" value="<?php echo $results['category']->description ?>" />
    </li>



    And then when you select reviews for example the description i put for reviews will appear in the description field. Any help getting me towards my goal will be great.

    (I want/need this in the new article page/form)

    Ian

    [Edited by snookian on 30-Apr-13 09:49]
    13-Jun-13 08:06
    Hello Sir I tried combining the tutorial for image upload and category upload but is not working. Having debugged it, I discovered that the object has not extension property. Please tell me what to do

    View all 40 responses »

    Post a response

    Want to add a comment, or ask a question about this article? Post a response.

    To post responses you need to be a member. Not a member yet? Signing up is free, easy and only takes a minute. Sign up now.

    Top of Page