How to Add Image Uploading to Your CMS

Learn how to modify a PHP and MySQL content management system so that you can upload an image for each article. Full CMS code download included.

How to Add Image Uploading to Your CMS

In my tutorial Build a CMS in an Afternoon with PHP and MySQL, I showed how to build a simple but useful content management system with PHP and MySQL. I also showed how to extend the CMS to allow article categories.

In this tutorial, you'll look at another way to extend the CMS. You'll take the original CMS code, and modify it so that the administrator can upload an image for each article. Then, when a visitor views an article page, the CMS will display the image at the start of the article. In addition, our CMS will generate a smaller thumbnail version of each article image, and display this thumbnail next to each article headline in the homepage and article archive pages.

You can see the finished result by pressing the View Demo button above. Notice the thumbnail images next to each article headline. Click a headline or thumbnail to view the corresponding article, along with the full-size article image.

The plan

We'll start with the original CMS code from Build a CMS in an Afternoon with PHP and MySQL, and modify it to include the image upload feature. Here are the steps we'll need to carry out:

  1. Create a couple of folders to store the article images
  2. Alter various image-related settings to the CMS config file
  3. Modify the database to store the image filename extensions
  4. Modify the Article class to handle images
  5. Modify admin.php to handle image upload and deletion
  6. Modify the front-end templates to display the article images and thumbnails
  7. Modify the back-end article edit form to let the administrator upload an image, view the article image, and delete the article image
  8. Tweak the CMS stylesheet to style the article images and the new elements in the article edit form

Ready? Let's get started!

Step 1: Create the image folders

The first thing to do is create a couple of folders in your website to store the full-size and thumbnail article images.

Open up the existing cms folder and you'll see an images folder containing the sample logo.jpg image. Inside this images folder, create an articles folder. Then, inside the articles folder, create two more folders:

  • fullsize to store the full-size article images
  • thumb to store the smaller thumbnail versions of the article images

Next you need to give your web server user permission to create files in these two folders. Typically on a Linux or Mac system, you need to change the permissions to 777, like this:

$ cd images/articles/
$ chmod 777 fullsize
$ chmod 777 thumb

If your CMS is on a remote web server then you can usually set these permissions using your FTP software.

Giving a folder 777 permissions potentially makes the folder's contents writable by any user on the system. This isn't usually a problem if you're running the CMS on your desktop computer, or on a dedicated or VPS server. However, if you're running your CMS on a shared server then you may want to make your image folders more secure. Ask your web hosting support people for advice on this.

Windows servers handle permissions differently to Mac and Linux systems. Again, if you're unsure how to set up permissions on a Windows web server, speak to your web hosting support or IT people.

Step 2: Edit the config file

The next step is to add some image-related constants to the CMS config file. Open up the config.php file in the top-level cms folder, and add the new lines highlighted in the code below:

<?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" );
define( "ARTICLE_IMAGE_PATH", "images/articles" );
define( "IMG_TYPE_FULLSIZE", "fullsize" );
define( "IMG_TYPE_THUMB", "thumb" );
define( "ARTICLE_THUMB_WIDTH", 120 );
define( "JPEG_QUALITY", 85 );
require( CLASS_PATH . "/Article.php" );

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

set_exception_handler( 'handleException' );
?>

You've added the following constants:

  • ARTICLE_IMAGE_PATH defines the path to the article images folder, relative to the top-level CMS folder. (If you want to store your article images somewhere else, change this constant accordingly.)
  • IMG_TYPE_FULLSIZE defines a constant to represent the "full-size" image type. We'll use this in the code whenever we want to indicate a full-size image. This value ("fullsize") is also used to locate the full-size images folder (images/articles/fullsize), so if you use a different folder name, you'll want to update this constant too.
  • IMG_TYPE_THUMB does a similar job to IMG_TYPE_FULLSIZE, but represents the "thumbnail" image type instead.
  • ARTICLE_THUMB_WIDTH defines the width to use for the article thumbnail images, in pixels. Our image-handling code will use this value when generating the thumbnail versions of article images when they're uploaded.
  • JPEG_QUALITY defines the quality level to use when generating thumbnail versions of JPEG images. The value ranges from 0 to 100, where 100 is the best quality (but largest file size). 85 is a good compromise.

Step 3: Modify the database

Safe

You need to make one small change to the articles table in the CMS database. Open up the tables.sql file from the original CMS, and add the line highlighted in the code below:

DROP TABLE IF EXISTS articles;
CREATE TABLE articles
(
  id              smallint unsigned NOT NULL auto_increment,
  publicationDate date NOT NULL,                              # When the article was published
  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
  imageExtension  varchar(255) NOT NULL,                      # The filename extension of the article's full-size and thumbnail images

  PRIMARY KEY     (id)
);

This line adds a new field called imageExtension to the articles table. This field stores the filename extension of each article's uploaded image. For example, if the administrator uploads a PNG image then we'll store the value ".png" in the imageExtension field.

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, which is obviously not what you want.

So if you already have articles in your CMS database, you want to modify the articles table while retaining the existing data in the table. To do this, change your tables.sql file to the following:

ALTER TABLE articles ADD imageExtension varchar(255) NOT NULL AFTER content;

Applying the changes

To actually create your articles table (or add the new imageExtension field to your existing articles table, as appropriate), you need to load the tables.sql file into MySQL. To do this, follow the procedure described in Applying the changes in my last tutorial.

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      |
+---------------+
1 row 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    |                |
| title           | varchar(255)         | NO   |     | NULL    |                |
| summary         | text                 | NO   |     | NULL    |                |
| content         | mediumtext           | NO   |     | NULL    |                |
| imageExtension  | varchar(255)         | NO   |     | NULL    |                |
+-----------------+----------------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

mysql> 

Notice the new imageExtension field inside the articles table.

You've set up your CMS database so that it's ready to handle image uploads. Now you can start modifying the CMS code.

