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

Tidy up

We've got a nice set of features working, but there are a good number of improvements and refactoring changes that can be made. Doing these periodically can make for easier and happier development, which in turn can improve your development efficiency.

The first change is two-fold: pages were replicating some information in the header, and various items (the top menu bar and system messages) were using inline style rules rather than using class rules that need only be written once.

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
/* Success/error message boxes */
.box {
border: 1px dotted silver;
border-radius: 5px;
padding: 4px;
}
.error {
background-color: #ff6666;
}
.success {
background-color: #88ff88;
}
.install-password {
font-size: 1.2em;
}
.top-menu {
border: 1px dotted silver;
min-height: 18px;
padding: 4px;
margin-bottom: 4px;
}
.menu-options {
float: right;
}
h1, h2, h3 {
margin-top: 0;
margin-bottom: 8px;
}
body {
font-family: sans-serif;
}
25
26
27
28
29
30
31
32
33
34
35
36
37
<html>
<head>
<title>A blog application</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
<?php require 'templates/title.php' ?>
<?php if ($notFound): ?>
<div style="border: 1px solid #ff6666; padding: 6px;">
Error: cannot find the requested blog post
</div>
<?php endif ?>
25
26
27
28
29
30
31
32
33
34
35
36
37
<html>
<head>
<title>A blog application</title>
<?php require 'templates/head.php' ?>
</head>
<body>
<?php require 'templates/title.php' ?>
<?php if ($notFound): ?>
<div class="error box">
Error: cannot find the requested blog post
</div>
<?php endif ?>
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
78
79
80
81
82
83
84
<html>
<head>
<title>Blog installer</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<style type="text/css">
.box {
border: 1px dotted silver;
border-radius: 5px;
padding: 4px;
}
.error {
background-color: #ff6666;
}
.success {
background-color: #88ff88;
}
</style>
</head>
<body>
<?php if ($attempted): ?>
<?php // Report the new password ?>
The new '<?php echo htmlEscape($username) ?>' password is
<span style="font-size: 1.2em;"><?php echo htmlEscape($password) ?></span>
(copy it to clipboard if you wish).
</div>
52
53
54
55
 
 
 
 
 
 
 
 
 
 
 
 
 
56
57
58
78
79
80
81
82
83
84
<html>
<head>
<title>Blog installer</title>
<?php require 'templates/head.php' ?>
</head>
<body>
<?php if ($attempted): ?>
<?php // Report the new password ?>
The new '<?php echo htmlEscape($username) ?>' password is
<span class="install-password"><?php echo htmlEscape($password) ?></span>
(copy it to clipboard if you wish).
</div>
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<title>
A blog application | Login
</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
<?php require 'templates/title.php' ?>
<?php // If we have a username, then the user got something wrong, so let's have an error ?>
<?php if ($username): ?>
<div style="border: 1px solid #ff6666; padding: 6px;">
The username or password is incorrect, try again
</div>
<?php endif ?>
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<title>
A blog application | Login
</title>
<?php require 'templates/head.php' ?>
</head>
<body>
<?php require 'templates/title.php' ?>
<?php // If we have a username, then the user got something wrong, so let's have an error ?>
<?php if ($username): ?>
<div class="error box">
The username or password is incorrect, try again
</div>
<?php endif ?>
10
11
12
13
14
15
16
<?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>
10
11
12
13
14
15
16
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php echo $error ?></li>
1
2
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<link rel="stylesheet" type="text/css" href="assets/main.css" />
1
2
3
4
5
6
7
 
 
8
9
10
<div style="float: right;">
<?php if (isLoggedIn()): ?>
Hello <?php echo htmlEscape(getAuthUser()) ?>.
<a href="logout.php">Log out</a>
<?php else: ?>
<a href="login.php">Log in</a>
<?php endif ?>
</div>
<a href="index.php">
1
2
3
4
5
6
7
8
9
10
11
12
<div class="top-menu">
<div class="menu-options">
<?php if (isLoggedIn()): ?>
Hello <?php echo htmlEscape(getAuthUser()) ?>.
<a href="logout.php">Log out</a>
<?php else: ?>
<a href="login.php">Log in</a>
<?php endif ?>
</div>
</div>
<a href="index.php">
62
63
64
65
66
67
68
A blog application |
<?php echo htmlEscape($row['title']) ?>
</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
<?php require 'templates/title.php' ?>
62
63
64
65
66
67
68
A blog application |
<?php echo htmlEscape($row['title']) ?>
</title>
<?php require 'templates/head.php' ?>
</head>
<body>
<?php require 'templates/title.php' ?>

Let's take a better look at the second change. Some of the original code was written like this:

<div style="border: 1px solid #ff6666; padding: 6px;"> … </div>

