Random String

Just some functions for generating random strings in PHP.

function randstr($len=8, $chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') {
    $i = 0;
    $str = '';
    $randmax = strlen($chars)-1;

    for($i=0; $i<$len; ++$i) {
        $str .= $chars[mt_rand(0,$randmax)];
    }

    return $str;
}

function randkey($len=32) {
    return randstr($len, '`1234567890-=qwertyuiop[]\\asdfghjkl;\'zxcvbnm,./ [email protected]#$%^&*()_+{}|:"<>?');
}

randkey is good for generating salt strings and such, randstr is good for stuff like short URLs. Of course, you can pass in whatever characters you want.

Posted in

Base62 Encode

If you have large integers and you want to shrink them down in size for whatever reason, you can use this code. Should be easy enough to extend if you want even higher bases (just add a few more chars and increase the base).

<?php

/**
 * Converts a base 10 number to any other base.
 *
 * @param int $val   Decimal number
 * @param int $base  Base to convert to. If null, will use strlen($chars) as base.
 * @param string $chars Characters used in base, arranged lowest to highest. Must be at least $base characters long.
 *
 * @return string    Number converted to specified base
 */

function base_encode($val, $base=62, $chars='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') {
    if(!isset($base)) $base = strlen($chars);
    $str = '';
    do {
        $m = bcmod($val, $base);
        $str = $chars[$m] . $str;
        $val = bcdiv(bcsub($val, $m), $base);
    } while(bccomp($val,0)>0);
    return $str;
}

/**
 * Convert a number from any base to base 10
 *
 * @param string $str   Number
 * @param int $base  Base of number. If null, will use strlen($chars) as base.
 * @param string $chars Characters use in base, arranged lowest to highest. Must be at least $base characters long.
 *
 * @return int    Number converted to base 10
 */

function base_decode($str, $base=62, $chars='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') {
    if(!isset($base)) $base = strlen($chars);
    $len = strlen($str);
    $val = 0;
    $arr = array_flip(str_split($chars));
    for($i = 0; $i < $len; ++$i) {
        $val = bcadd($val, bcmul($arr[$str[$i]], bcpow($base, $len-$i-1)));
    }
    return $val;
}

(code updated 26-Aug-2011 for arbitrary precision using BC Math)

Posted in

Templating with PHP

There are two main ways to create templates with PHP.

Header/Footer Files

First, design a one-page static layout for your site. You should come up with something like this:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
    <title>Untitled</title>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
    <meta name="keywords" content="" />
    <meta name="description" content="" />
    <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" />
    <link rel="stylesheet" type="text/css" href="/style.css" />
</head>
<body>
<div id="page-wrap">
    <ul id="navbar">
        <li><a href="first-link">First Page</a></li>
        <li><a href="second-link">Second Page</a></li>
    </ul>
    <div id="main-area">
        <a id="logo" href="/"></a>
        <div id="content-container">
            <div id="content-body">
                PAGE-CONTENT-HERE
            </div>
        </div>
    </div>
    <div id="footer">
        <p>yoursite.com &copy; <?=date('Y')?> | <a href="/privacy-policy">Privacy Policy</a></p>
    </div>
</div>
</body>
</html>

Now, take everything above “PAGE-CONTENT-HERE” and throw it in a file like “header.inc.php”. Take the bottom half and put it in “footer.inc.php”. Now for each page, you can do something like this:

<?php
include 'header.inc.php';
echo <<<HTML
<h2>Page Title</h2>

<p>Page content.</p>
HTML
;
include 'footer.inc.php';
?>

Now, if you ever want to change the appearance of your site, you just need to edit your header or footer files!

The advantage of this method is that you can put PHP scripts before you include the header, or make special pages different by using a different header/footer.

Include Method

For this method, you can use the same page layout as above (save it as index.php), but replace PAGE-CONTENT-HERE with something like this:

<?php
include $_GET['p'].'.php';
?>