Step 4: Modify the Article class

Cogs

Next, we need to modify the Article class to handle article images. Here's the updated Article.php class file. I've highlighted the lines of code so you can see what's been added and changed. 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 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;

  /**
  * @var string The filename extension of the article's full-size and thumbnail images (empty string means the article has no image)
  */
  public $imageExtension = "";


  /**
  * 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['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'];
    if ( isset( $data['imageExtension'] ) ) $this->imageExtension = preg_replace ( "/[^\.\,\-\_\'\"\@\?\!\$ a-zA-Z0-9()]/", "", $data['imageExtension'] );
  }


  /**
  * 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 );
      }
    }
  }


  /**
  * Stores any image uploaded from the edit form
  *
  * @param assoc The 'image' element from the $_FILES array containing the file upload data
  */

  public function storeUploadedImage( $image ) {

    if ( $image['error'] == UPLOAD_ERR_OK )
    {
      // Does the Article object have an ID?
      if ( is_null( $this->id ) ) trigger_error( "Article::storeUploadedImage(): Attempt to upload an image for an Article object that does not have its ID property set.", E_USER_ERROR );

      // Delete any previous image(s) for this article
      $this->deleteImages();

      // Get and store the image filename extension
      $this->imageExtension = strtolower( strrchr( $image['name'], '.' ) );

      // Store the image

      $tempFilename = trim( $image['tmp_name'] ); 

      if ( is_uploaded_file ( $tempFilename ) ) {
        if ( !( move_uploaded_file( $tempFilename, $this->getImagePath() ) ) ) trigger_error( "Article::storeUploadedImage(): Couldn't move uploaded file.", E_USER_ERROR );
        if ( !( chmod( $this->getImagePath(), 0666 ) ) ) trigger_error( "Article::storeUploadedImage(): Couldn't set permissions on uploaded file.", E_USER_ERROR );
      }

      // Get the image size and type
      $attrs = getimagesize ( $this->getImagePath() );
      $imageWidth = $attrs[0];
      $imageHeight = $attrs[1];
      $imageType = $attrs[2];

      // Load the image into memory
      switch ( $imageType ) {
        case IMAGETYPE_GIF:
          $imageResource = imagecreatefromgif ( $this->getImagePath() );
          break;
        case IMAGETYPE_JPEG:
          $imageResource = imagecreatefromjpeg ( $this->getImagePath() );
          break;
        case IMAGETYPE_PNG:
          $imageResource = imagecreatefrompng ( $this->getImagePath() );
          break;
        default:
          trigger_error ( "Article::storeUploadedImage(): Unhandled or unknown image type ($imageType)", E_USER_ERROR );
      }

      // Copy and resize the image to create the thumbnail
      $thumbHeight = intval ( $imageHeight / $imageWidth * ARTICLE_THUMB_WIDTH );
      $thumbResource = imagecreatetruecolor ( ARTICLE_THUMB_WIDTH, $thumbHeight );
      imagecopyresampled( $thumbResource, $imageResource, 0, 0, 0, 0, ARTICLE_THUMB_WIDTH, $thumbHeight, $imageWidth, $imageHeight );

      // Save the thumbnail
      switch ( $imageType ) {
        case IMAGETYPE_GIF:
          imagegif ( $thumbResource, $this->getImagePath( IMG_TYPE_THUMB ) );
          break;
        case IMAGETYPE_JPEG:
          imagejpeg ( $thumbResource, $this->getImagePath( IMG_TYPE_THUMB ), JPEG_QUALITY );
          break;
        case IMAGETYPE_PNG:
          imagepng ( $thumbResource, $this->getImagePath( IMG_TYPE_THUMB ) );
          break;
        default:
          trigger_error ( "Article::storeUploadedImage(): Unhandled or unknown image type ($imageType)", E_USER_ERROR );
      }

      $this->update();
    }
  }


  /**
  * Deletes any images and/or thumbnails associated with the article
  */

  public function deleteImages() {

    // Delete all fullsize images for this article
    foreach (glob( ARTICLE_IMAGE_PATH . "/" . IMG_TYPE_FULLSIZE . "/" . $this->id . ".*") as $filename) {
      if ( !unlink( $filename ) ) trigger_error( "Article::deleteImages(): Couldn't delete image file.", E_USER_ERROR );
    }
    
    // Delete all thumbnail images for this article
    foreach (glob( ARTICLE_IMAGE_PATH . "/" . IMG_TYPE_THUMB . "/" . $this->id . ".*") as $filename) {
      if ( !unlink( $filename ) ) trigger_error( "Article::deleteImages(): Couldn't delete thumbnail file.", E_USER_ERROR );
    }

    // Remove the image filename extension from the object
    $this->imageExtension = "";
  }


  /**
  * Returns the relative path to the article's full-size or thumbnail image
  *
  * @param string The type of image path to retrieve (IMG_TYPE_FULLSIZE or IMG_TYPE_THUMB). Defaults to IMG_TYPE_FULLSIZE.
  * @return string|false The image's path, or false if an image hasn't been uploaded
  */

  public function getImagePath( $type=IMG_TYPE_FULLSIZE ) {
    return ( $this->id && $this->imageExtension ) ? ( ARTICLE_IMAGE_PATH . "/$type/" . $this->id . $this->imageExtension ) : false;
  }


  /**
  * 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 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, $order="publicationDate DESC" ) {
    $conn = new PDO( DB_DSN, DB_USERNAME, DB_PASSWORD );
    $sql = "SELECT SQL_CALC_FOUND_ROWS *, UNIX_TIMESTAMP(publicationDate) AS publicationDate FROM articles
            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() ) {
      $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, title, summary, content, imageExtension ) VALUES ( FROM_UNIXTIME(:publicationDate), :title, :summary, :content, :imageExtension )";
    $st = $conn->prepare ( $sql );
    $st->bindValue( ":publicationDate", $this->publicationDate, 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( ":imageExtension", $this->imageExtension, 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), title=:title, summary=:summary, content=:content, imageExtension=:imageExtension WHERE id = :id";
    $st = $conn->prepare ( $sql );
    $st->bindValue( ":publicationDate", $this->publicationDate, 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( ":imageExtension", $this->imageExtension, 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;
  }

}

?>

Here's a list of the changes we've made to the Article class:

A new $imageExtension property

This corresponds to the imageExtension field you added to the articles table in Step 3. This property is used to store the filename extension for the article's full-size and thumbnail images — for example, ".jpg" or ".png".

We also modified the constructor method, __construct(), to store the new $imageExtension property value in newly-created Article objects.

A new storeUploadedImage() method

We'll call this method from the admin.php script whenever the user uploads a new article image using the article edit form. Its job is to move the uploaded image to the fullsize images folder we created in Step 1, as well as generate a thumbnail version of the image and store it in the thumb folder.

The method accepts a single $image parameter. This should be the element in the PHP $_FILES superglobal array that contains all the information about the uploaded image file.

Each element in the $_FILES array stores the details of any file uploaded via a particular file upload field in the form. For example, if a submitted form contains two upload fields called "myPhoto" and "anotherPhoto", the $_FILES array will contain two elements: $_FILES['myPhoto'] and $_FILES['anotherPhoto']. Each element is itself an associative array containing information about the uploaded file, such as its filename and its size. Read more about the $_FILES array.

Here's how the storeUploadedImage() method works:

  1. Check for an upload error
    The first thing the method does is check that the 'error' element in the $image array equals the constant UPLOAD_ERR_OK. This indicates that the user uploaded an image, and that the upload was successful. If the upload went OK then the method starts to process the uploaded image; otherwise it does nothing.

    Other possible 'error' values include UPLOAD_ERR_NO_FILE (nothing was uploaded) and UPLOAD_ERR_CANT_WRITE (there was a problem writing the uploaded file to the server's hard disk). Here's a full list.

  2. Does the Article object have an ID?
    Assuming the file was uploaded OK, the method then makes sure that the Article object has an ID; in other words, that it has been saved to the database. This is important, because we're going to rename the image file using the article's ID in a moment, so that we can easily associate the image with the article. If the article doesn't have an ID then the method calls trigger_error() to display an error message and exit.
  3. Delete any previous image(s) for this article
    Next the method calls the Article::deleteImages() method to delete any existing full-size and thumbnail image files associated with the article. (We'll write this method in a moment.) We do this in order to keep the image folders clean, without any old, unused article images lying about. For example, if the article currently has a .png image uploaded, and the user uploads a new .jpg image, we want to make sure that the now-unused .png images are deleted from the folders.
  4. Get and store the image filename extension
    As you saw earlier, the $imageExtension property needs to store the filename extension of the article image. Here the method uses the strrchr() function to extract the filename extension — that is, everything after (and including) the final dot in the filename — and stores the result in $imageExtension, converted to lowercase with strtolower() for consistency.

    The 'name' element in the $_FILES['fieldname'] array contains the filename of the uploaded file.

  5. Store the image
    Now the method moves the actual uploaded image into the images/articles/fullsize folder. To do this, it first retrieves the path to the uploaded file from the $_FILES['fieldname']['tmp_name'] array element and stores it in $tempFilename. Typically this value is the path to the uploaded file in the server's temporary folder, such as /tmp/.

    You can change the temporary folder that PHP uses to store uploaded files by setting the upload_tmp_dir directive.

    Then the method calls is_uploaded_file() to check that the file in the temporary folder is indeed a file uploaded by PHP. This is a good security precaution to prevent sensitive system files accidentally being made public.

    Finally, the method calls the move_uploaded_file() function to move the uploaded image from the temporary folder to the images/articles/fullsize folder. This function takes two arguments: the path to the file in the temporary folder, and the path to move the file to. It's a good idea to use move_uploaded_file() to move uploaded files, since the function performs additional security checks on the file before moving it.

    Once the file's in place, the method calls the chmod() function to set the file's permissions to 0666. This ensures that the file can be read and written by anyone, including the web server user and any FTP user that may need to change or delete the article images. (As always, if you're on a shared web server then you might want to use more restrictive permissions than this.)

    To determine the destination path and filename to use, the method calls the Article::getImagePath() method. We'll look at this method in a moment.

  6. Get the image size and type
    The next job for storeUploadedImage() is to create the smaller thumbnail version of the uploaded image. First it calls getimagesize(), paassing in the path to the uploaded image, in order to get the image's width, height and format (GIF, JPEG or PNG), which it then stores in $imageWidth, $imageHeight and $imageType respectively.
  7. Load the image into memory
    Now that the method knows the type of image it's dealing with, it calls imagecreatefromgif(), imagecreatefromjpeg() or imagecreatefrompng() as appropriate to load the image into an image resource variable, $imageResource, for processing.
  8. Copy and resize the image to create the thumbnail
    Now it's time to create the thumbnail image. To do this, the method first computes the thumbnail height, $thumbHeight, based on the full-size image height ($imageHeight), the full-size image width ($imageWidth), and the desired thumbnail width (ARTICLE_THUMB_WIDTH).

    Next it calls imagecreatetruecolor() to create a blank image resource for storing the thumbnail image data, passing in the width and height of the image to create. It stores this resource in a $thumbResource variable.

    Finally, it calls imagecopyresampled() to create the smaller version of the uploaded image and store the result in the $thumbResource variable. It passes the following arguments to imagecopyresampled():

    • The image resource to store the resized image in ($thumbResource)
    • The uploaded image resource ($imageResource)
    • The (x,y) coordinates of top-left corner of the rectangle in $thumbResource to copy the image data to (0,0 — that is, the top left corner of the thumbnail)
    • The (x,y) coordinates of top-left corner of the rectangle in $imageResource to copy the image data from (0,0 — that is, the top left corner of the uploaded image)
    • The width and height of the rectangle in $thumbResource to copy the image data to ( ARTICLE_THUMB_WIDTH and $thumbHeight — that is, the entire width and height of the thumbnail)
    • The width and height of the rectangle in $imageResource to copy the image data from ( $imageWidth and $imageHeight — that is, the entire width and height of the uploaded image)
  9. Save the thumbnail
    Now that the method has created the thumbnail image data and stored it in $thumbResource, it needs to write the new thumbnail image to disk. To do this, it calls imagegif(), imagejpeg() or imagepng(), depending on the image type. It passes in both $thumbResource and the path to use for the thumbnail image. To get the path, it calls the getImagePath() method (which we'll look at in a moment), passing in our IMG_TYPE_THUMB constant to indicate that we want the path to the thumbnail.

    For JPEG images, the method also passes a third argument to indicate the JPEG quality level to use. It uses the value of the JPEG_QUALITY constant that we created in Step 2.

  10. Update the article record
    Finally, since the Article object's $imageExtension property may well have changed as a result of uploading the image, the method calls $this->update() to update the article record in the database.

A new deleteImages() method

The deleteImages() method is responsible for clearing out any image files associated with the current article. It's called by storeUploadedImage() before uploading a new image (as you saw in the previous section). In addition, it's called if the administrator specifically asks to delete the article's image and thumbnail via the Edit Article form. Finally, deleteImages() is also called when the article itself needs to be deleted.

deleteImages() calls PHP's glob() function to retrieve a list of all image files in both the images/articles/fullsize and images/articles/thumb folders that are named after the article's ID. For example, if the article's ID is 3, the call to glob() will return any image files called "3.gif", "3.jpg" or "3.png".

For each filename in the array returned by glob(), the method attempts to delete the file by calling PHP's unlink() function. If there's a problem deleting the file then it raises an error and exits.

Once all the image files have been deleted, deleteImages() sets the Article object's $imageExtension property to an empty string ("") to indicate that the article no longer has an uploaded image.

A new getImagePath() method

The last new method we've added to the Article class is getImagePath(), which returns the path to one of the two article images.

The method takes a single, optional argument, $type, that indicates whether it should return the path to the full-size image (IMG_TYPE_FULLSIZE, the default), or the thumbnail (IMG_TYPE_THUMB). It then uses the article's ID, along with the value stored in the article's $imageExtension property, to compute the path to the image file inside the images/articles/fullsize or images/articles/thumb folder.

For example, if getImagePath() is passed IMG_TYPE_THUMB as an argument, the article's ID is 3, and its $imageExtension property contains ".jpg", then the method will return the value "images/articles/thumb/3.jpg".

If either of the article's $id or $imageExtension properties are empty then getImagePath() simply returns false.

Changes to the insert() and update() methods

The final modifications to Article.php are inside the insert() and update() methods toward the end of the file. As you can see, we've modified the SQL INSERT and UPDATE statements to accommodate the new $imageExtension property so that the image extension is stored in the articles table. We've also added extra bindValue() calls to pass the property's value to the SQL statements.

Step 5: Modify the admin.php script

Lock

We now need to make some changes to admin.php, the back-end admin script, so that it can handle image uploads. Fortunately, we've already done most of the hard work in our Article class, so there aren't many changes that we need to make to this script.

Here's the modified admin.php file with the changes highlighted. Replace the code in your existing cms/admin.php file with this 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;
  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();
    if ( isset( $_FILES['image'] ) ) $article->storeUploadedImage( $_FILES['image'] );
    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;
    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 );
    if ( isset($_POST['deleteImage']) && $_POST['deleteImage'] == "yes" ) $article->deleteImages();
    $article->update();
    if ( isset( $_FILES['image'] ) ) $article->storeUploadedImage( $_FILES['image'] );
    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'] );
    require( TEMPLATE_PATH . "/admin/editArticle.php" );
  }

}


function deleteArticle() {

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

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


function listArticles() {
  $results = array();
  $data = Article::getList();
  $results['articles'] = $data['results'];
  $results['totalRows'] = $data['totalRows'];
  $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" );
}

?>

Let's work through these changes to admin.php:

  • Changes to newArticle()
    We've added a single line of code to the newArticle() function to handle image uploads. It checks that the 'image' element exists in the $_FILES array and, if it does exist, it calls the Article object's storeUploadedImage() method, passing in the $_FILES['image'] element, to store the image and create the thumbnail.

    As you'll see in a moment, the file upload field in the Edit Article form is called "image". Therefore we access the information about the uploaded image using the array element called $_FILES['image'].

  • Changes to editArticle()
    As with newArticle(), we've added a line of code that checks for an uploaded image and calls $article->storeUploadedImage() to store it and create the thumbnail. We've also added a line of code that checks if the user selected the "delete image" checkbox in the Edit Article form. If they did then we call $article->deleteImages() to delete any existing images associated with the article.
  • Changes to deleteArticle()
    Finally, we've added a single line of code to the deleteArticle() function that calls $article->deleteImages(). This ensures that any images associated with the article also get deleted.

Step 6: Modify the front-end templates

Article thumbnails screenshot

Our database and PHP code can now handle article images, but we need to make some changes both to the front-end templates that visitors see, and the back-end admin templates.

Let's start by altering the front-end templates to display the article images.

1. homepage.php

The homepage.php template displays the site's home page, including a list of recent articles. We'll modify this template to display each article's thumbnail next to the article in the list.

Here's the modified file with the new lines 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>
          </h2>
          <p class="summary">
            <?php if ( $imagePath = $article->getImagePath( IMG_TYPE_THUMB ) ) { ?>
              <a href=".?action=viewArticle&amp;articleId=<?php echo $article->id?>"><img class="articleImageThumb" src="<?php echo $imagePath?>" alt="Article Thumbnail" /></a>
            <?php } ?>
          <?php echo htmlspecialchars( $article->summary )?>
          </p>
        </li>

<?php } ?>

      </ul>

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

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

Here we've added some code inside the "summary" paragraph for each article. The code calls the article's getImagePath() method, passing in IMG_TYPE_THUMB to indicate that we want the path to the article's thumbnail. It then stores the path in the $imagePath variable. If this path is a non-false value then the article has a thumbnail image, so the code then constructs a link to view the article, wrapped around an img element that contains the thumbnail's path. We've added an articleImageThumb class to the thumbnail image so that we can style it in the stylesheet.

If $imagePath's value is false then the article doesn't have a thumbnail, so no markup is constructed.

Since our image paths are relative to the PHP scripts index.php and admin.php, we can also use the image paths as relative image URLs. If you wanted your image URLs to be different to the paths — for example, if you wanted to use URLs that are relative to the site root (such as /images/articles/), and/or paths that are relative to the root of the hard disk volume (for example, /home/me/mywebsite/htdocs/images/articles/) — then you'd need to create an ARTICLE_IMAGE_URL constant in config.php, as well as a corresponding Article::getImageUrl() method.

2. archive.php

archive.php displays the article archive page — that is, a list of all the articles in the database. We need to modify it in the same way as homepage.php, so that it displays thumbnails next to the article summaries.

Here's the modified archive.php file — replace your old cms/templates/archive.php file with this one:

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

      <h1>Article Archive</h1>

      <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>
          </h2>
          <p class="summary">
            <?php if ( $imagePath = $article->getImagePath( IMG_TYPE_THUMB ) ) { ?>
              <a href=".?action=viewArticle&amp;articleId=<?php echo $article->id?>"><img class="articleImageThumb" src="<?php echo $imagePath?>" alt="Article Thumbnail" /></a>
            <?php } ?>
          <?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" ?>

As you can see, this is the same change that we made to homepage.php. If an article has an image, its thumbnail will now appear next to the article summary in the archive page.

3. viewArticle.php

The viewArticle.php template displays an individual article page, containing the article's headline, summary and content. Just as we modified homepage.php and archive.php to display thumbnails next to the article summaries, we also need to enhance viewArticle.php so that it displays the full-size article images in the article pages.

Here's the changed template. As always, I've highlighted the new code. Save this code over your old cms/templates/viewArticle.php file:

<?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%; min-height: 300px;">
      <?php if ( $imagePath = $results['article']->getImagePath() ) { ?>
        <img id="articleImageFullsize" src="<?php echo $imagePath?>" alt="Article Image" />
      <?php } ?>
      <?php echo $results['article']->content?>
      </div>
      <p class="pubDate">Published on <?php echo date('j F Y', $results['article']->publicationDate)?></p>

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

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

This new code works in essentially the same way as the code added to the homepage and archive templates. It calls the article's getImagePath() method to get the path to the full-size article image. If the path isn't false then the article has an image, and the code inserts the appropriate img element into the markup. The img element is given an id of articleImageFullsize so that we can style it using CSS.

We also add a min-height of 300 pixels to the article content div. This prevents the image overlapping the page footer if the article happens to be very short.

Step 7: Modify the back-end templates

Edit Article screenshot

There's actually only one back-end admin template that we need to change, and that's the editArticle.php article edit form. Here's the new template with changes highlighted — save it over your existing cms/templates/admin/editArticle.php file:

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

      <script>

      // Prevents file upload hangs in Mac Safari
      // Inspired by http://airbladesoftware.com/notes/note-to-self-prevent-uploads-hanging-in-safari

      function closeKeepAlive() {
        if ( /AppleWebKit|MSIE/.test( navigator.userAgent) ) {
          var xhr = new XMLHttpRequest();
          xhr.open( "GET", "/ping/close", false );
          xhr.send();
        }
      }

      </script>

      <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=logout"?>Log out</a></p>
      </div>

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

      <form action="admin.php?action=<?php echo $results['formAction']?>" method="post" enctype="multipart/form-data" onsubmit="closeKeepAlive()">
        <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="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>

          <?php if ( $results['article'] && $imagePath = $results['article']->getImagePath() ) { ?>
          <li>
            <label>Current Image</label>
            <img id="articleImage" src="<?php echo $imagePath ?>" alt="Article Image" />
          </li>

          <li>
            <label></label>
            <input type="checkbox" name="deleteImage" id="deleteImage" value="yes"/ > <label for="deleteImage">Delete</label>
          </li>
          <?php } ?>

          <li>
            <label for="image">New Image</label>
            <input type="file" name="image" id="image" placeholder="Choose an image to upload" maxlength="255" />
          </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" ?>

Let's take a look at each of these changes in turn:

  • The closeKeepAlive() JavaScript function
    For some reason, Safari on the Mac has suffered from a long-standing issue whereby file uploads hang occasionally. (Find out more about this issue here and here.) Since this is quite annoying if you use Safari (as I do), I've added this little function that makes an Ajax request to a non-existent URL on the server, forcing Safari to close its connection to the server. This seems to fix the problem. My function is similar to this script, except that my function doesn't require the Prototype.js library.
  • Changes to the <form> tag
    We've added the attribute enctype="multipart/form-data" to the form element. This attribute is required whenever you create a form containing a file upload field. It lets the browser know that it needs to encode the form data as a multipart MIME stream containing different media types (in this case, text and image data).

    We've also attached the closeKeepAlive() function as a submit event handler to the form, so that the function runs when the form is submitted.
  • The article image and "delete" checkbox
    The next addition to the form displays the full-size image currently associated with the article (if any). As with the front-end templates, the PHP code calls the article's getImagePath() method to retrieve the image's path. If an image path was returned, we add an li element to the page containing a field label ("Current Image"), along with an img element linking to the image. We also include another li element containing a deleteImage checkbox. This lets the administrator delete any image(s) currently associated with the article.
  • The image upload field
    Last, but by no means least, we add the <input type="file"> upload field that allows the administrator to upload an image for this article. We give it a name attribute of "image", which means that we're able to access the uploaded file from our PHP code using $_FILES['image'] (see Step 4 earlier in the tutorial).

Step 8: Modify the stylesheet

Next we'll make some additions and changes to our CMS's stylesheet, style.css, in order to style the article images and thumbnails on the front end, as well as the new elements in the Edit Article form.

Here's the new style.css file with the changes highlighted. Replace your existing style.css file in your cms folder with this file:

/* 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;
}


/* Article headlines */

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

