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

Improving the installer

At this juncture, I decided to tidy up the installer a bit more. Firstly, it would be nice to see some data about what it has created; in the course of development you can expect to wipe and recreate your test data hundreds of times, so it's worth making the output useful.

Secondly, as it stands it demonstrates non-optimal techniques, and fixing that gives me an opportunity to explain how to improve upon it. Broadly, the problem is that visiting the URL changes the database, but it does not take into account that web addresses can receive visits from automated software (e.g. search engines looking for new websites). To be sure that it is a human who has requested an install, I've used a form with a "post" method (this is explained in more detail later).

The changes here are quite substantial, so you may find it easier to download the file, and copy the whole thing over the top of the old version. So, here's the diff:

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
120
121
122
123
124
125
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
<?php
require_once 'lib/common.php';
// 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();
}
}
}
?>
</style>
</head>
<body>
<?php if ($error): ?>
<div class="error box">
<?php echo $error ?>
</div>
<?php else: ?>
<div class="success box">
The database and demo data was created OK.
<?php foreach (array('post', 'comment') as $tableName): ?>
<?php if (isset($count[$tableName])): ?>
<?php // Prints the count ?>
<?php echo $count[$tableName] ?> new
<?php // Prints the name of the thing ?>
<?php echo $tableName ?>s
were created.
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>
</body>
</html>
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
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
 
 
157
158
159
<?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();
// Only run the installer when we're responding to the form
if ($_POST)
{
// Here's the install
list($_SESSION['count'], $_SESSION['error']) = installBlog();
// ... and here we redirect from POST to GET
$host = $_SERVER['HTTP_HOST'];
$script = $_SERVER['REQUEST_URI'];
header('Location: http://' . $host . $script);
exit();
}
// Let's see if we've just installed
$attempted = false;
if ($_SESSION)
{
$attempted = true;
$count = $_SESSION['count'];
$error = $_SESSION['error'];
// Unset session variables, so we only report the install/failure once
unset($_SESSION['count']);
unset($_SESSION['error']);
}
?>
</style>
</head>
<body>
<?php if ($attempted): ?>
<?php if ($error): ?>
<div class="error box">
<?php echo $error ?>
</div>
<?php else: ?>
<div class="success box">
The database and demo data was created OK.
<?php foreach (array('post', 'comment') as $tableName): ?>
<?php if (isset($count[$tableName])): ?>
<?php // Prints the count ?>
<?php echo $count[$tableName] ?> new
<?php // Prints the name of the thing ?>
<?php echo $tableName ?>s
were created.
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>
<?php else: ?>
<p>Click the install button to reset the database.</p>
<form method="post">
<input
name="install"
type="submit"
value="Install"
/>
</form>
<?php endif ?>
</body>
</html>

Finally, let's add some links so we can easily move to our next task after re-installing:

Expand/contract code area Select previous tab
Select next tab
140
141
142
 
 
 
 
 
143
144
145
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>
<?php else: ?>
140
141
142
143
144
145
146
147
148
149
150
<?php endif ?>
<?php endforeach ?>
</div>
<p>
<a href="index.php">View the blog</a>,
or <a href="install.php">install again</a>.
</p>
<?php endif ?>
<?php else: ?>

As is our custom, delete your database file and re-run the installer. This time you should have an "Install" button. All being well, it will do the same installation when you click it, and then you can opt to go straight to the blog's home page.

~

Since our changes to the installer have been brief, let's do some refactoring and a couple of minor functionality tweaks. Now, it could be said that this process of tidying seems rather erratic, and that similar items of work should be neatly collected together. However, real-life development rarely works that way: instead, a rough but usable solution is developed, and then improvements and refactorings are added depending on user feedback, and often also on time available.

So, here's our first small improvement task. One of the things you may have noticed is that posts and comments are being marked as written on a particular day, but with no time information. It is usual to record and show this sort of data, so let's do that now. Don't forget to re-run the installer to test it!

Expand/contract code area Select previous tab
Select next tab
24
25
26
27
28
29
30
38
39
40
41
42
43
44
52
53
54
55
56
57
58
74
75
76
77
78
79
80
88
89
90
91
92
93
94
It is split into paragraphs.",
1,
date('now', '-2 months')
)
;
"This is the body of the second post.
This is another paragraph.",
1,
date('now', '-40 days')
)
;
"This is the body of the third post.
This is split into paragraphs.",
1,
date('now', '-13 days')
)
;
)
VALUES(
1,
date('now', '-10 days'),
'Jimmy',
'http://example.com/',
"This is Jimmy's contribution"
)
VALUES(
1,
date('now', '-8 days'),
'Jonny',
'http://anotherexample.com/',
"This is a comment from Jonny"
24
25
26
27
28
29
30
38
39
40
41
42
43
44
52
53
54
55
56
57
58
74
75
76
77
78
79
80
88
89
90
91
92
93
94
It is split into paragraphs.",
1,
datetime('now', '-2 months', '-45 minutes', '+10 seconds')
)
;
"This is the body of the second post.
This is another paragraph.",
1,
datetime('now', '-40 days', '+815 minutes', '+37 seconds')
)
;
"This is the body of the third post.
This is split into paragraphs.",
1,
datetime('now', '-13 days', '+198 minutes', '+51 seconds')
)
;
)
VALUES(
1,
datetime('now', '-10 days', '+231 minutes', '+7 seconds'),
'Jimmy',
'http://example.com/',
"This is Jimmy's contribution"
)
VALUES(
1,
datetime('now', '-8 days', '+549 minutes', '+32 seconds'),
'Jonny',
'http://anotherexample.com/',
"This is a comment from Jonny"
54
55
56
57
58
59
60
61
62
function convertSqlDate($sqlDate)
{
/* @var $date DateTime */
$date = DateTime::createFromFormat('Y-m-d', $sqlDate);
return $date->format('d M Y');
}
/**
54
55
56
57
58
59
60
61
62
function convertSqlDate($sqlDate)
{
/* @var $date DateTime */
$date = DateTime::createFromFormat('Y-m-d H:i:s', $sqlDate);
return $date->format('d M Y, H:i');
}
/**

Following on from our last bit of refactoring, here's another opportunity to tidy code. Arguably, the view post page has too much business logic in it, and it would be more maintainable to move this to a separate file. So, create lib/view-post.php and paste in the new content.

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
<?php
/**
* Retrieves a single post
*
* @param PDO $pdo
* @param integer $postId
* @throws Exception
*/
function getPostRow(PDO $pdo, $postId)
{
$stmt = $pdo->prepare(
'SELECT
title, created_at, body
FROM
post
WHERE
id = :id'
);
if ($stmt === false)
{
throw new Exception('There was a problem preparing this query');
}
$result = $stmt->execute(
array('id' => $postId, )
);
if ($result === false)
{
throw new Exception('There was a problem running this query');
}
// Let's get a row
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row;
}
1
2
 
