Choose a version here. If you've not already started the tutorial, just go with the latest one.

30 Aug 2014 Preview version Choose
5 Oct 2014 Explicitly adds a specific charset for htmlspecialchars(), and wraps it with a custom function Choose
16 Oct 2014 Updated redirect function to work with vhost subfolders Choose
4 Nov 2014 Minor improvements: added missing docblock, fixed security issue, CSS tweak. Switched hashing method to DEFAULT instead of BCRYPT, this is best practice. Choose
25 Nov 2014 Improve the notes on getting started, in particular choosing a programmer's editor. Added introduction to mod_rewrite rules. Choose
OK
NB: There are several versions of this tutorial, each successive one containing additional improvements. If you're in the middle of working through it, please check the versions panel above, to ensure you're not mixing code from different versions.

Make your own blog

Commenting form

Righto, let's get onto a new solid block of functionality: allowing users to add comments. This adds a new block of HTML in comment-form.php, which reports any errors when the comment is made, a form to capture the usual comment information, and some business logic in lib/view-post.php and view-post.php to glue it all together.

Expand/contract code area Select previous tab
Select next tab
33
34
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row;
}
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row;
}
/**
* Writes a comment to a particular post
*
* @param PDO $pdo
* @param integer $postId
* @param array $commentData
* @return array
*/
function addCommentToPost(PDO $pdo, $postId, array $commentData)
{
$errors = array();
// Do some validation
if (empty($commentData['name']))
{
$errors['name'] = 'A name is required';
}
if (empty($commentData['text']))
{
$errors['text'] = 'A comment is required';
}
// If we are error free, try writing the comment
if (!$errors)
{
$sql = "
INSERT INTO
comment
(name, website, text, post_id)
VALUES(:name, :website, :text, :post_id)
";
$stmt = $pdo->prepare($sql);
if ($stmt === false)
{
throw new Exception('Cannot prepare statement to insert comment');
}
$result = $stmt->execute(
array_merge($commentData, array('post_id' => $postId, ))
);
if ($result === false)
{
// @todo This renders a database-level message to the user, fix this
$errorInfo = $pdo->errorInfo();
if ($errorInfo)
{
$errors[] = $errorInfo[2];
}
}
}
return $errors;
}
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
<?php // We'll use a rule-off for now, to separate page sections ?>
<hr />
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div style="border: 1px solid #ff6666; padding: 6px;">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php echo $error ?></li>
<?php endforeach ?>
</ul>
</div>
<?php endif ?>
<h3>Add your comment</h3>
<form method="post">
<p>
<label for="comment-name">
Name:
</label>
<input
type="text"
id="comment-name"
name="comment-name"
/>
</p>
<p>
<label for="comment-website">
Website:
</label>
<input
type="text"
id="comment-website"
name="comment-website"
/>
</p>
<p>
<label for="comment-text">
Comment:
</label>
<textarea
id="comment-text"
name="comment-text"
rows="8"
cols="70"
></textarea>
</p>
<input type="submit" value="Submit comment" />
</form>
23
24
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
27
28
88
89
90
 
 
91
92
redirectAndExit('index.php?not-found=1');
}
// Swap carriage returns for paragraph breaks
$bodyText = htmlspecialchars($row['body']);
$paraText = str_replace("\n", "</p><p>", $bodyText);
</div>
</div>
<?php endforeach ?>
</body>
</html>
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
88
89
90
91
92
93
94
redirectAndExit('index.php?not-found=1');
}
$errors = null;
if ($_POST)
{
$commentData = array(
'name' => $_POST['comment-name'],
'website' => $_POST['comment-website'],
'text' => $_POST['comment-text'],
);
$errors = addCommentToPost(
$pdo,
$postId,
$commentData
);
// If there are no errors, redirect back to self and redisplay
if (!$errors)
{
redirectAndExit('view-post.php?post_id=' . $postId);
}
}
// Swap carriage returns for paragraph breaks
$bodyText = htmlspecialchars($row['body']);
$paraText = str_replace("\n", "</p><p>", $bodyText);
</div>
</div>
<?php endforeach ?>
<?php require 'templates/comment-form.php' ?>
</body>
</html>

