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
16 Aug 2018 Some bug fixes, remove compatibility library for earlier version of PHP 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

Comment admin

In this section, we'll allow authorised users to delete comments from posts. To kick us off, here's a nice item of refactoring — the comments list within article pages is quite large, and it would be more modular to store it in a separate file.

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
<?php
/**
* @var $pdo PDO
* @var $postId integer
*/
?>
<div class="comment-list">
<h3><?php echo countCommentsForPost($pdo, $postId) ?> comments</h3>
<?php foreach (getCommentsForPost($pdo, $postId) as $comment): ?>
<div class="comment">
<div class="comment-meta">
Comment from
<?php echo htmlEscape($comment['name']) ?>
on
<?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 ?>
</div>
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<?php echo convertNewlinesToParagraphs($row['body']) ?>
</div>
<div class="comment-list">
<h3><?php echo countCommentsForPost($pdo, $postId) ?> comments</h3>
<?php foreach (getCommentsForPost($pdo, $postId) as $comment): ?>
<div class="comment">
<div class="comment-meta">
Comment from
<?php echo htmlEscape($comment['name']) ?>
on
<?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 ?>
</div>
<?php require 'templates/comment-form.php' ?>
</body>
79
80
81
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
84
85
<?php echo convertNewlinesToParagraphs($row['body']) ?>
</div>
<?php require 'templates/list-comments.php' ?>
<?php require 'templates/comment-form.php' ?>
</body>

Let us now add a delete button next to comments on blog posts, for authorised users only. I've added a bit of extra CSS to get it to float to the right-hand side.

Expand/contract code area Select previous tab
Select next tab
69
70
71
 
 
 
 
72
73
74
80
81
82
 