And then make all your links like <a href="/?p=mypage">. You have to very careful if you use this method though, because malicious users can include whatever they want into your layout! Imagine what would happen if they went to “yoursite.com/?p=index” for example (you’d have a page inside a page inside a page…). Or ?p=http://malicioususer.com/evilscript.

The advantage of using this method is that you don’t have to include a header and footer in each one of your content pages, but you get less flexibility over the layout of your site for special pages.

The Hybrid Method

This method gives you the best of both worlds, although it is perhaps ever so slightly more processor intensive. For this method, you will need create a .htaccess file like this:

RewriteEngine on

RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule .* /?p=$0

Well, I guess you don’t really need to, but it will make your URLs prettier (no file extensions).

Then create an index.php file like this:

<?php
define('DS', DIRECTORY_SEPARATOR);
define('ROOT', dirname(__FILE__));
define('PAGES', ROOT.DS.'pages'.DS);
define('LAYOUTS', ROOT.DS.'layouts'.DS);
define('PHP', '.php');

$pageLayout = 'default';

if(empty($_GET['p'])) $page = 'home';
else $page = $_GET['p'];

if(file_exists(PAGES.$page.PHP)) {
    $pageTitle = ucwords($page);
} else {
    header("HTTP/1.0 404 Not Found");
    $page = '404';
    $pageTitle = '404 - Page Not Found';
}

ob_start();
include PAGES.$page.PHP;
$pageContent = ob_get_clean();
include LAYOUTS.$pageLayout.PHP;

You probably won’t need to edit that at all unless you want to change a few default settings. Then create two folders, “layouts” and “pages”.

In the layouts folder, create a default layout called “default.php”. Something like this:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
    <title><?=$pageTitle?> - Your Site</title>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
    <meta name="keywords" content="" />
    <meta name="description" content="" />
    <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" />
    <link rel="stylesheet" type="text/css" href="/style.css" />
    <?=$pageScripts?>
</head>
<body>
<div id="page-wrap">
    <ul id="navbar">
        <li><a href="first-link">First Page</a></li>
        <li><a href="second-link">Second Page</a></li>
    </ul>
    <div id="main-area">
        <a id="logo" href="/"></a>
        <div id="content-container">
            <div id="content-body">
                <?=$pageContent?>
            </div>
        </div>
    </div>
    <div id="footer">
        <p>yoursite.com &copy; <?=date('Y')?> | <a href="/privacy-policy">Privacy Policy</a></p>
    </div>
</div>
</body>
</html>

Notice the usage of variables $pageContent, $pageScripts, and $pageTitle. You can set the $pageTitle and $pageScripts within the page. $pageContent is where your content will appear in the layout. There is also one more variable you can set, $pageLayout, which you can set to use a layout other than the default one.

Now your page URLs will look something like “yoursite.com/page” with no file extension. You shouldn’t have to worry too much about evil users including bad files because they can only include stuff within the “pages” folder. You can also still access all your other files directly, by using the full URL with extension.

Oh, also make sure you have a file called “404.php” within the “pages” folder, or bad things might happen :) This is the file that will get called if no page could be found. It uses the layout too.

I think that’s everything… so happy templating!

Resize images using this PHP script

Always bugs me when people stretch their images out of proportion, there’s really no reason for it. Just use this simply function to nicely resize your images! If you want all your images the same size (like a square), I recommend “trim”. Otherwise use “squeeze”. If you can think of more appropriate fill names, let me know!

<?php
// @fill
// center       - Centers the image without any scaling.  May be smaller than dimensions, or cropped.
// stretch      - Stretches the image to fill dimensions.  May change image proportions.
// squeeze      - Scales the image, maintaining original proportions.  May not fill dimensions.
// trim         - Scales the image, maintaining original proportions, to fill dimensions.  Image may be centered and trimmed.
// trim_rand    - Scales the image, maintaining original proportions, to fill dimensions.  Trimmed randomly.