Let us have a look in detail at view-post.php, which is the page called by the web server when a single post is rendered. In the newly inserted code, we take these actions:

  1. Reset the errors variable to null i.e. we do not yet know if they are any errors.
  2. Detect if we are in a POST operation. If we are not (i.e. the page is just being rendered normally, rather than submitting a form) then don't do any more of this new stuff.
  3. Get the author name, website URL and comment message, and pass them to new function addCommentToPost() for validation and saving.
  4. This will return any resulting errors into the $errors array.
  5. If the comment-adding function successfully saves a comment to the database, redirect to self and exit. This will request the page again in GET mode.
  6. If the comment save failed (perhaps because a mandatory field was not supplied) then the errors array will contain the error message(s). Since this skips the redirection phase, the form is rendered within the same call, which allows the errors to be marked on the form.

Adding a comment doesn't work yet, as the INSERT command is missing a value for the date of comment creation. Let's fix that now:

Expand/contract code area Select previous tab
Select next tab
63
64
65
66
67
68
69
70
71
72
73
 
 
 
74
75
 
 
 
76
77
78
$sql = "
INSERT INTO
comment
(name, website, text, post_id)
VALUES(:name, :website, :text, :post_id)
";
$stmt = $pdo->prepare($sql);
if ($stmt === false)
{
throw new Exception('Cannot prepare statement to insert comment');
}
$result = $stmt->execute(
array_merge($commentData, array('post_id' => $postId, ))
);
if ($result === false)
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
$sql = "
INSERT INTO
comment
(name, website, text, created_at, post_id)
VALUES(:name, :website, :text, :created_at, :post_id)
";
$stmt = $pdo->prepare($sql);
if ($stmt === false)
{
throw new Exception('Cannot prepare statement to insert comment');
}
$createdTimestamp = date('Y-m-d H:m:s');
$result = $stmt->execute(
array_merge(
$commentData,
array('post_id' => $postId, 'created_at' => $createdTimestamp, )
)
);
if ($result === false)

So, that gets the success case working. However, if you test a failure condition (empty name field or an empty comment, the fields that were filled in now disappear. This is because a form does not by default contain values, so we have to add them manually.

Thus, we now set empty values for the GET case (no form submission) in view-post.php as well as the already existing POST case. Where we output the user-supplied data, we pass it through the PHP function htmlspecialchars(), which prevents any rendering problems if the user has used any HTML characters such as angle brackets.

Expand/contract code area Select previous tab
Select next tab
 
 
 
 
 
 
 
1
2
3
30
31
32
 
33
34
35
41
42
43
 
44
45
46
53
54
55
56
57
58
59
<?php // We'll use a rule-off for now, to separate page sections ?>
<hr />
type="text"
id="comment-name"
name="comment-name"
/>
</p>
<p>
type="text"
id="comment-website"
name="comment-website"
/>
</p>
<p>
name="comment-text"
rows="8"
cols="70"
></textarea>
</p>
<input type="submit" value="Submit comment" />
1
2
3
4
5
6
7
8
9
10
30
31
32
33
34
35
36
41
42
43
44
45
46
47
53
54
55
56
57
58
59
<?php
/**
* @var $errors string
* @var $commentData array
*/
?>
<?php // We'll use a rule-off for now, to separate page sections ?>
<hr />
type="text"
id="comment-name"
name="comment-name"
value="<?php echo htmlspecialchars($commentData['name']) ?>"
/>
</p>
<p>
type="text"
id="comment-website"
name="comment-website"
value="<?php echo htmlspecialchars($commentData['website']) ?>"
/>
</p>
<p>
name="comment-text"
rows="8"
cols="70"
><?php echo htmlspecialchars($commentData['text']) ?></textarea>
</p>
<input type="submit" value="Submit comment" />
43
44
45
 
 
 
 
 
 
 
 
46
47
48
redirectAndExit('view-post.php?post_id=' . $postId);
}
}
// Swap carriage returns for paragraph breaks
$bodyText = htmlspecialchars($row['body']);
43
44
45
46
47
48
49
50
51
52
53
54
55
56
redirectAndExit('view-post.php?post_id=' . $postId);
}
}
else
{
$commentData = array(
'name' => '',
'website' => '',
'text' => '',
);
}
// Swap carriage returns for paragraph breaks
$bodyText = htmlspecialchars($row['body']);

And now for some more tidying. The first tweaking opportunity I noticed was that the code to make a comment safe to render to the screen, and to swap newlines for paragraph tags, might be useful elsewhere in the future. So I've generalised that snippet of code in a function, and then made use of it:

Expand/contract code area Select previous tab
Select next tab
48
49
50
 
 
 
 
 
 
 
 
 
 
 
 
 