The purpose of the style attribute to specify CSS rules (otherwise known as style rules) to the HTML within. Firstly, we have the border rule, which says the content (an error message) should have a red border rendered in an unbroken line, and the padding means that there should be six pixels of gap between the border and an invisible box around the content.

However, having to write that for every error message takes a bit of effort, and it's a pain if we decide that error messages should have a magenta border and not a red one. Thus, it makes life easier if we write this instead:

<div class="error box"> … </div>

That applies two rules to the block: one called error and another called box. That's much easier to remember, easier to read, and — since we centralise the definitions in assets/main.css — easier to change if we have to.

While we are dealing with CSS changes, let's add a few more. Here we add some rules for article synopses, titles and dates on the home page, and comments on individual blog posts.

Expand/contract code area Select previous tab
Select next tab
33
34
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
body {
font-family: sans-serif;
}
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
body {
font-family: sans-serif;
}
.post-synopsis {
padding-bottom: 8px;
border-bottom: 1px dotted silver;
margin-bottom: 20px;
}
.post-synopsis h2, .post h2 {
color: darkblue;
}
.post .date, .post-synopsis .meta {
color: white;
background-color: grey;
border-radius: 7px;
padding: 2px;
display: inline;
font-size: 0.95em;
}
.comment .comment-meta {
font-size: 0.85em;
color: grey;
border-top: 1px dotted silver;
padding-top: 8px;
}
.comment-body p {
margin: 8px 4px;
}
.comment-list {
border-bottom: 1px dotted silver;
margin-bottom: 12px;
}
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
 
 
 
 
57
58
59
</div>
<?php endif ?>
<?php while ($row = $stmt->fetch(PDO::FETCH_ASSOC)): ?>
<h2>
<?php echo htmlEscape($row['title']) ?>
</h2>
<div>
<?php echo convertSqlDate($row['created_at']) ?>
(<?php echo countCommentsForPost($row['id']) ?> comments)
</div>
<p>
<?php echo htmlEscape($row['body']) ?>
</p>
<p>
<a
href="view-post.php?post_id=<?php echo $row['id'] ?>"
>Read more...</a>
</p>
<?php endwhile ?>
</body>
</html>
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
</div>
<?php endif ?>
<div class="post-list">
<?php while ($row = $stmt->fetch(PDO::FETCH_ASSOC)): ?>
<div class="post-synopsis">
<h2>
<?php echo htmlEscape($row['title']) ?>
</h2>
<div class="meta">
<?php echo convertSqlDate($row['created_at']) ?>
(<?php echo countCommentsForPost($row['id']) ?> comments)
</div>
<p>
<?php echo htmlEscape($row['body']) ?>
</p>
<div class="read-more">
<a
href="view-post.php?post_id=<?php echo $row['id'] ?>"
>Read more...</a>
</div>
</div>
<?php endwhile ?>
</div>
</body>
</html>
5
6
7
8
9
10
11
12
13
*/
?>
<?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 class="error box">
5
6
7
 
 
 
8
9
10
*/
?>
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box">
67
68
69
70
71
72
73
74
 
 
 
 
 
75
76
77
78
79
 
80
81
82
83
84
85
86
87
88
89
 
 
 