#headlines li {
  margin-bottom: 2em;
  overflow: hidden;
}

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

#headlines .pubDate {
  display: inline-block;
  width: 100px;
  font-size: .5em;
  vertical-align: middle;
}

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

#headlines .articleImageThumb {
  width: 120px;
  float: left;
  margin: 4px 20px 0 0;
  border: 1px solid #00a0b0;
}

.summary {
  padding-left: 100px;
}

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


/* Article pages */

#articleImageFullsize {
  width: 250px;
  float: left;
  margin: 4px 20px 10px 0;
  border: 1px solid #00a0b0;
}


/* "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;
  overflow: hidden;
}

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;
}

input[type="checkbox"] {
  display: inline-block;
  padding: 0;
  margin: 0 0 .8em 0;
  width: auto;
}

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;
}

#articleImage {
  border: 2px solid #666;
}

#deleteImage {
  clear: both;
}

label[for="deleteImage"] {
  float: none;
  display: inline;
}

input[type="file"] {
  float: left;
}
  

/* 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;
}

As you can see, we've added various rulesets to style the thumbnails in the homepage and archive pages; the full-size images in article pages; and the "delete image" checkbox, article image and file upload field inside the "Edit Article" form (which you added in Step 7).

On line 89, we've set the thumbnail width to 120 pixels to match the ARTICLE_THUMB_WIDTH setting in config.php. Similarly, on line 107 we've set the width of the full-size images in the article pages to 250 pixels. If you want to use different widths for the thumbnails and images then you need to change these two values, as well as the ARTICLE_THUMB_WIDTH setting.

The overflow: hidden rule that we've added to the #headlines li ruleset ensures that the floated thumbnail images stack vertically below each other, rather than side-by-side. Similarly, the overflow: hidden added to the form ul selector ensures that the floated file upload field doesn't interfere with the Save Changes and Cancel buttons below it. (We need to float this field so that it positions correctly across all browsers.)

overflow: hidden is a useful trick for situations where you want to clear a float using CSS.

Try it out!

Great stuff! You now have a CMS that can handle image uploads. To test your new CMS, 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. Upload some images
    Click an article in the All Articles list, or add a new article by clicking the Add a New Article link at the bottom of the page. In the New Article / Edit Article form, click the button next to the New Image label at the bottom of the form. Choose a file to upload, then click Save Changes to save your article edits and upload the image.
  3. View your images
    Click the Widget News logo at the top of the page to view the site. You should see thumbnail images next to the articles in the list. If you click the Article Archive link at the bottom of the page then you should also see the thumbnails there. Click a thumbnail (or article headline) to view the full article page, along with the full-size article image.
  4. Changing and deleting images
    Just to make sure everything works, try editing an article with an image and uploading a new image. This should then replace the previous article image and thumbnail. (Depending on your server and browser setup, you may need to clear your browser's cache and reload the page to see the new image.) You can also try clicking the Delete checkbox to remove an existing image from an article.

You can also try out the demo on our server too! The demo is read-only, so you can't upload or delete images, but you can see how the images look on the front-end, as well as in the back-end article edit form.

Summary

In this tutorial we've added an image upload feature to the content management system from my original tutorial. Here's what we did:

  • Created some folders within your CMS to store the article images and thumbnails
  • Added some constants to the config file to specify the path to the images folder, the width to use for article thumbnails, and other useful settings
  • Modified the MySQL database to add an imageExtension field, which tracks the filename extension of the image uploaded for each article
  • Modified the Article class to add the $imageExtension property, as well as methods to handle image uploads, image deletion, and retrieving image paths
  • Extended the admin.php admin script to allow uploading and deleting of article images
  • Altered the front-end templates, homepage.php, archive.php and viewArticle.php, to display the article thumbnails and full-size images as appropriate
  • Enhanced the article edit form, editArticle.php, to include the image upload field, as well as the currently-uploaded image and a "delete" image" checkbox, and
  • Tweaked the stylesheet, style.css, to style the article images and thumbnails, as well as the article edit form elements.

You can now use your CMS to publish articles illustrated with images. Now all you need to do is create some beautiful images! 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

[Flickr photo credits for sample images: Businessman, Cogs, Willy Wonka, Newspapers, Dollars.]

Follow Elated

Related articles

Responses to this article

20 most recent responses (oldest first):

21-Nov-12 20:28
@meTony: Depends how strict you want it to be. You could just compare the filename extension (the Article::storeUploadedImage() method shows how to get this) to the string ".jpg" or ".jpeg". Obviously this could be faked. Or you can use http://php.net/manual/en/function.getimagesize.php to inspect the image data itself.

@lafamosa: See http://www.elated.com/articles/add-article-categories-to-your-cms/

@jonathanzuniga: I'll see if I have time. Also see my hints earlier in this thread.

@jvt4: You mean on the front end? Certainly possible. jQuery's Ajax methods - especially load() - should do what you need. More info:

http://www.elated.com/articles/ajax-with-jquery-a-beginners-guide/

Cheers,
Matt
23-Nov-12 12:55
Yes I am trying to integrate front-end ajax. My steps so far are:

1.) Created a new page called "content.php" with a div "#content" called content which is supposed to initially load "homePage.php" inside of it using jQuery Ajax .load;

2.) Edited homePage.php's article-specific links to "viewArticle.php"

$('#getArticle').click( function() {
var data = { articleId=$article };
$('#content').load( "viewArticle.php", data );
} );


3.) Edited homePage.php's bottom link to "viewArchive.php"

$('#getArchive').click( function() {
var data = { ???NULL??? };
$('#content').load( "archive.php", data );
} );


Is this the right direction? It seems like it could work but seems like there's an easier way.

[Edited by jvt4 on 23-Nov-12 13:03]
17-Dec-12 15:51
@jvt4: Yes that seems like the right idea to me.
25-Apr-13 11:01
Matt, great job on the tutorials. I must say I have learned an enormous amount of php over the last few months due to using your CMS as a foundation.

However, I am having a bit of an issue when I move everything from my localhost to my mediatemple server. I have fixed a ton of the errors that I had but these two persist.

1) The dates bump one day back every time I add a new post. Also, when I update the post it bumps one day further back. I have changed the timezone to America/Chicago from the php website and have looked at a few of the UNIX time fixes and havent really found anything that worked yet.

2) I am getting an invalid argument error and header error when I try to delete a post. Here is the error that I get from the browser (this is for a church so Article was replaced by Sermon):

[i][b]Warning: Invalid argument supplied for foreach() in tylercookmedia.com/html/clients/agape/admin/classes/Sermon.php on line 162

Warning: Invalid argument supplied for foreach() in tylercookmedia.com/html/clients/agape/admin/classes/Sermon.php on line 167

Warning: Cannot modify header information - headers already sent by (output started at tylercookmedia.com/html/clients/agape/admin/classes/Sermon.php:162) in tylercookmedia.com/html/clients/agape/admin/add-sermon.php on line 145[/b]
[/i]

Here is the code from all of those lines and I am pretty much stuck as to what the problem is sense it works locally but not on the live server.

line 162

// Delete all fullsize images for this Sermon
foreach (glob(SERMON_IMAGE_PATH . "/" . IMG_TYPE_FULLSIZE . "/" . $this->id . ".*") as $filename) {
if ( !unlink( $filename ) ) trigger_error( "Sermon::deleteImages(): Couldn't delete image file.", E_USER_ERROR );
}


Line 167
 
// Delete all thumbnail images for this Sermon
foreach (glob( SERMON_IMAGE_PATH . "/" . IMG_TYPE_THUMB . "/" . $this->id . ".*") as $filename) {
if ( !unlink( $filename ) ) trigger_error( "Sermon::deleteImages(): Couldn't delete thumbnail file.", E_USER_ERROR );
}


Header Error on line 145 (im assuming that once the for each error is resolved this one will work itself out)


function deleteSermon() {

if ( !$sermon = Sermon::getById( (int)$_GET['sermonId'] ) ) {
header( "Location: add-sermon.php?error=sermoneNotFound" );
return;
}

$sermon->deleteImages();
$sermon->delete();
header( "Location: add-sermon.php?status=sermonDeleted" );
}


Any ideas on what could be causing this would be much appreciated.
30-Apr-13 00:54
@tylercook:

1) Make sure your PHP timezone is the same as the MySQL timezone on the server.

2) The "Cannot modify header information" error is merely a side-effect of the first error being reported.

You'll want to add some debugging to see what glob(SERMON_IMAGE_PATH . "/" . IMG_TYPE_FULLSIZE . "/" . $this->id . ".*") is actually returning. It should be an array. If it's not then you'll get that "Invalid argument supplied" error. My guess is it's returning FALSE which means there's a problem accessing the filesystem (eg permissions problem or file not found).
20-May-13 19:17
I used your CMS tutorial and then added the Category tutorial and everything worked great. When I went to add the images tutorial, I received error on the adding new articles page:

Warning: PDOStatement::execute(): SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens in /Applications/MAMP/htdocs/cms/classes/Article.php on line 292

Warning: PDOStatement::execute(): SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens in /Applications/MAMP/htdocs/cms/classes/Article.php on line 292

Warning: Cannot modify header information - headers already sent by (output started at /Applications/MAMP/htdocs/cms/classes/Article.php:292) in /Applications/MAMP/htdocs/cms/admin.php on line 133

I wasn't sure why this error was popping up. Any solution? They seem to work independently; however, when I use the code from the image uploaded and category, it pops up with errors.
31-May-13 17:05
Matt,
Is there any basic solution to help solve the problem I'm having with the above post.

The Category and Image upload don't seem to work together. I kept receiving errors.

Original Post:
I used your CMS tutorial and then added the Category tutorial and everything worked great. When I went to add the images tutorial, I received error on the adding new articles page:

Warning: PDOStatement::execute(): SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens in /Applications/MAMP/htdocs/cms/classes/Article.php on line 292

Warning: PDOStatement::execute(): SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens in /Applications/MAMP/htdocs/cms/classes/Article.php on line 292

Warning: Cannot modify header information - headers already sent by (output started at /Applications/MAMP/htdocs/cms/classes/Article.php:292) in /Applications/MAMP/htdocs/cms/admin.php on line 133

I wasn't sure why this error was popping up. Any solution? They seem to work independently; however, when I use the code from the image uploaded and category, it pops up with errors.
21-Jun-13 00:09
how to save title, summary Unicode format now
we can only saving content in Unicode format
15-Aug-13 14:48
thank you for these tutorials they are a good base for people to build upon im trying to figure out why this in particular isnt working, some are saying it's because of the categories here, idk, the images are being uploaded (fullsize and thumbnail) but it isnt being linked with the article, it doesnt show neither on the editing page nor on the article view page

edit: for some reason the .jpg .png .gif etc extensions arent being stored in the database, if i manually store the .jpg on the article i want it at it will work fine hmmm

edit: solved xD my mistake when overwritting stuff, forgot to include the caregoryId part on the sql statement :3 tip to whoever has any issues with this tutorial: follow the white rabbit :3

[Edited by Evan on 15-Aug-13 18:09]
12-Sep-13 01:36
Thanks for the cms images tutorial, I got the images to upload to the thumbs and full-size okay but not display, can you tell me where I went wrong? Thanks.
20-Nov-13 14:23
Hello,

I like this script, its great working with CMS in afternoon.

At the moment the images are storred as id.extesion like 1.jpg ore 1.png
Is it possible to store the images with the title, like nice_cms_job.jpg ore nive_cms_job.png ?

I like the images found by searchmachines, so the name of the image is better found by name thene by id.

Thanks,
Greetings
02-Mar-14 17:12
Good article but I get the following error after I've chosen the image to upload and tried to save the article, could you tell me where I'm going wrong? I'm a noob

Warning: move_uploaded_file(/images/articles/fullsize/6.png): failed to open stream: No such file or directory in E:\xampp\htdocs\test\cms\classes\article.php on line 104

Warning: move_uploaded_file(): Unable to move 'E:\xampp\tmp\phpD717.tmp' to '/images/articles/fullsize/6.png' in E:\xampp\htdocs\test\cms\classes\article.php on line 104

Fatal error: Article::storeUploadedImage(): Couldn't move uploaded file. in E:\xampp\htdocs\test\cms\classes\article.php on line 104

Line 104 is:

if ( !( move_uploaded_file( $tempFilename, $this->getImagePath() ) ) ) trigger_error( "Article::storeUploadedImage(): Couldn't move uploaded file.", E_USER_ERROR );
01-Apr-14 21:10
What a great tutorial, just what I was looking for purely for my own home use, in cataloguing my record collection, however I have tried to alter the homepage.php to display as a table but no matter what I do I cant get it to work properly, for some reason the image will not display, its all but there and I am sure its a very simple fix but I can no longer see the error for looking. this is the homepage.php now it works but not correctly, can anyone help at all.



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

<?php foreach ( $results['articles'] as $article ) { ?>
<?php if ( $imagePath = $article->getImagePath( IMG_TYPE_THUMB ) ) { ?>
<?php
//Table headings
echo "<table border='1' cellpadding='5' align='center'>";
echo "<tr><th>ID</th><th>Date</th><th>Image</th><th>Title</th><th>Statement</th></tr>";
echo "<tr>";
echo '<td>' . $article->id . '</td>';
echo '<td>' . date('j F', $article->publicationDate) . '</td>';
echo '<td><a href=".?action=viewArticle&amp;articleId= . $article->id ."><img class="articleImageThumb" src=" . $imagePath . " alt="Article Thumbnail" /></a></td>';
//echo '<td>' .action=viewArticle&amp;articleId=echo $article->id . '</td>';
echo '<td>' . htmlspecialchars( $article->title ) . '</td>';
echo '<td>' . htmlspecialchars( $article->summary ) . '</td>';

echo "</tr>";
echo "</table>";
}
}
?>
<p><a href="./?action=archive">HOME PAGEArticle Archive</a></p>
<?php include "templates/include/footer.php" ?>

05-Apr-14 05:30
Hello Matt! First of all thanks for the truly beautiful, clear, and practical tutorials. Everything works perfectly! I'm starting to learn php with your own examples. Now, I would simply add to the file: editArticle.php another data field. Also edit the file: classes/Article.php adding the new id of the new field created in editArticle.php. I also create a new field in mysql with the same id. The field should be a more content. If I change all these parameters... load a list of errors appear endless! How can I fix it? I tried searching the forum but have not found an answer! Thanks again for everything you have done and I hope that you will continue to do! Hello;-);-)
22-Apr-14 07:34
hi guys,first of all i wanna thank matt for this awesome tutorial, i've modified the original cms to have some amazing features but what i can't seem to get right is how to implement pagination for the archive page and the list articles page at the admin section.i've searched the entire web and i've seen many tutorials written in pdo but i can't seem to implement it into the cms please anyone who has any help should pls reply or i'll prefer matt to make another tutorial on pagination.pleaaaseeee i need this so my site can go live...pls
21-Jun-14 09:49
Hello all,

please help me !

On a local NAS server i have no problem.
But on a hosting service (OVH),
I've a problem while trying to upload the file :

here is the message (it seems the variables can't be taken into account).

Warning: move_uploaded_file() [function.move-uploaded-file]: Filename cannot be empty in /home/xxx/www/cms/classes/Article.php on line 257

Warning: move_uploaded_file() [function.move-uploaded-file]: Unable to move '/tmp/phpTD5F91' to '' in /home/xxx/www/cms/classes/Article.php on line 257

Fatal error: Article::storeUploadedImage(): Couldn't move uploaded file. in /home/xxx/www/cms/classes/Article.php on line 257
21-Jun-14 14:16
Check the permissions to the tmp folder.
29-Jun-14 14:48
Thank you chrishirst fro your answer. I can't check it as I'm on a shared hosted server and don't have access to that.

I wanted to change the default tmp folder for one I would have chosen with upload_tmp_dir but it keeps using "tmp" by default.

Which bypass I could use please ?

[Edited by LeonTheOne on 29-Jun-14 14:48]
30-Jun-14 07:26
put a php.ini file in your document root folder with the following in it:




upload_tmp_dir = your_temp_folder_path
01-Jul-14 14:26
Chris thanks for your help and support.

What I tested before my first post :

I set ini_set("upload_tmp_dir" , "temp"); in config.php

1)
-> not taken into account, still the same message
-> I read somewhere that "upload_tmp_dir" is part of PHP_INI_SYSTEM which means it can only be set in php.ini or httpd.conf
2)
-> this is the point you tell me and I applied it but still the same error message
-> I read that my hoster doesn't allow php.ini file to change settings on mutualized servers so it means I can add it on the root folder but not taken into account indeed

I would definitely like to make it work on that sort of server. It's possible to run strong known CMS with same functiunalities on mutualized server so I think it's possible to do it with Matt's incredible CMS.

P.S. I there a beautiful view from the tower today ?

View all 39 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