function resize($src_filename, $dst_filename, $dst_width, $dst_height, $fill='squeeze', $quality=80, $png_filters=PNG_NO_FILTER)
{
    if(!file_exists($src_filename)) {
        //throw new Exception("File does not exist: $src_filename");
        return false;
    }
    if(empty($dst_filename)) {
        $dst_filename = $src_filename;
    }
    if($dst_width <= 0) {
        //throw new Exception("Width must be positive: $dst_width");
        return false;
    }
    if($dst_height <= 0) {
        //throw new Exception("Height must be positive: $dst_height");
        return false;
    }
    $src_ext = substr($src_filename,strrpos($src_filename,'.')+1);
    switch(strtolower($src_ext)) {
        case 'gif':
            $src_image = imagecreatefromgif($src_filename);
            break;
        case 'jpe':
        case 'jpeg':
        case 'jpg':
            $src_image = imagecreatefromjpeg($src_filename);
            break;
        case 'png':
            $src_image = imagecreatefrompng($src_filename);
            break;
        default:
            //throw new Exception("Invalid source file extension: $src_ext");
            return false;
    }
    $src_width = imagesx($src_image);
    $src_height = imagesy($src_image);
    switch(strtolower(trim($fill))) {
        case 'center':
            $src_x = round($src_width/2-$dst_width/2);
            $src_y = round($src_height/2-$dst_height/2);
            if($src_x < 0) {
                $dst_width = $src_width;
                $src_x = 0;
            }
            if($src_y < 0) {
                $dst_height = $src_height;
                $src_y = 0;
            }
            $dst_image = imagecreatetruecolor($dst_width, $dst_height);
            imagecopyresampled($dst_image, $src_image, 0, 0, $src_x, $src_y, $dst_width, $dst_height, $dst_width, $dst_height);
            break;
        case 'stretch':
            $dst_image = imagecreatetruecolor($dst_width, $dst_height);
            imagecopyresampled($dst_image, $src_image, 0, 0, 0, 0, $dst_width, $dst_height, $src_width, $src_height);
            break;
        case 'crop':
        case 'crop_center':
        case 'trim_center':
        case 'trim':
            $src_ratio = $src_width/$src_height;
            $dst_ratio = $dst_width/$dst_height;
            if($src_ratio < $dst_ratio) // trim top and bottom
            {
                $ratio = $src_width/$dst_width;
                $crop_height = $dst_height*$ratio;
                $src_y = round(($src_height-$crop_height)/2);
                $crop_width = $src_width;
                $src_x = 0;
            }
            else // trim left and right
            {
                $ratio = $src_height/$dst_height;
                $crop_width = $dst_width*$ratio;
                $src_x = round(($src_width-$crop_width)/2);
                $crop_height = $src_height;
                $src_y = 0;
            }
            $dst_image = imagecreatetruecolor($dst_width, $dst_height);
            imagecopyresampled($dst_image, $src_image, 0, 0, $src_x, $src_y, $dst_width, $dst_height, $crop_width, $crop_height);
            break;
        case 'crop_rand':
        case 'trim_rand':
            $src_ratio = $src_width/$src_height;
            $dst_ratio = $dst_width/$dst_height;
            if($src_ratio < $dst_ratio) // trim top and bottom
            {
                $ratio = $src_width/$dst_width;
                $crop_height = $dst_height*$ratio;
                $src_y = rand(0,$src_height-$crop_height);
                $crop_width = $src_width;
                $src_x = 0;
            }
            else // trim left and right
            {
                $ratio = $src_height/$dst_height;
                $crop_width = $dst_width*$ratio;
                $src_x = rand(0,$src_width-$crop_width);
                $crop_height = $src_height;
                $src_y = 0;
            }
            $dst_image = imagecreatetruecolor($dst_width, $dst_height);
            imagecopyresampled($dst_image, $src_image, 0, 0, $src_x, $src_y, $dst_width, $dst_height, $crop_width, $crop_height);
            break;
        case 'squeeze':
        case 'stretch_prop':
        case 'fit':
            $ratio = max($src_width/$dst_width, $src_height/$dst_height);
            if($ratio < 1) $ratio = 1; // do not enlarge
            $dst_width = round($src_width/$ratio);
            $dst_height = round($src_height/$ratio);
            $dst_image = imagecreatetruecolor($dst_width, $dst_height);
            imagecopyresampled($dst_image, $src_image, 0, 0, 0, 0, $dst_width, $dst_height, $src_width, $src_height);
            break;
        default:
            //throw new Exception("Unrecognized fill type: $fill");
            return false;
    }
    $dst_ext = substr($dst_filename,strrpos($dst_filename,'.')+1);
    if(empty($dst_ext)) {
        $dst_ext = $src_ext;
        $dst_filename .= ".$src_ext";
    }
    switch(strtolower($dst_ext)) {
        case 'gif':
            return imagegif($dst_image, $dst_filename);
        case 'jpe':
        case 'jpeg':
        case 'jpg':
            return imagejpeg($dst_image, $dst_filename, $quality);
        case 'png':
            return imagepng($dst_image, $dst_filename, $quality, $png_filters);
        default:
            //throw new Exception('Invalid destination file extension: $dst_ext');
            return false;
    }
}