51
52
53
return $date->format('d M Y, H:i');
}
function redirectAndExit($script)
{
$host = $_SERVER['HTTP_HOST'];
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
return $date->format('d M Y, H:i');
}
/**
* Converts unsafe text to safe, paragraphed, HTML
*
* @param string $text
* @return string
*/
function convertNewlinesToParagraphs($text)
{
$escaped = htmlspecialchars($text);
return '<p>' . str_replace("\n", "</p><p>", $escaped) . '</p>';
}
function redirectAndExit($script)
{
$host = $_SERVER['HTTP_HOST'];
52
53
54
55
56
57
58
59
60
71
72
73
74
75
76
77
78
79
80
87
88
89
90
 
91
92
93
);
}
// Swap carriage returns for paragraph breaks
$bodyText = htmlspecialchars($row['body']);
$paraText = str_replace("\n", "</p><p>", $bodyText);
?>
<!DOCTYPE html>
<html>
<div>
<?php echo convertSqlDate($row['created_at']) ?>
</div>
<p>
<?php // This is already escaped, so doesn't need further escaping ?>
<?php echo $paraText ?>
</p>
<h3><?php echo countCommentsForPost($postId) ?> comments</h3>
<?php echo convertSqlDate($comment['created_at']) ?>
</div>
<div class="comment-body">
<?php echo htmlspecialchars($comment['text']) ?>
</div>
</div>
<?php endforeach ?>
52
53
54
 
 
 
55
56
57
71
72
73
74
75
 
 
76
77
78
87
88
89
90
91
92
93
94
);
}
?>
<!DOCTYPE html>
<html>
<div>
<?php echo convertSqlDate($row['created_at']) ?>
</div>
<?php // This is already escaped, so doesn't need further escaping ?>
<?php echo convertNewlinesToParagraphs($row['body']) ?>
<h3><?php echo countCommentsForPost($postId) ?> comments</h3>
<?php echo convertSqlDate($comment['created_at']) ?>
</div>
<div class="comment-body">
<?php // This is already escaped ?>
<?php echo convertNewlinesToParagraphs($comment['text']) ?>
</div>
</div>
<?php endforeach ?>

A nice simple one is up next. The code for install.php was rather bloated by the presence of the large function at the start, and there's no reason why this couldn't be stored separately, making the installer page easier to maintain. We refactored this one a while ago, but it's perfectly fine to refactor again — the process can be regarded as fairly iterative anyway. So here's the diff, resulting in new file lib/install.php:

Expand/contract code area Select previous tab
Select next tab
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php
require_once 'lib/common.php';
function installBlog()
{
// Get the PDO DSN string
$root = getRootPath();
$database = getDatabasePath();
$error = '';
// A security measure, to avoid anyone resetting the database if it already exists
if (is_readable($database) && filesize($database) > 0)
{
$error = 'Please delete the existing database manually before installing it afresh';
}
// Create an empty file for the database
if (!$error)
{
$createdOk = @touch($database);
if (!$createdOk)
{
$error = sprintf(
'Could not create the database, please allow the server to create new files in \'%s\'',
dirname($database)
);
}
}
// Grab the SQL commands we want to run on the database
if (!$error)
{
$sql = file_get_contents($root . '/data/init.sql');
if ($sql === false)
{
$error = 'Cannot find SQL file';
}
}
// Connect to the new database and try to run the SQL commands
if (!$error)
{
$pdo = getPDO();
$result = $pdo->exec($sql);
if ($result === false)
{
$error = 'Could not run SQL: ' . print_r($pdo->errorInfo(), true);
}
}
// See how many rows we created, if any
$count = array();
foreach(array('post', 'comment') as $tableName)
{
if (!$error)
{
$sql = "SELECT COUNT(*) AS c FROM " . $tableName;
$stmt = $pdo->query($sql);
if ($stmt)
{
// We store each count in an associative array
$count[$tableName] = $stmt->fetchColumn();
}
}
}
return array($count, $error);
}
// We store stuff in the session, to survive the redirect to self
session_start();
1
2
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
5
6
<?php
require_once 'lib/common.php';
require_once 'lib/install.php';
// We store stuff in the session, to survive the redirect to self
session_start();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php
/**
* Blog installer function
*
* @return array(count array, error string)
*/
function installBlog()
{
// Get the PDO DSN string
$root = getRootPath();
$database = getDatabasePath();
$error = '';
// A security measure, to avoid anyone resetting the database if it already exists
if (is_readable($database) && filesize($database) > 0)
{
$error = 'Please delete the existing database manually before installing it afresh';
}
// Create an empty file for the database
if (!$error)
{
$createdOk = @touch($database);
if (!$createdOk)
{
$error = sprintf(
'Could not create the database, please allow the server to create new files in \'%s\'',
dirname($database)
);
}
}
// Grab the SQL commands we want to run on the database
if (!$error)
{
$sql = file_get_contents($root . '/data/init.sql');
if ($sql === false)
{
$error = 'Cannot find SQL file';
}
}
// Connect to the new database and try to run the SQL commands
if (!$error)
{
$pdo = getPDO();
$result = $pdo->exec($sql);
if ($result === false)
{
$error = 'Could not run SQL: ' . print_r($pdo->errorInfo(), true);
}
}
// See how many rows we created, if any
$count = array();
foreach(array('post', 'comment') as $tableName)
{
if (!$error)
{
$sql = "SELECT COUNT(*) AS c FROM " . $tableName;
$stmt = $pdo->query($sql);
if ($stmt)
{
// We store each count in an associative array
$count[$tableName] = $stmt->fetchColumn();
}
}
}
return array($count, $error);
}