3
4
5
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
<?php
require_once 'lib/common.php';
// Get the post ID
if (isset($_GET['post_id']))
// Connect to the database, run a query, handle errors
$pdo = getPDO();
$stmt = $pdo->prepare(
'SELECT
title, created_at, body
FROM
post
WHERE
id = :id'
);
if ($stmt === false)
{
throw new Exception('There was a problem preparing this query');
}
$result = $stmt->execute(
array('id' => $postId, )
);
if ($result === false)
{
throw new Exception('There was a problem running this query');
}
// Let's get a row
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// Swap carriage returns for paragraph breaks
$bodyText = htmlEscape($row['body']);
1
2
3
4
5
6
15
16
17
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
20
21
<?php
require_once 'lib/common.php';
require_once 'lib/view-post.php';
// Get the post ID
if (isset($_GET['post_id']))
// Connect to the database, run a query, handle errors
$pdo = getPDO();
$row = getPostRow($pdo, $postId);
// Swap carriage returns for paragraph breaks
$bodyText = htmlEscape($row['body']);

One of the situations we have not yet catered for is the user requesting a blog article that does not exist. We need to handle that based on the maxim that "if something can go wrong, it will". There are a good few small changes here, but in essence our approach is that if we cannot find a database row, we issue a browser redirect and show an error.

Expand/contract code area Select previous tab
Select next tab
16
17
18
 
 
19
20
21
28
29
30
 
 
 
 
 
 
31
32
33
throw new Exception('There was a problem running this query');
}
?>
<!DOCTYPE html>
<html>
<body>
<?php require 'templates/title.php' ?>
<?php while ($row = $stmt->fetch(PDO::FETCH_ASSOC)): ?>
<h2>
<?php echo htmlEscape($row['title']) ?>
16
17
18
19
20
21
22
23
28
29
30
31
32
33
34
35
36
37
38
39
throw new Exception('There was a problem running this query');
}
$notFound = isset($_GET['not-found']);
?>
<!DOCTYPE html>
<html>
<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 ?>
<?php while ($row = $stmt->fetch(PDO::FETCH_ASSOC)): ?>
<h2>
<?php echo htmlEscape($row['title']) ?>
80
81
82
83
84
85
86
87
88
89
list($_SESSION['count'], $_SESSION['error']) = installBlog();
// ... and here we redirect from POST to GET
$host = $_SERVER['HTTP_HOST'];
$script = $_SERVER['REQUEST_URI'];
header('Location: http://' . $host . $script);
exit();
}
// Let's see if we've just installed
80
81
82
83
 
 
 
84
85
86
list($_SESSION['count'], $_SESSION['error']) = installBlog();
// ... and here we redirect from POST to GET
redirectAndExit('install.php');
}
// Let's see if we've just installed
59
60
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
63
64
return $date->format('d M Y, H:i');
}
/**
* Returns the number of comments for the specified post
*
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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
// out the folder (e.g. /blog/ or /).
$relativeUrl = $_SERVER['PHP_SELF'];
$urlFolder = substr($relativeUrl, 0, strrpos($relativeUrl, '/') + 1);
// Redirect to the full URL (http://myhost/blog/script.php)
$host = $_SERVER['HTTP_HOST'];
$fullUrl = 'http://' . $host . $urlFolder . $script;
header('Location: ' . $fullUrl);
exit();
}
/**
* Returns the number of comments for the specified post
*
17
18
19
 
 
 
 
 
 
20
21
22
$pdo = getPDO();
$row = getPostRow($pdo, $postId);
// Swap carriage returns for paragraph breaks
$bodyText = htmlEscape($row['body']);
$paraText = str_replace("\n", "</p><p>", $bodyText);
17
18
19
20
21
22
23
24
25
26
27
28
$pdo = getPDO();
$row = getPostRow($pdo, $postId);
// If the post does not exist, let's deal with that here
if (!$row)
{
redirectAndExit('index.php?not-found=1');
}
// Swap carriage returns for paragraph breaks
$bodyText = htmlEscape($row['body']);
$paraText = str_replace("\n", "</p><p>", $bodyText);

Since the browser redirect is useful, it has been written as a function, and re-used by the installer. Although there is no new data to install, it is a good idea to delete your database file and re-install at this point, just to check it still works.