?>

cPanel Quick Config – Quick Tip

Maybe I’m slow, but I just discovered “php.ini QuickConfig” in cPanel 11. It probably exists in prior versions too. It was hidden under “Software / Services”. Most of the default settings should be fine, but you might want to disable register_globals if it isn’t disabled already. This will prevent malicious visitors from setting your PHP variables themselves by attaching arguments to the URL. Just make sure you always use $_GET and $_POST where ever it’s necessary.

Mark’s PHP Snippets

These are just a few PHP snippets/functions I have written over the years and have found to be quite useful.

mysql_connect('localhost','USERNAME','PASSWORD');
mysql_select_db('DATABASE');

This one really isn’t anything special, but it makes its way into every single one of my pages, so it’s worth mentioning in case you forget the syntax or are new to PHP. It simply connects to a mysql database.

function mysql_safe_string($value) {
    if(empty($value))           return 'NULL';
    elseif(is_string($value))   return '\''.mysql_real_escape_string(trim($value)).'\'';
    elseif(is_numeric($value))  return $value;
    elseif(is_array($value))    return implode(',',array_map('mysql_safe_string',$value));
    else                        return false;
}

function mysql_safe_query($format) {
    $args = array_slice(func_get_args(),1);
    $args = array_map('mysql_safe_string',$args);
    $query = vsprintf($format,$args);
    return mysql_query($query);
}

These are my favorite two functions that I can’t live without. They make writing mysql queries so much easier.

// compare something like this
mysql_query(sprintf('INSERT INTO users (name, age) VALUES (%s, %d)', mysql_real_escape_string($_POST['name']), (is_numeric($_POST['age'])?$_POST['age']:'NULL')));
// to this
mysql_safe_query('INSERT INTO users (name, age) VALUES (%s, %s)', $_POST['name'], $_POST['age']);
// it even handles arrays nicely
$arr = array(1,2,3,4,5);
mysql_safe_query('SELECT * FROM posts WHERE cat_id IN (%s) ORDER BY date DESC', $arr);

These next two are quite simple, but still very handy.

function redirect($uri) {
    header('location:'.$uri);
    exit;
}

function pr($arr) {
    echo '<pre>';
    print_r($arr);
    echo '</pre>';
}

pr() is great for debugging, and redirect() helps keep your code clean and meaningful.

This next function is still in its infancy, so it may be refined over time.

function smart_excerpt($text, $maxLen=500) {
    if(preg_match('`.*?<!--\s*more\s*-->`is', $text, $matches))
        return $matches[0];
    if(strlen($text) > $maxLen) {
        $minLen = round($maxLen/2);
        if(preg_match('`.{'.$minLen.','.$maxLen.'}[.!?]`s', $text, $matches))
            return rtrim($matches[0]);
        return rtrim(substr($text, 0, $maxLen),' \t\n\r\0\x0B.!?').'...';
    }
    return false;
}