When a comment is created, we use a snippet of code to create a timestamp in a format suitable for the database server. Since that'll be useful for other things, let's convert that to a reusable function:

Expand/contract code area Select previous tab
Select next tab
48
49
50
 
 
 
 
 
51
52
53
return $date->format('d M Y, H:i');
}
/**
* Converts unsafe text to safe, paragraphed, HTML
*
48
49
50
51
52
53
54
55
56
57
58
return $date->format('d M Y, H:i');
}
function getSqlDateForNow()
{
return date('Y-m-d H:i:s');
}
/**
* Converts unsafe text to safe, paragraphed, HTML
*
72
73
74
75
76
77
78
79
80
81
82
83
throw new Exception('Cannot prepare statement to insert comment');
}
$createdTimestamp = date('Y-m-d H:m:s');
$result = $stmt->execute(
array_merge(
$commentData,
array('post_id' => $postId, 'created_at' => $createdTimestamp, )
)
);
72
73
74
 
 
75
76
77
78
79
80
81
throw new Exception('Cannot prepare statement to insert comment');
}
$result = $stmt->execute(
array_merge(
$commentData,
array('post_id' => $postId, 'created_at' => getSqlDateForNow(), )
)
);

I noticed that the installer creates its own database connection. Although there was no pressing need to do so, I modified it so it uses a connection passed to it. This would make it easier to run automated tests against it, for example — a custom test connection would be passed to it, rather than the "hard-wired" one we have now removed.

Expand/contract code area Select previous tab
Select next tab
9
10
11
12
 
13
14
15
if ($_POST)
{
// Here's the install
list($_SESSION['count'], $_SESSION['error']) = installBlog();
// ... and here we redirect from POST to GET
redirectAndExit('install.php');
9
10
11
12
13
14
15
16
if ($_POST)
{
// Here's the install
$pdo = getPDO();
list($_SESSION['count'], $_SESSION['error']) = installBlog($pdo);
// ... and here we redirect from POST to GET
redirectAndExit('install.php');
5
6
7
8
9
10
11
46
47
48
49
50
51
52
*
* @return array(count array, error string)
*/
function installBlog()
{
// Get the PDO DSN string
$root = getRootPath();
// Connect to the new database and try to run the SQL commands
if (!$error)
{
$pdo = getPDO();
$result = $pdo->exec($sql);
if ($result === false)
{
5
6
7
8
9
10
11
46
47
48
 
49
50
51
*
* @return array(count array, error string)
*/
function installBlog(PDO $pdo)
{
// Get the PDO DSN string
$root = getRootPath();
// Connect to the new database and try to run the SQL commands
if (!$error)
{
$result = $pdo->exec($sql);
if ($result === false)
{

The last tweak is very easy: a re-reading of some of the code showed that I'd not updated a comment in line with a code change. Now, I could magic this away for the benefit of the tutorial, but I rather like the opportunity to show that code is never perfect, and that sometimes comments come out of sync with what they're meant to describe! So, just apply the following diff, and we're done for this chapter.

Expand/contract code area Select previous tab
Select next tab
7
8
9
10
11
12
13
*/
function installBlog(PDO $pdo)
{
// Get the PDO DSN string
$root = getRootPath();
$database = getDatabasePath();
7
8
9
10
11
12
13
*/
function installBlog(PDO $pdo)
{
// Get a couple of useful project paths
$root = getRootPath();
$database = getDatabasePath();