90
91
92
93
94
95
96
97
98
99
<body>
<?php require 'templates/title.php' ?>
<h2>
<?php echo htmlEscape($row['title']) ?>
</h2>
<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 foreach (getCommentsForPost($postId) as $comment): ?>
<?php // For now, we'll use a horizontal rule-off to split it up a bit ?>
<hr />
<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 ?>
<?php require 'templates/comment-form.php' ?>
</body>
67
68
69
70
71
72
73
74
75
76
77
78
79
80
 
 
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
 
 
 
 
100
101
102
<body>
<?php require 'templates/title.php' ?>
<div class="post">
<h2>
<?php echo htmlEscape($row['title']) ?>
</h2>
<div class="date">
<?php echo convertSqlDate($row['created_at']) ?>
</div>
<?php // This is already escaped, so doesn't need further escaping ?>
<?php echo convertNewlinesToParagraphs($row['body']) ?>
</div>
<div class="comment-list">
<h3><?php echo countCommentsForPost($postId) ?> comments</h3>
<?php foreach (getCommentsForPost($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>

You may have noticed that some function calls that need to access the database create their own connection rather than use one that we've already created. For low-volume systems this might not matter a great deal, but programmers hate this sort of inefficiency, and where it is trivial to fix, we should.

Expand/contract code area Select previous tab
Select next tab
45
46
47
48
49
50
51
<div class="meta">
<?php echo convertSqlDate($row['created_at']) ?>
(<?php echo countCommentsForPost($row['id']) ?> comments)
</div>
<p>
<?php echo htmlEscape($row['body']) ?>
45
46
47
48
49
50
51
<div class="meta">
<?php echo convertSqlDate($row['created_at']) ?>
(<?php echo countCommentsForPost($pdo, $row['id']) ?> comments)
</div>
<p>
<?php echo htmlEscape($row['body']) ?>
94
95
96
 
97
98
99
100
101
102
103
104
105
119
120
121
 
122
 
123
124
125
126
127
128
129
/**
* Returns the number of comments for the specified post
*
* @param integer $postId
* @return integer
*/
function countCommentsForPost($postId)
{
$pdo = getPDO();
$sql = "
SELECT
COUNT(*) c
/**
* Returns all the comments for the specified post
*
* @param integer $postId
*/
function getCommentsForPost($postId)
{
$pdo = getPDO();
$sql = "
SELECT
id, name, text, created_at, website
94
95
96
97
98
99
100
101
102
 
103
104
105
119
120
121
122
123
124
125
126
127
 
128
129
130
/**
* Returns the number of comments for the specified post
*
* @param PDO $pdo
* @param integer $postId
* @return integer
*/
function countCommentsForPost(PDO $pdo, $postId)
{
$sql = "
SELECT
COUNT(*) c
/**
* Returns all the comments for the specified post
*
* @param PDO $pdo
* @param integer $postId
* return array
*/
function getCommentsForPost(PDO $pdo, $postId)
{
$sql = "
SELECT
id, name, text, created_at, website
80
81
82
83
84
85
86
87
88
</div>
<div class="comment-list">
<h3><?php echo countCommentsForPost($postId) ?> comments</h3>
<?php foreach (getCommentsForPost($postId) as $comment): ?>
<div class="comment">
<div class="comment-meta">
Comment from
80
81
82
83
84
85
86
87
88
</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

Now we improve the appearance of the comment form. It's worth opening up an article page prior to making the improvements, so you can refresh it after the CSS is in place. This will allow you to see the change quickly.

Expand/contract code area Select previous tab
Select next tab
10
11
12
 
 
 
 
 
 
 
 
13
14
15
76
77
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
.success {
background-color: #88ff88;
}
.install-password {
font-size: 1.2em;
.comment-list {
border-bottom: 1px dotted silver;
margin-bottom: 12px;
}
10
11
12
13
14
15
16
17
18
19
20
21
22
23
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
.success {
background-color: #88ff88;
}
.box ul {
margin: 4px;
padding-left: 14px;
}
.box ul li {
margin-bottom: 2px;
}
.install-password {
font-size: 1.2em;
.comment-list {
border-bottom: 1px dotted silver;
margin-bottom: 12px;
}
.comment-margin {
margin-bottom: 8px;
}
.comment-form input,
.comment-form textarea {
margin: 4px;
}
.comment-form label {
font-size: 0.95em;
margin: 6px;
width: 7em;
color: grey;
float: left;
text-align: right;
/* Some browsers make labels too tall, and as a result they incorrectly stack horizontally.
Let's reset each to the left-hand side to be sure. */
clear: left;
}
7
8
9
10
11
12
13
18
19
20
21
22
23
24
25
29
30
31
32
33
34
35
36
40
41
42
43
44
45
46
47
51
52
53
54
55
56
 
 
57
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php echo $error ?></li>
<h3>Add your comment</h3>
<form method="post">
<p>
<label for="comment-name">
Name:
</label>
name="comment-name"
value="<?php echo htmlEscape($commentData['name']) ?>"
/>
</p>
<p>
<label for="comment-website">
Website:
</label>
name="comment-website"
value="<?php echo htmlEscape($commentData['website']) ?>"
/>
</p>
<p>
<label for="comment-text">
Comment:
</label>
rows="8"
cols="70"
><?php echo htmlEscape($commentData['text']) ?></textarea>
</p>
<input type="submit" value="Submit comment" />
</form>
7
8
9
10
11
12
13
18
19
20
21
22
23
24
25
29
30
31
32
33
34
35
36
40
41
42
43
44
45
46
47
51
52
53
54
55
56
57
58
59
<?php // Report any errors in a bullet-point list ?>
<?php if ($errors): ?>
<div class="error box comment-margin">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php echo $error ?></li>
<h3>Add your comment</h3>
<form method="post" class="comment-form">
<div>
<label for="comment-name">
Name:
</label>
name="comment-name"
value="<?php echo htmlEscape($commentData['name']) ?>"
/>
</div>
<div>
<label for="comment-website">
Website:
</label>
name="comment-website"
value="<?php echo htmlEscape($commentData['website']) ?>"
/>
</div>
<div>
<label for="comment-text">
Comment:
</label>
rows="8"
cols="70"
><?php echo htmlEscape($commentData['text']) ?></textarea>
</div>
<div>
<input type="submit" value="Submit comment" />
</div>
</form>

We now turn our attention to an improvement to the database. This consists of the tables post and user, for blog posts and authors respectively. When creating a post record, we insert our automatically generated user.id in post.user_id to store which user has authored it (of course, we only have one user in our test data, but in practice we might have several).

However, it is possible to insert any number in post.user_id, so if we have an undiscovered bug in our code, it might cause a non-existent user primary key to be stored here. Since we rely on values here always pointing to a user row, if a bad value were to get in, it might crash our application.

To protect against that scenario, many database systems will allow the use of foreign key constraints. These allow us to specify that values inserted into a particular column must exist in another column belonging to another table, and that an error must occur if this condition is not met. SQLite offers this feature, although it is unusual in that it needs to be turned on explicitly, using the PRAGMA command.

So, let's make these changes:

Expand/contract code area Select previous tab
Select next tab
2
3
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
6
7
36
37
38
39
 
40
41
42
91
92
93
94
 
95
96
97
122
123
124
125
126
127
128
129
130
131
132
* Database creation script
*/
DROP TABLE IF EXISTS post;
CREATE TABLE post (
body VARCHAR NOT NULL,
user_id INTEGER NOT NULL,
created_at VARCHAR NOT NULL,
updated_at VARCHAR
);
INSERT INTO
created_at VARCHAR NOT NULL,
name VARCHAR NOT NULL,
website VARCHAR,
text VARCHAR NOT NULL
);
INSERT INTO
"This is a comment from Jonny"
)
;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username VARCHAR NOT NULL,
password VARCHAR NOT NULL,
created_at VARCHAR NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true
);
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
36
37
38
39
40
41
42
43
91
92
93
94
95
96
97
98
122
123
124
* Database creation script
*/
/* Foreign key constraints need to be explicitly enabled in SQLite */
PRAGMA foreign_keys = ON;
DROP TABLE IF EXISTS user;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username VARCHAR NOT NULL,
password VARCHAR NOT NULL,
created_at VARCHAR NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true
);
/* This will become user = 1. I'm creating this just to satisfy constraints here.
The password will be properly hashed in the installer */
INSERT INTO
user
(
username, password, created_at, is_enabled
)
VALUES
(
"admin", "unhashed-password", datetime('now', '-3 months'), 0
)
;
DROP TABLE IF EXISTS post;
CREATE TABLE post (
body VARCHAR NOT NULL,
user_id INTEGER NOT NULL,
created_at VARCHAR NOT NULL,
updated_at VARCHAR,
FOREIGN KEY (user_id) REFERENCES user(id)
);
INSERT INTO
created_at VARCHAR NOT NULL,
name VARCHAR NOT NULL,
website VARCHAR,
text VARCHAR NOT NULL,
FOREIGN KEY (post_id) REFERENCES post(id)
);
INSERT INTO
"This is a comment from Jonny"
)
;
37
38
39
40
 
 
 
 
 
 
 
 
 
41
42
43
*/
function getPDO()
{
return new PDO(getDsn());
}
/**
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
*/
function getPDO()
{
$pdo = new PDO(getDsn());
// Foreign key constraints need to be enabled manually in SQLite
$result = $pdo->query('PRAGMA foreign_keys = ON');
if ($result === false)
{
throw new Exception('Could not turn on foreign key constraints');
}
return $pdo;
}
/**
76
77
78
79
80
81
82
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
135
136
137
138
139
140
141
}
/**
* Creates a new user in the database
*
* @param PDO $pdo
* @param string $username
// Insert the credentials into the database
$sql = "
INSERT INTO
user
(username, password, created_at)
VALUES (
:username, :password, :created_at
)
";
$stmt = $pdo->prepare($sql);
if ($stmt === false)
{
$error = 'Could not prepare the user creation';
}
if (!$error)
);
if ($result === false)
{
$error = 'Could not run the user creation';
}
}
76
77
78
79
80
81
82
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
135
136
137
138
139
140
141
}
/**
* Updates the admin user in the database
*
* @param PDO $pdo
* @param string $username
// Insert the credentials into the database
$sql = "
UPDATE
user
SET
password = :password, created_at = :created_at, is_enabled = 1
WHERE
username = :username
";
$stmt = $pdo->prepare($sql);
if ($stmt === false)
{
$error = 'Could not prepare the user update';
}
if (!$error)
);
if ($result === false)
{
$error = 'Could not run the user password update';
}
}

Here's what's new:

As usual: delete your database file, re-run the installer, and check all the changes appear to work.

The last change is related to security. Since all of our files are in the web server's public directory, a user who knows our directory structure would be able to download files that we didn't intend to make accessible. Since SQLite uses a single file to store its data, and since this file is often stored in a web-accessible location, it is of particular importance to lock this down.

Expand/contract code area Select previous tab
Select next tab
1
2
3
4
RewriteEngine on
RewriteCond %{REQUEST_URI} ^/(data|lib|templates|vendor)/
RewriteRule ^ - [L,R=404]