It accepts a body of text, and searches for the text “<!–more–>”. If it finds it, it returns everything before that. Otherwise, if it doesn’t, and it’s longer than $maxLen, it trims the text down to the first punctuation character (.!?) before $maxLen. That way your text doesn’t get cropped mid-sentence/word. If it can’t find any punctuation (because you’re a terrible writer?), it will crop at exactly $maxLen. It returns false if the text doesn’t need cropping. This way you can decide if you want to print a “view more” link or not.

// example usage
if($excerpt = smart_excerpt($post['body']))
    echo nl2br($excerpt).' <a href="post_view.php?id='.$post['id'].'">More &raquo;</a>';
else
    echo nl2br($post['body']);

Well, that’s all for now. I’ll post up some more if I find any other tasty tidbits lying around.

Create a Blog in 30 Minutes Without a Framework

A lot of PHP frameworks like to boast “create a blog in 20 minutes”. Well, I’m going to show you how to create a bare bones blog in about 30, but without the use of a framework. Our blog will include posts, and comments. I won’t bother with HTML headers and footers – you can add those in yourself. I recommend using my XHTML template; most of the pages below can easily be inserted into the body. I also won’t be covering user authentication in this tutorial, I’ll cover that in a later tutorial. We also won’t be doing much error checking, or prevention against users inserting malicious code. We will, however, prevent SQL injection with a very simple function.

What you will need:

  • A web server with PHP and MySQL support
  • Very basic programming knowledge

Topics covered:

  • Using MySQL to insert, delete, and update posts and comments
  • Iterating over MySQL results
  • Using cookies to save commenter information
  • Protection against SQL injection
  • Examples of php’s date() function
  • Using anchors to scroll the page
  • Redirection
  • Heredocs

Not covered:

  • User authentication (coming later)
  • Categories and tags (coming later)
  • Prevention against other (JavaScript) injection attacks
  • Error checking (empty fields, invalid emails, etc.)

The first thing we need to do is create some MySQL tables so that we have some place to store our data. We will be using two tables for our blog: posts, and comments. I will show you how to add categories and users in later tutorials.

If you haven’t already done so, you will need to create a MySQL database for your blog. I cover this in step 4 of my WordPress tutorial. Make note of your database name, username and password; you will need these in a few minutes.

Now create the following tables. You can do so by running the following code in the “SQL” tab of phpMyAdmin if you have it installed.

