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.
 
		 
	- lib/view-post.php lib/view-post.php
- templates/comment-form.php templates/comment-form.php
- view-post.php view-post.php
 
				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 = htmlEscape($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 = htmlEscape($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:
- Reset the errors variable to null i.e. we do not yet know if they are any errors.
- 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.
- Get the author name, website URL and comment message, and pass them to new
		function addCommentToPost()for validation and saving.
- This will return any resulting errors into the $errorsarray.
- 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.
- 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:
 
		 
	- lib/view-post.php lib/view-post.php
 
				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() via our custom function htmlEscape(),
	which prevents any rendering problems if the user has used any HTML characters such as angle
	brackets.
 
		 
	- templates/comment-form.php templates/comment-form.php
- view-post.php view-post.php
 
				 
					 
					 
					 
					 
					 
					 
					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 htmlEscape($commentData['name']) ?>"
							/>
						</p>
						<p>
								type="text"
								id="comment-website"
								name="comment-website"
								value="<?php echo htmlEscape($commentData['website']) ?>"
							/>
						</p>
						<p>
								name="comment-text"
								rows="8"
								cols="70"
							><?php echo htmlEscape($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 = htmlEscape($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 = htmlEscape($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:
 
		 
	- lib/common.php lib/common.php
- view-post.php view-post.php
 
				59
					60
					61
					 
					 
					 
					 
					 
					 
					 
					 
					 
					 
					 
					 
					 
					62
					63
					64
						return $date->format('d M Y, H:i');
					}
					function redirectAndExit($script)
					{
						// Get the domain-relative URL (e.g. /blog/whatever.php or /whatever.php) and work
					59
					60
					61
					62
					63
					64
					65
					66
					67
					68
					69
					70
					71
					72
					73
					74
					75
					76
					77
						return $date->format('d M Y, H:i');
					}
					/**
					 * Converts unsafe text to safe, paragraphed, HTML
					 *
					 * @param string $text
					 * @return string
					 */
					function convertNewlinesToParagraphs($text)
					{
						$escaped = htmlEscape($text);
						return '<p>' . str_replace("\n", "</p><p>", $escaped) . '</p>';
					}
					function redirectAndExit($script)
					{
						// Get the domain-relative URL (e.g. /blog/whatever.php or /whatever.php) and work
					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 = htmlEscape($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 htmlEscape($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:
 
		 
	- install.php install.php
- lib/install.php lib/install.php
 
				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:
 
		 
	- lib/common.php lib/common.php
- lib/view-post.php lib/view-post.php
 
				59
					60
					61
					 
					 
					 
					 
					 
					62
					63
					64
						return $date->format('d M Y, H:i');
					}
					/**
					 * Converts unsafe text to safe, paragraphed, HTML
					 *
					59
					60
					61
					62
					63
					64
					65
					66
					67
					68
					69
						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.
 
		 
	- install.php install.php
- lib/install.php lib/install.php
 
				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.
 
		 
	- lib/install.php lib/install.php
 
				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();
					 Download
						Download