83
84
85
padding-top: 8px;
}
.comment-body p {
margin: 8px 4px;
}
.comment-list {
border-bottom: 1px dotted silver;
margin-bottom: 12px;
}
.comment-margin {
69
70
71
72
73
74
75
76
77
78
80
81
82
83
84
85
86
padding-top: 8px;
}
.comment .comment-meta input {
float: right;
}
.comment-body p {
margin: 8px 4px;
}
.comment-list {
border-bottom: 1px dotted silver;
margin-bottom: 12px;
max-width: 900px;
}
.comment-margin {
14
15
16
 
 
 
 
 
 
 
17
18
19
<?php echo htmlEscape($comment['name']) ?>
on
<?php echo convertSqlDate($comment['created_at']) ?>
</div>
<div class="comment-body">
<?php // This is already escaped ?>
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php echo htmlEscape($comment['name']) ?>
on
<?php echo convertSqlDate($comment['created_at']) ?>
<?php if (isLoggedIn()): ?>
<input
type="submit"
name="delete-comment[<?php echo $comment['id'] ?>]"
value="Delete"
/>
<?php endif ?>
</div>
<div class="comment-body">
<?php // This is already escaped ?>

Since we want to do more than one thing with comments (add and delete), we need to make a change to explain what we are doing within the form submission. To do this, I've used the query string, which sends these items of information:

Expand/contract code area Select previous tab
Select next tab
18
19
20
21
 
 
 
 
22
23
24
<h3>Add your comment</h3>
<form method="post" class="comment-form user-form">
<div>
<label for="comment-name">
Name:
18
19
20
21
22
23
24
25
26
27
28
<h3>Add your comment</h3>
<form
action="view-post.php?action=add-comment&amp;post_id=<?php echo $postId?>"
method="post"
class="comment-form user-form"
>
<div>
<label for="comment-name">
Name:
4
5
6
7
 
 
 
 
8
9
10
32
33
34
35
* @var $postId integer
*/
?>
<div class="comment-list">
<h3><?php echo countCommentsForPost($pdo, $postId) ?> comments</h3>
<?php foreach (getCommentsForPost($pdo, $postId) as $comment): ?>
</div>
</div>
<?php endforeach ?>
</div>
4
5
6
7
8
9
10
11
12
13
14
32
33
34
35
* @var $postId integer
*/
?>
<form
action="view-post.php?action=delete-comment&amp;post_id=<?php echo $postId?>&amp;"
method="post"
class="comment-list"
>
<h3><?php echo countCommentsForPost($pdo, $postId) ?> comments</h3>
<?php foreach (getCommentsForPost($pdo, $postId) as $comment): ?>
</div>
</div>
<?php endforeach ?>
</form>

Let us now make some room in our view post page, by moving some logic to a new function.

Expand/contract code area Select previous tab
Select next tab
1
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
4
5
<?php
/**
* Retrieves a single post
*
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
<?php
/**
* Called to handle the comment form, redirects upon success
*
* @param PDO $pdo
* @param integer $postId
* @param array $commentData
*/
function handleAddComment(PDO $pdo, $postId, array $commentData)
{
$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);
}
return $errors;
}
/**
* Retrieves a single post
*
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
71
72
73
 
74
75
76
'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);
}
}
else
{
<?php require 'templates/list-comments.php' ?>
<?php require 'templates/comment-form.php' ?>
</body>
</html>
33
34
35
36
 
 
 
 
 
 
 
 
 
 
37
38
39
71
72
73
74
75
76
77
'website' => $_POST['comment-website'],
'text' => $_POST['comment-text'],
);
$errors = handleAddComment($pdo, $postId, $commentData);
}
else
{
<?php require 'templates/list-comments.php' ?>
<?php // We use $commentData in this HTML fragment ?>
<?php require 'templates/comment-form.php' ?>
</body>
</html>

We now need to read the action key we set up, so we can decide what feature to call. If the user is deleting a comment, the new function deleteComment in the next set of changes is called; although we could just delete by comment.id, to be sure of deleting the right thing we filter additionally by comment.post_id.

Note that we also add an if() clause to ensure that the delete operation is skipped if the user is not logged in. Although we do not render delete buttons for anonymous users, it is still possible for security crackers to fake a form submission that contains a valid deletion request — this clause prevents that.

Expand/contract code area Select previous tab
Select next tab
24
25
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
28
29
return $errors;
}
/**
* Retrieves a single post
*
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
return $errors;
}
/**
* Delete the specified comment on the specified post
*
* @param PDO $pdo
* @param integer $postId
* @param integer $commentId
* @return boolean True if the command executed without errors
* @throws Exception
*/
function deleteComment(PDO $pdo, $postId, $commentId)
{
// The comment id on its own would suffice, but post_id is a nice extra safety check
$sql = "
DELETE FROM
comment
WHERE
post_id = :post_id
AND id = :comment_id
";
$stmt = $pdo->prepare($sql);
if ($stmt === false)
{
throw new Exception('There was a problem preparing this query');
}
$result = $stmt->execute(
array(
'post_id' => $postId,
'comment_id' => $commentId,
)
);
return $result !== false;
}
/**
* Retrieves a single post
*
28
29
30
31
32
33
34
35
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
38
39
$errors = null;
if ($_POST)
{
$commentData = array(
'name' => $_POST['comment-name'],
'website' => $_POST['comment-website'],
'text' => $_POST['comment-text'],
);
$errors = handleAddComment($pdo, $postId, $commentData);
}
else
{
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
$errors = null;
if ($_POST)
{
switch ($_GET['action'])
{
case 'add-comment':
$commentData = array(
'name' => $_POST['comment-name'],
'website' => $_POST['comment-website'],
'text' => $_POST['comment-text'],
);
$errors = handleAddComment($pdo, $postId, $commentData);
break;
case 'delete-comment':
// Don't do anything if the user is not authorised
if (isLoggedIn())
{
$deleteResponse = $_POST['delete-comment'];
$keys = array_keys($deleteResponse);
$deleteCommentId = $keys[0];
deleteComment($pdo, $postId, $deleteCommentId);
redirectAndExit('view-post.php?post_id=' . $postId);
}
break;
}
}
else
{

Finally, I noticed that the logic to add a comment is contained within a function, but the logic to delete one is still in the switch() block in view-post.php. The following change just tidies that up:

Expand/contract code area Select previous tab
Select next tab
24
25
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
28
29
return $errors;
}
/**
* Delete the specified comment on the specified post
*
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
return $errors;
}
/**
* Called to handle the delete comment form, redirects afterwards
*
* The $deleteResponse array is expected to be in the form:
*
* Array ( [6] => Delete )
*
* which comes directly from input elements of this form:
*
* name="delete-comment[6]"
*
* @param PDO $pdo
* @param integer $postId
* @param array $deleteResponse
*/
function handleDeleteComment(PDO $pdo, $postId, array $deleteResponse)
{
if (isLoggedIn())
{
$keys = array_keys($deleteResponse);
$deleteCommentId = $keys[0];
if ($deleteCommentId)
{
deleteComment($pdo, $postId, $deleteCommentId);
}
redirectAndExit('view-post.php?post_id=' . $postId);
}
}
/**
* Delete the specified comment on the specified post
*
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
$errors = handleAddComment($pdo, $postId, $commentData);
break;
case 'delete-comment':
// Don't do anything if the user is not authorised
if (isLoggedIn())
{
$deleteResponse = $_POST['delete-comment'];
$keys = array_keys($deleteResponse);
$deleteCommentId = $keys[0];
deleteComment($pdo, $postId, $deleteCommentId);
redirectAndExit('view-post.php?post_id=' . $postId);
}
break;
}
}
39
40
41
42
43
 
 
 
 
 
 
 
44
45
46
$errors = handleAddComment($pdo, $postId, $commentData);
break;
case 'delete-comment':
$deleteResponse = $_POST['delete-comment'];
handleDeleteComment($pdo, $postId, $deleteResponse);
break;
}
}

As ever, test everything to check it works, before we proceed to the final chapter!