CREATE TABLE `posts` (
  `id` INT(11) NOT NULL AUTO_INCREMENT,
  `title` VARCHAR(255) NOT NULL,
  `body` text NOT NULL,
  `num_comments` INT(11) NOT NULL DEFAULT '0',
  `date` INT(11) NOT NULL,
  PRIMARY KEY  (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
CREATE TABLE `comments` (
  `id` INT(11) NOT NULL AUTO_INCREMENT,
  `post_id` INT(11) NOT NULL,
  `name` VARCHAR(255) NOT NULL,
  `email` VARCHAR(255) NOT NULL,
  `website` VARCHAR(255) DEFAULT NULL,
  `content` text NOT NULL,
  `date` INT(11) NOT NULL,
  PRIMARY KEY  (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

You may have noticed “date” is an int rather than datetime. I prefer to use a unix timestamp instead because they are easier to work with in PHP, but you can use whatever you choose — just keep in mind you will have to modify some of the code below if you choose to use datetime instead.

Now open your favorite text editor, and get ready to start coding! I use EditPlus, but there are plenty of free alternatives. We will be creating the following pages:

  • index.php
  • post_add.php
  • post_delete.php
  • post_edit.php
  • post_view.php
  • comment_add.php
  • comment_delete.php
  • mysql.php

You can create these files now if you like, and we’ll slowly fill them in. Don’t worry, most of them are just a few lines of code!

Let’s start with the most important file: mysql.php. This file simply connects to our database so that we can run queries on it. I’ve also included a few simple helper functions. I don’t know how people get by without these!

<?php
// mysql.php
function mysql_safe_string($value) {
    $value = trim($value);
    if(empty($value))           return 'NULL';
    elseif(is_numeric($value))  return $value;
    else                        return "'".mysql_real_escape_string($value)."'";
}

function mysql_safe_query($query) {
    $args = array_slice(func_get_args(),1);
    $args = array_map('mysql_safe_string',$args);
    return mysql_query(vsprintf($query,$args));
}

function redirect($uri) {
    header('location:'.$uri);
    exit;
}

mysql_connect('localhost','USERNAME','PASSWORD');
mysql_select_db('DATABASE');

If you hadn’t noticed, you will need to fill in your username, password and database name. mysql_safe_query is basically a combination of mysql_query, sprintf, and some sanitization. It converts empty strings to NULL, leaves numbers as they are, and quotes and properly escapes strings as necessary so that you don’t have to worry about SQL injection attacks.

Now let’s add the ability to make some blog posts!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
// post_add.php
if(!empty($_POST)) {
    include 'mysql.php';
    if(mysql_safe_query('INSERT INTO posts (title,body,date) VALUES (%s,%s,%s)', $_POST['title'], $_POST['body'], time()))
        echo 'Entry posted. <a href="post_view.php?id='.mysql_insert_id().'">View</a>';
    else
        echo mysql_error();
}
?>

<form method="post">
    <table>
        <tr>
            <td><label for="title">Title</label></td>
            <td><input name="title" id="title" /></td>
        </tr>
        <tr>
            <td><label for="body">Body</label></td>
            <td><textarea name="body" id="body"></textarea></td>
        </tr>
        <tr>
            <td></td>
            <td><input type="submit" value="Post" /></td>
        </tr>
    </table>
</form>

At the bottom is a simple form with title and body fields, and a submit button. Pretty self explanatory. If you omit the “action” attribute in the form element it will simply post back to the current page; this is what we want.

The PHP at the top simply checks if anything has been posted, and if it has it inserts it into the “posts” table in our database. It doesn’t check that both “title” and “body” have been properly filled out, nor does it prevent users from entering malicious JavaScript code. If you want to check against these things, do it before you insert the data into the database (line 5). However, in a proper implementation this page would be protected with some sort of user authentication, so you may want to allow it if it’s from a trusted user.

The “if” statement checks if there were any (usually syntax) errors in the query. If there weren’t any, and the post was successfully inserted into the database, it notifies the user and adds a link to view the newly created post. Otherwise, it spits out the error for debugging.

Since we now have a link to post_view.php, we might as well create that page next.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php
// post_view.php
include 'mysql.php';
$result = mysql_safe_query('SELECT * FROM posts WHERE id=%s LIMIT 1', $_GET['id']);

if(!mysql_num_rows($result)) {
    echo 'Post #'.$_GET['id'].' not found';
    exit;
}

$row = mysql_fetch_assoc($result);
echo '<h2>'.$row['title'].'</h2>';
echo '<em>Posted '.date('F j<\s\up>S</\s\up>, Y', $row['date']).'</em><br/>';
echo nl2br($row['body']).'<br/>';
echo '<a href="post_edit.php?id='.$_GET['id'].'">Edit</a> | <a href="post_delete.php?id='.$_GET['id'].'">Delete</a> | <a href="index.php">View All</a>';

echo '<hr/>';
$result = mysql_safe_query('SELECT * FROM comments WHERE post_id=%s ORDER BY date ASC', $_GET['id']);
echo '<ol id="comments">';
while($row = mysql_fetch_assoc($result)) {
    echo '<li id="post-'.$row['id'].'">';
    echo (empty($row['website'])?'<strong>'.$row['name'].'</strong>':'<a href="'.$row['website'].'" target="_blank">'.$row['name'].'</a>');
    echo ' (<a href="comment_delete.php?id='.$row['id'].'&post='.$_GET['id'].'">Delete</a>)<br/>';
    echo '<small>'.date('j-M-Y g:ia', $row['date']).'</small><br/>';
    echo nl2br($row['content']);
    echo '</li>';
}
echo '</ol>';

echo <<<HTML
<form method="post" action="comment_add.php?id={$_GET['id']}">
    <table>
        <tr>
            <td><label for="name">Name:</label></td>
            <td><input name="name" id="name" value="{$_COOKIE['name']}"/></td>
        </tr>
        <tr>
            <td><label for="email">Email:</label></td>
            <td><input name="email" id="email" value="{$_COOKIE['email']}"/></td>
        </tr>
        <tr>
            <td><label for="website">Website:</label></td>
            <td><input name="website" id="website" value="{$_COOKIE['website']}"/></td>
        </tr>
        <tr>
            <td><label for="content">Comments:</label></td>
            <td><textarea name="content" id="content"></textarea></td>
        </tr>
        <tr>
            <td></td>
            <td><input type="submit" value="Post Comment"/></td>
        </tr>
    </table>
</form>
HTML
;

This one’s a little more complicated. It displays the blog entry, all comments on the entry, and adds a form to post new comments.

Line 4 gets the post given by the “id” variable in the URL. We use “LIMIT 1” to let MySQL know we only want 1 result back, and it can stop searching after it has found it. Lines 6-9 check that we actually got a result back. Lines 11-15 will display the post. Notice how in the date script I used <sup> to display the suffix on the date – we need to escape the “s” and the “u” so that the date function doesn’t convert them, but the “p” isn’t an accepted letter so it doesn’t need to be escaped. On line 14 we convert new lines into <br/>s so that you don’t need to type them in your post. Line 15 prints out some options we’ll talk about later.

Lines 17-28 will display all the comments in an ordered list. The while loop on line 20 is used to iterate over all the results/comments. We give each comment an “id” so that we can use anchor links, as you will see later.

Lines 30-55 print uses heredoc syntax to print out the form. Heredocs behave the same as double-quoted strings, meaning you can use variables in them. Just remember to use squiggly brackets {} around array variables. We include $_COOKIE variables to automatically fill out the user’s name, email and website so that he doesn’t have to retype them every time he leaves a comment. I will show you how to set these next. Note that if the variable does not exist/isn’t set, the value will just be left blank, like we want. We also include the post ID in the action URL on line 31. We could have just as easily included this in a hidden form field, as many people like to do, but I like to put it in the URL for a little more consistency. If we later decide we want to put the comment form on the actual comment_add page, then we can link directly to it with the id in the URL, whereas otherwise we would have to somehow post the id over.

Let’s add the comment_add.php page next.

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
// comment_add.php
include 'mysql.php';

$expire = time()+60*60*24*30;
setcookie('name', $_POST['name'], $expire, '/');
setcookie('email', $_POST['email'], $expire, '/');
setcookie('website', $_POST['website'], $expire, '/');

mysql_safe_query('INSERT INTO comments (post_id,name,email,website,content,date) VALUES (%s,%s,%s,%s,%s,%s)',
    $_GET['id'], $_POST['name'], $_POST['email'], $_POST['website'], $_POST['content'], time());
mysql_safe_query('UPDATE posts SET num_comments=num_comments+1 WHERE id=%s LIMIT 1', $_GET['id']);
redirect('post_view.php?id='.$_GET['id'].'#post-'.mysql_insert_id());

First we save the user’s input to some cookie variables. We set to expiry date to one month from now. This will be refreshed every time the user comments on something. Lines 9 and 10 simply insert the comment into the database. Line 11 increments the comment counter on the post. We could have just retrieved the number of comments with MySQL’s COUNT(*) command, but this puts extra strain on the database if it’s used a lot. It probably won’t make a difference until you’re getting thousands of visitors per day, but it’s always a good idea to do it right the first time around. Line 12 redirects back the post we were just on, and scrolls the page down to the comment we just inserted – people like to see that their comment was actually posted.

Deleting comments is pretty easy, so we’ll do that next.

<?php
// comment_delete.php
include 'mysql.php';
mysql_safe_query('DELETE FROM comments WHERE id=%s LIMIT 1', $_GET['id']);
mysql_safe_query('UPDATE posts SET num_comments=num_comments-1 WHERE id=%s LIMIT 1', $_GET['post']);
redirect('post_view.php?id='.$_GET['post']);

Not much needs explaining here. Same deal as before. Just remember the decrement the comment counter or it will be off.

Let’s delete some posts too while we’re at it.

<?php
// post_delete.php
include 'mysql.php';
mysql_safe_query('DELETE FROM posts WHERE id=%s LIMIT 1', $_GET['id']);
mysql_safe_query('DELETE FROM comments WHERE post_id=%s', $_GET['id']);
redirect('index.php');

You may have noticed that I haven’t been using closing PHP tags – you don’t need them, and it’s actually probably a good idea not to include them so that you don’t accidentally output some whitespace. Notice we also delete all the comments associated with the post. It won’t hurt much to leave them there, but we might as well save some disk space and give MySQL less to dig through.

On to post_edit.php. I always find the edit pages to be the biggest pain to write.

<?php
// post_edit.php
include 'mysql.php';

if(!empty($_POST)) {
    if(mysql_safe_query('UPDATE posts SET title=%s, body=%s, date=%s WHERE id=%s', $_POST['title'], $_POST['body'], time(), $_GET['id']))
        redirect('post_view.php?id='.$_GET['id']);
    else
        echo mysql_error();
}

$result = mysql_safe_query('SELECT * FROM posts WHERE id=%s', $_GET['id']);

if(!mysql_num_rows($result)) {
    echo 'Post #'.$_GET['id'].' not found';
    exit;
}

$row = mysql_fetch_assoc($result);

echo <<<HTML
<form method="post">
    <table>
        <tr>
            <td><label for="title">Title</label></td>
            <td><input name="title" id="title" value="{$row['title']}" /></td>
        </tr>
        <tr>
            <td><label for="body">Body</label></td>
            <td><textarea name="body" id="body">{$row['body']}</textarea></td>
        </tr>
        <tr>
            <td></td>
            <td><input type="submit" value="Save"/></td>
        </tr>
    </table>
</form>
HTML
;

This one’s not so bad. We simply retrieve the post, and then fill the form with it. We use MySQL’s UPDATE command to update the post, then redirect back to the post so the user can see his changes. You might notice that this page is quite similar to the post_add.php page. You can merge these two pages if you like, so that if you add any more fields to the post, you don’t have to edit two pages, but then you end up with a lot of conditionals just to change a bit of wording and it can get quite messy.

Now, back to the beginning, let’s write the index page. The first page your users will see when the view your blog. It should probably display only the last few entries, but for simplicity, we’re going to make it display all entries on one page.

<?php
// index.php
include 'mysql.php';

echo '<h1>My First Blog</h1>';
echo "<em>Not just another WordPress web log</em><hr/>";

$result = mysql_safe_query('SELECT * FROM posts ORDER BY date DESC');

if(!mysql_num_rows($result)) {
    echo 'No posts yet.';
} else {
    while($row = mysql_fetch_assoc($result)) {
        echo '<h2>'.$row['title'].'</h2>';
        $body = substr($row['body'], 0, 300);
        echo nl2br($body).'...<br/>';
        echo '<a href="post_view.php?id='.$row['id'].'">Read More</a> | ';
        echo '<a href="post_view.php?id='.$row['id'].'#comments">'.$row['num_comments'].' comments</a>';   
        echo '<hr/>';
    }
}

echo <<<HTML
<a href="post_add.php">+ New Post</a>
HTML
;

The loop here is quite similar to the comment loop you saw earlier, so there isn’t much to explain. I’ve shortened the posts to 300 characters so that you don’t end up with too much text on one page. Note that you need a line break after the heredoc if it’s at the end of the page, otherwise PHP will complain.

You should now have a fully functional blog! We will add more features to it in the future, so stay tuned. Please leave some comments below and let me know if this tutorial was too difficult to follow, or too wordy :)

download-button

It’s better if you work through the tutorial on your own, but for convenience, I’ve included a zip file with all the above files. I wouldn’t use this on a live site! It’s meant for learning purposes only.