├── .gitignore ├── README.md └── pages └── original.html /.gitignore: -------------------------------------------------------------------------------- 1 | ; Ignore IDE files 2 | nbproject 3 | 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Tutorial text for "I ♥ PHP" 2 | === 3 | 4 | This repo contains the text for the [I ♥ PHP](http://ilovephp.jondh.me.uk) tutorial. 5 | The tutorial has been designed for beginner and improving-beginner programmers wishing to learn 6 | some good practices in PHP web development. The code example uses PDO/SQLite, and demonstrates 7 | parameterisation, HTML escaping, logic/content separation, authentication, form handling, sessions 8 | and proper password hashing. The repo is public so that experienced developers may propose 9 | improvements if they wish. 10 | 11 | Code changes in the tutorial are shown as diffs in the text, and each modified file can be 12 | downloaded in its entirety at that point of development. To facilitate this, files and diffs are 13 | extracted from this repo by a script, rather than being copied in manually. This makes 14 | code improvements much easier to transpose to the tutorial than the traditional method of 15 | making adjustments by hand. 16 | 17 | Here is [a blog post about the tutorial](http://blog.jondh.me.uk/2014/08/online-php-beginners-tutorial/) 18 | that was written prior to, and at the time of, going live. Since then, some readers have made 19 | suggestions, tickets have been raised, and improvements have been committed and published. 20 | 21 | See also the [code repo here](https://github.com/halfer/php-tutorial-project). 22 | 23 | Page build process 24 | --- 25 | 26 | The pages are held in a single file, using `__NEWFILE__` as a chapter separator. Menu titles are 27 | derived from the first `
11 | In this tutorial, I'll present how to write a simple blog system, using the PHP language and 12 | the Apache web server. For the database, we'll use SQLite, to keep things simple. Don't worry 13 | if these terms don't mean anything yet, that's okay. I'm assuming readers are familiar 14 | with how to use their computer, but perhaps have not programmed before. That said, even a 15 | beginner's tutorial can get non-trivial quickly, so if you are brand new to programming, you 16 | may wish to research unfamiliar keywords (usually on the PHP website). 17 |
18 | 19 |20 | The application we will build together was built prior to writing the tutorial. The text is 21 | therefore written around the way I think development happens in real life; some steps may 22 | introduce bugs, or be written in ways the purist might consider non-ideal. However, for the most 23 | part, these problems will be ironed out as we go. At the end of this project, we'll have a 24 | functional, maintainable and secure application, but there'll be much 25 | that can still be improved on (which will likely make for a subsequent tutorial). 26 |
27 | 28 | 29 |37 | If you are new to programming, it is possible that you might feel somewhat lost as you work 38 | through the material. Whilst any prior practice will always help, the before-and-after code 39 | samples should see you right through to the end, even if you don't initially understand every 40 | step. You should find that some of this will sink in, and will contribute to your future "aha" 41 | moment, even if it is daunting at the start. The elation of getting it working will, I hope, 42 | encourage you to dive even deeper into programming. Enjoy the ride! 43 |
44 | 45 |46 |69 | 70 |47 | A word about PHP tutorials on the web 48 |
49 |50 | To become proficient with a programming language, it makes sense to obtain your learning 51 | materials from as wide a source as possible. PHP owes its success, I think, to its very 52 | low barriers to entry, but that situation has given birth to plenty of tutorials on the 53 | Internet that make two fundamental errors. 54 |
55 |56 | The first is that the
62 |mysql_library is still in use (a library is 57 | just a package of useful functions). This has been a staple of database access 58 | in PHP applications for some ten years, but has now been superceded by newer systems. 59 | In particular, libraries such as PDO and mysqli offer parameterisation, a feature 60 | that helps prevent miscreants and ne'erdowells from cracking system security. 61 |63 | The other problem I see a great deal of are tutorials that interleave business logic 64 | (what an application does) with layout (what an application looks like). 65 | This isn't wrong exactly, but having separation between the two does tend to make 66 | for systems that are more maintainable. 67 |
68 |
71 | I'll assume that your development environment is set up. Here's what you will need: 72 |
73 | 74 |88 | For Windows and Mac users, you can get all of this with 89 | XAMPP. If you use Linux, you may have all of this 90 | already — although XAMPP works on Linux too, and adds a user-friendly control panel. 91 | If you get stuck, turn to your favourite search engine, and 92 | ask "How do I install [whatever] on [Windows]". In 99% of cases, your question has already 93 | been answered, so be persistent. 94 |
95 | 96 | 97 |106 | You'll also need an editor to modify files. Most computers have a free text editor already 107 | installed, but these are rarely good enough. Use whatever you like, but if you have no 108 | preference, download and install NetBeans. 109 |
110 | 111 |114 | In designing this tutorial, I wrote out a list of things I thought would be great to have, and 115 | then developed until I had a basic complete product. In retrospect, it would have been better to 116 | decide this prior to development; regardless of whether you are setting up a business or programming 117 | for fun, having a simple and achievable plan is miles better than an all-singing, 118 | all-dancing one that ends up abandoned half-way through (either through boredom, bankruptcy, or 119 | both). 120 |
121 | 122 |Here's the initial list of features/technologies I put on paper:
123 | 124 |125 |134 | 135 |126 | Login, Logout, Add comment, Create user, Amend user, Delete user, Email validation for new user 127 | accounts, Display post, Accept markdown format, Create post, Edit post, Unpublish post, Delete post, 128 | Unit tests, List posts, AJAX posting/commenting, Database profiler, Internationalisation, 129 | Pretty URLs, Use template engine, Per-environment configuration files, Post pagination, 130 | Comment pagination, Unpublish comment, Delete comment, Foreign key constraints, Unique constraints, 131 | User access levels. 132 |
133 |
136 | To illustrate how much an "initial working product" should trim the initial wishlist, here's 137 | what made it into the first version: 138 |
139 | 140 |141 |146 | 147 |142 | Login, Logout, Add comment, Display post, Create post, Edit post, Delete post, List posts, 143 | Delete comment, Foreign key constraints. 144 |
145 |
148 | It's worth pointing out that, with any software project, rejecting a feature now does 149 | not mean you can never add that feature. It just means that you are prioritising getting 150 | important things done, so that the product is in a releasable state quickly. Once it is out the 151 | door, you can then find a few more items on your list to add into the next iteration. In a 152 | commercial situation, this approach is helpful because if your users don't like a change you've 153 | made, you've wasted two months rather than two years — a saving that could be the difference 154 | between steering things back on track, and going bust. 155 |
156 | 157 |158 | One of the other features of this tutorial is that it uses an absolute minimum of additional 159 | software to get it working. Software projects nearly always make use of libraries, 160 | each of which offer some particular pre-written functionality. These are well worth using in 161 | proper development, since they are better-tested by more people than an individual can usually 162 | achieve on their own. However, for a tutorial I think they can add integration complexity, and 163 | explaining the advanced-level code within is usually quite a distraction. Thus, I have eschewed 164 | libraries more than I would normally, and suspect this will be corrected in a second (more 165 | advanced) tutorial. 166 |
167 |168 | I have made one exception for this rule, however, for reasons of password security. The login 169 | system requires some cryptographic code, and this sort of thing is quite easy to get wrong. Whilst 170 | this is just a tutorial, if it teaches readers to leave security to the experts (the 171 | library-writers), it has served a valuable purpose. 172 |
173 | 174 |
177 | So, let us get started. I'll assume you know how to create, edit, save and delete files in your
178 | editor, and that you can create folders as required. This might be in the root of your "htdocs"
179 | or "www" folder, depending on what you used to install Apache. You can use a subfolder of "blog"
180 | if you wish, or for more advanced users, feel free to set up a new vhost. So, when I use
181 | http://localhost/, you can assume I am referring to the root of your project, even
182 | if your URL is somewhat different.
183 |
186 | Let us first check that your server installation is working okay. Create a file called 187 | info.php in the root of your project folder: 188 |
189 | 190 | 191 | 192 |
193 | Then, run this in your browser, by accessing the URL http://localhost/info.php.
194 | You should see a PHP configuration page. If you do, then check you are running a version later
195 | than 5.4; if it is earlier than this then
196 | you will need to upgrade. If you don't see this screen at all, then check that you have created
197 | the file in the right folder.
198 |
201 | Once you have this working, and showing an acceptable version number, you can delete this file. 202 |
203 | 204 |Here's the first bit of code to add:
205 | 206 |
211 | This is the initial file, so all we have to do is copy and paste it in. To do so, create
212 | index.php in the root of your project folder, open it in your
213 | editor, and ensure it is empty before pasting in the code. Then visit
214 | http://localhost/index.php in your browser, to ensure that you can see the
215 | initial mock-up of our blog's home page.
216 |
219 | If you can, then your first piece of coding is done! The language is HTML, which is understood
220 | by your web browser as a way to describe documents on the web. HTML is not actually a
221 | programming language: it is properly referred to as a markup language. The markup itself
222 | is made up of tags such as <body> and
223 | <h1>, each of which has an opening tag (without the slash)
224 | and a closing tag (with the slash).
225 |
226 | These
227 | are nested to form a hierarchy in the same way as an ordinary letter is hierarchical: instead of
228 | an envelope containing a document containing headings and paragraphs, we have an
229 | <html> containing a <body> containing
230 | <h1> and <p>. Easy peasy!
231 |
234 |253 | 254 |235 | What do the tags mean? 236 |
237 |238 |
250 | 251 |- 240 |
DOCTYPE: this tells the browser to expect a particular dialect of HTML 239 | — in this case HTML5- 242 |
html: this is the "envelope" for the whole document. It acts as a container for 241 | everything- 243 |
head: this contains stuff about the document, such as the title- 244 |
body: unsurprisingly, this contains the main part of the document- 246 |
h1,h2,h3: title headings of decreasing 245 | importance- 247 |
p: a paragraph of text- 248 |
div: a "division", i.e. a section- 249 |
a: a hyperlinkWe'll use a few more as we go on, but those are the main ones.
252 |
255 | Let us start improving our skeleton home page. One of the observations we can make is that there 256 | is a fair bit of repetition: there are two articles that both have a heading, date, summary 257 | and hyperlink. Since this is just test data, let us add a loop around the first article block 258 | and delete the second one. 259 |
260 | 261 |262 | Here is how I am presenting the changes in this tutorial. This will be good practice for you, 263 | since all changes will be shown in this way. The approach of showing code changes like this is 264 | known as a diff, for obvious reasons (though the diffs I use are more colourful than 265 | the usual monochrome versions). 266 |
267 | 268 | 269 | 270 |271 | The rules for making these changes are simple: the code on the left shows the old file, and the 272 | code on the right shows the new one. Also, red lines are deleted, green lines are added. So, 273 | the two red blocks on the left are in the existing file, and can be removed. The green block 274 | on the right should be added. Where you see a solid horizontal bar across the whole diff, 275 | that's just a sign that some identical lines have been missed out for brevity. 276 |
277 |
278 | Thus, if you wish, you can delete both article blocks and replace them with the new loop block
279 | (containing the for() through to the endfor). However, the way this was
280 | actually written was as follows:
281 |
for() loop before the first article, on a new lineendfor) at the end of the first article, on a new
287 | line$postId are inserted into the
291 | article
292 | 295 | In summary, it doesn't matter too much how you apply these changes (or, indeed, any changes in 296 | this tutorial). That said, understanding the way in which changes are written in real 297 | life may help towards a greater understanding of the code. Don't worry though if you think you've 298 | made a mistake; the full file for each change is available above each one. 299 |
300 | 301 |302 |313 | 314 |303 | What are loops? 304 |
305 |306 | The
312 |for()andendforyou have seen form a loop block. This is 307 | a way of telling PHP that you want the enclosed code to be run a number of times. In this 308 | case, it sets a value to 1, and carries on looping whilst that value is less than or equal 309 | to 3. For each iteration of the loop, the value is incremented by 1, which is carried out by 310 | the++operator. 311 |
315 | So, finally, refresh your browser screen, and make sure your changes work. You should now see 316 | three articles, each with a different heading and paragraph. 317 |
318 | 319 |322 | The next step we will take is to set up the database. We'll use a database system called 323 | SQLite (pronounced "see-kwel light") as it is really easy to connect to, and does not 324 | require a database server. Create the following two files in your project, paste in the contents, 325 | and then we'll discuss what they do. 326 |
327 | 328 |329 | You'll notice that some changes in this tutorial — including one of these files, 330 | data/init.sql — has a name that contains a forward slash. 331 | This means that the file is stored in a sub-directory, and that you'll need to create that 332 | manually if it does not already exist. 333 |
334 | 335 | 336 | 337 |338 | This change comprises of two new files, a SQL file and a PHP file. I'll discuss the SQL file 339 | first. 340 |
341 | 342 |343 |353 | 354 |344 | What is SQL? 345 |
346 |347 | SQL is a programming language for databases. It has been mostly standardised between different 348 | database systems, so it looks nearly the same regardless of whether you are connecting to 349 | MySQL or PostgreSQL or (as we are) SQLite. We will use it to set up our database initially, 350 | save information to our blog, and query information from our blog. 351 |
352 |
355 | Broadly, databases are stored in terms of tables, columns and rows. Each
356 | different kind of data we wish to store needs its own table, but to start with, we only have one:
357 | a post. The CREATE TABLE statement specifies which properties we want a post to have,
358 | namely:
359 |
371 | The column id has two particular features:
372 |
PRIMARY KEY in order to mark it as the predominant unique
376 | identifier for the row. This makes it suitable to refer to the row from another
377 | table (which we will do later on)AUTOINCREMENT option, so unique values are generated for us
379 | by the database server automatically
383 | A blog post is therefore just a row added to the posts table, since each row has space to store
384 | all of these values. Once the table is created, we also create some dummy article data, using
385 | INSERT. The format of this command, as used in the SQL script, is simply thus:
386 |
389 |
390 | INSERT INTO table_name (column_1, column_2, ...) VALUES (value_1, value_2, ...);
391 |
392 |
395 | Now, the SQL file won't do anything on its own: we need a program to send the commands to 396 | the database. This is where the install.php file comes in; we'll 397 | run that whenever we want to set up the blog (or to wipe it and start again). I'll describe 398 | its broad features here, but do also read the comments in the code. 399 |
400 | 401 | 402 |
409 | The first half of the code is written in PHP. Here, we use a series of if()
410 | statements to ensure everything goes smoothly. The variable $error is set to an
411 | error message if something goes wrong. Here are the steps we take:
412 |
touch()
421 | and report if there was any problems doing that (such as being disallowed by file system
422 | permissions).
423 | file_get_contents() and report an error
426 | if the file cannot be found (this is unlikely, but it is good practice to check
427 | everything that can go wrong!).
428 | $pdo->exec() and report any issues
431 | with that.
432 |
439 | The second half of the file (from <!DOCTYPE html>) presents the results of
440 | the script, in HTML.
441 |
So, if you have not already tried it, visit http://localhost/install.php in your
443 | browser, and make sure some posts are created. If you want to re-run it, delete the data.sqlite
444 | file manually and refresh your browser page.
445 |
452 | Since we now have some data in our database, we can change our home page to render that data, rather 453 | than the mock data it has now. It's worth noting though that the process of designing the page 454 | with dummy data hasn't been a waste of time; using a mock-up is often a valuable part of deciding what 455 | a page should contain, and where those elements should go. So, make these changes next: 456 |
457 | 458 | 459 | 460 |
461 | The first few lines determine the file path to the database, and then we create a new PDO object
462 | with new PDO(), which we can use to access the data. We then use the
463 | query() method to run a SQL statement that reads articles from the post
464 | table.
465 |
468 |492 | 493 |469 | What is a SELECT statement? 470 |
471 |472 | The
475 | 476 |SELECTcommand is used to read from the database. For a single table, it takes a 473 | format like this: 474 |484 | 485 |SELECT 477 | (column names in a list) 478 | FROM 479 | (table) 480 | WHERE 481 | (condition) 482 | ORDER BY 483 | (column names to sort on)486 | The
489 | 490 |WHEREis optional, and is useful if we want to filter on some particular 487 | feature of each row. 488 |The statement is often written on a single line, but it can be split up as here, for readability.
491 |
494 | So, refresh your browser's view of http://localhost/index.php, and ensure that your
495 | new changes render the posts from the database without errors. I'll then explain what is happening.
496 |
499 | After connecting to the database, the query is used to retrieve column values for each table row,
500 | returning ordering the rows in created_at DESC (i.e. in reverse creation order, or most
501 | recent first). It then enters a loop (in this case a while()) to render all the posts it
502 | finds.
503 |
506 | We use $stmt->fetch() to read the next available row, until the point when there are no
507 | rows left. When that happens, this method will return false and the loop will exit.
508 |
511 | For every row, there are two observations worth making. Firstly, rows are returned in an array, so we
512 | access them using the array syntax such as $row['title']. Secondly, you'll see that
513 | where text strings are output, they are wrapped in the htmlspecialchars() function. The
514 | reason for this is that, if user input (a blog title or blog post in this case) contains angle brackets,
515 | it could break the HTML used in the page layout, and worse, might let a user inject unauthorised
516 | JavaScript that would be run on other people's computers.
517 |
520 | You'll notice that most of the PHP code to deal with the database side of things is in the top half 521 | of the file, and the second half is predominantly HTML. This isn't an accident: this arrangement creates 522 | a healthy separation between the two, which makes each easier to work with. Of course, there are a few 523 | cases where dynamic output is required inside the HTML, but these are kept deliberately short. 524 |
525 | 526 |529 | The next step is to create a page to show individual posts. Add the following file: 530 |
531 | 532 | 533 | 534 |
535 | Now, it is our intention to link this page from individual posts on the home page. However, we've
536 | not quite done that bit yet, so let's visit the new page manually, in order to test it. Open a
537 | new tab, paste the link http://localhost/view-post.php into the address
538 | bar, and check that a simple post appears.
539 |
542 |
543 | The connection to the database is the same as before, but we now have a WHERE clause
544 | in our SELECT statement, and we are now using prepare() and
545 | execute() to send the statement to the database driver.
546 |
549 | Let's take the WHERE statement first. In the home page, we ran a query without
550 | this, because we wanted everything in the table. However it is more often the case that we
551 | want to limit returned rows to those matching one or more conditions. This is where the
552 | WHERE comes in. For our new page, we only want rows that have a specific id (and
553 | since "id" is unique, we know we won't get more than one).
554 |
557 | You'll notice that "id" is fixed to 1 (i.e. the first post). We'll fix that later, since of 558 | course this should depend on the post the user clicks on. 559 |
560 | 561 |
562 | The other change is swapping out the call to query() with two new methods.
563 | prepare() is used to set up the statement, and indicates with a colon where values
564 | should go (i.e. ":id"). The execute() statement then runs the query, swapping
565 | place-holders with real values (in this case, the number 1). This technique is known as
566 | parameterisation, and is a good way to inject user-supplied input in a secure manner
567 | (the post ID is not yet user-supplied, but it soon will be).
568 |
571 | In fact, let's wire in the post page now. Make the following changes, and test that clicking 572 | on each 'Read more' link shows the appropriate post. 573 |
574 | 575 | 576 | 577 |
578 | Alright, so let's make some nice easy changes now. A look at our
579 | index.php and view-post.php
580 | files shows that our blog title and synopsis is repeated in both files. That's not good, since
581 | if these things need to be amended, we need to change them more than once. Happily, the
582 | solution is easy: we create a PHP file for the duplicated snippet, and then use
583 | require to pull it in.
584 |
589 | Once that is done, we can see there is still some duplication, this time with the database 590 | PHP code. So, let's fix that too! You'll notice here that we're using a subfolder for 591 | lib: 592 | this is a very common name for library code folders. 593 |
594 | 595 | 596 | 597 |598 | The process of spotting code improvements (e.g. to reduce repetition) is known as 599 | refactoring, and it can be thought of as "cleaning as you go". Projects that are 600 | subject to these improvements are, at least in theory, more maintainable than ones that only 601 | get feature changes. 602 |
603 | 604 |605 |625 | 626 | __NEWFILE__ 627 | 628 |606 | What is a function? 607 |
608 |609 | We saw in the common.php library a set of code blocks that 610 | start with the word 611 |
616 | 617 |function. These are named blocks of code that can be called from different 612 | places, and are an essential item in your toolkit. They help reduce duplication (since 613 | you don't need to write code again) and they help improve modularity (since functions 614 | can be stored in separate files). 615 |618 | The functions we've seen here all provide a return value, which is the result of 619 | the function, and it can be of any type: a number, a string, whatever. This can either be 620 | printed out to the screen, or more frequently, stored in a variable at the place it is 621 | called. For example, the function
624 |getRootPath()gets the full directory 622 | name of your web project on disk. 623 |
631 | It is normal practice to link the main heading of a website to the home page, as this makes 632 | for a consistent navigation experience. So, let's do that now: 633 |
634 | 635 | 636 | 637 |638 | Next, let us add in some logic that interprets two carriage-returns in a post as a paragraph 639 | break. Here we go: 640 |
641 | 642 | 643 | 644 |645 | Now, the default date style used around the application isn't very readable, so let's 646 | improve that now: 647 |
648 | 649 | 650 | 651 |652 | Now we've done a bit of tidying, let's tackle a bigger item of functionality. We need 653 | to allow users to comment on articles, so let's make the necessary database changes to 654 | prepare for that: 655 |
656 | 657 | 658 | 659 |
660 | Since this involves database changes, we'll need to delete our database file, and re-run
661 | the installer. So, delete the file in data/data.sqlite and then
662 | reinstall by visiting http://localhost/install.php again.
663 |
666 | You might ask why we are putting so much effort into an installer when we're nowhere 667 | near having a piece of finished software. The answer is that this installer is for us, 668 | the developers, and not for end users. Thus, it is something we want to set up reasonably 669 | early in our development lifecycle; whenever a database change is made, we want to be able 670 | to recreate our useful test data quickly and easily. 671 |
672 | 673 |
674 | Now we're ready to start adding some comment-related changes. To start with, let's add
675 | a count of comments on the front page. This uses the SQL command COUNT() to count
676 | the number of rows that would be returned by a query:
677 |
682 | Also, we'll add a comment listing to individual post pages: 683 |
684 | 685 | 686 | 687 |688 | So, give that a whirl. You will want to check the new count on the homepage, and the comments 689 | feature on individual posts. How's that looking? 690 |
691 | 692 | __NEWFILE__ 693 | 694 |704 | At this juncture, I decided to tidy up the installer a bit more. Firstly, it would be nice to 705 | see some data about what it has created; in the course of development you can expect 706 | to wipe and recreate your test data hundreds of times, so it's worth making the output 707 | useful. 708 |
709 | 710 |711 | Secondly, as it stands it demonstrates non-optimal techniques, and fixing that gives me an 712 | opportunity to explain how to improve upon it. Broadly, the problem is that visiting the URL 713 | changes the database, but it does not take into account that web addresses can receive visits 714 | from automated software (e.g. search engines looking for new websites). To be sure that it 715 | is a human who has requested an install, I've used a form with a "post" method (this is 716 | explained in more detail later). 717 |
718 | 719 |720 | The changes here are quite substantial, so you may find it easier to download the file, and 721 | copy the whole thing over the top of the old version. So, here's the diff: 722 |
723 | 724 | 725 | 726 |727 | Finally, let's add some links so we can easily move to our next task after re-installing: 728 |
729 | 730 | 731 | 732 |733 | As is our custom, delete your database file and re-run the installer. This time you should 734 | have an "Install" button. All being well, it will do the same installation when you click it, 735 | and then you can opt to go straight to the blog's home page. 736 |
737 | 738 |741 | Since our changes to the installer have been brief, let's do some refactoring and a couple of 742 | minor functionality tweaks. Now, it could be said that this process of tidying seems rather 743 | erratic, and that similar items of work should be neatly collected together. However, real-life 744 | development rarely works that way: instead, a rough but usable solution is developed, and then 745 | improvements and refactorings are added depending on user feedback, and often also on time 746 | available. 747 |
748 | 749 |750 | So, here's our first small improvement task. One of the things you may have noticed is that 751 | posts and comments are being marked as written on a particular day, but with no time 752 | information. It is usual to record and show this sort of data, so let's do that now. Don't 753 | forget to re-run the installer to test it! 754 |
755 | 756 | 757 | 758 |759 | Following on from our last bit of refactoring, here's another opportunity to tidy code. Arguably, 760 | the view post page has too much business logic in it, and it would be more maintainable 761 | to move this to a separate file. So, create lib/view-post.php 762 | and paste in the new content. 763 |
764 | 765 | 766 | 767 |768 | One of the situations we have not yet catered for is the user requesting a blog article 769 | that does not exist. We need to handle that based on the maxim that "if something can go 770 | wrong, it will". There are a good few small changes here, but in essence our approach is 771 | that if we cannot find a database row, we issue a browser redirect and show an error. 772 |
773 | 774 | 775 |784 | Since the browser redirect is useful, it has been written as a function, and re-used by 785 | the installer. Although there is no new data to install, it is a good idea to delete your 786 | database file and re-install at this point, just to check it still works. 787 |
788 | 789 | __NEWFILE__ 790 | 791 |794 | Righto, let's get onto a new solid block of functionality: allowing users to add comments. 795 | This adds a new block of HTML in comment-form.php, which 796 | reports any errors when the comment is made, a form to capture the usual comment information, 797 | and some business logic in lib/view-post.php and 798 | view-post.php to glue it all together. 799 |
800 | 801 | 802 | 803 |804 | Let us have a look in detail at view-post.php, which is the page 805 | called by the web server when a single post is rendered. In the newly inserted code, we take 806 | these actions: 807 |
808 | 809 |addCommentToPost() for validation and saving.$errors array.
870 | Adding a comment doesn't work yet, as the INSERT command is missing a value for
871 | the date of comment creation. Let's fix that now:
872 |
877 | So, that gets the success case working. However, if you test a failure condition (empty name 878 | field or an empty comment, the fields that were filled in now disappear. This is because 879 | a form does not by default contain values, so we have to add them manually. 880 |
881 | 882 |
883 | Thus, we now set empty values for the GET case (no form submission) in
884 | view-post.php as well as
885 | the already existing POST case. Where we output the user-supplied data, we pass it through
886 | the PHP function htmlspecialchars(), which prevents any rendering problems if the
887 | user has used any HTML characters such as angle brackets.
888 |
907 | And now for some more tidying. The first tweaking opportunity I noticed was that the code to 908 | make a comment safe to render to the screen, and to swap newlines for paragraph tags, might be 909 | useful elsewhere in the future. So I've generalised that snippet of code in a function, and 910 | then made use of it: 911 |
912 | 913 | 914 | 915 |916 | A nice simple one is up next. The code for install.php was 917 | rather bloated by the presence of the 918 | large function at the start, and there's no reason why this couldn't be stored separately, 919 | making the installer page easier to maintain. We refactored this one a while ago, but it's 920 | perfectly fine to refactor again — the process can be regarded as fairly iterative 921 | anyway. So here's the diff, resulting in new file 922 | lib/install.php: 923 |
924 | 925 | 926 | 927 |928 | When a comment is created, we use a snippet of code to create a timestamp in a format suitable 929 | for the database server. Since that'll be useful for other things, let's convert that to a 930 | reusable function: 931 |
932 | 933 | 934 | 935 |936 | I noticed that the installer creates its own database connection. Although there was no 937 | pressing need to do so, I modified it so it uses a connection passed to it. This would make it 938 | easier to run automated tests against it, for example — a custom test connection would be 939 | passed to it, rather than the "hard-wired" one we have now removed. 940 |
941 | 942 | 943 | 944 |945 | The last tweak is very easy: a re-reading of some of the code showed that I'd not updated 946 | a comment in line with a code change. Now, I could magic this away for the benefit of the 947 | tutorial, but I rather like the opportunity to show that code is never perfect, and that 948 | sometimes comments come out of sync with what they're meant to describe! So, just apply the 949 | following diff, and we're done for this chapter. 950 |
951 | 952 | 953 | 954 | __NEWFILE__ 955 | 956 |959 | The next thing I decided to implement was a log-on system. This will allow a blog administrator 960 | to write, edit and delete posts, and to delete comments. In this set of changes, I add a 961 | users table, and allow the installer to create an admin user with a new password each time it 962 | is run. So, make the following changes, and then re-create the database: 963 |
964 | 965 | 966 | 967 |
968 | There are two fields in the new user table that I added based on my experience rather than an
969 | immediate need. These are created_at, which holds the date and time when the user
970 | was first set up, and is_enabled, which allows us to turn users on and off. Most
971 | user systems will find a practical use for these simple features during their lifetime.
972 |
975 | You'll have noticed a comment in lib/install.php noting that 976 | the password is stored in plaintext. 977 | This means that, as it stands, passwords would be stored literally, which is considered to be 978 | very bad practice indeed. What we should do is to store passwords in an encoded, non-reversible 979 | format, so that even if they are stolen they will be nearly impossible to read. This acts as a 980 | form of protection should a cracker get through our security and steal our database. 981 |
982 | 983 | 984 |
992 | So, let's make that improvement straight away. To do so, we'll need the library I talked about
993 | at the start of the tutorial, stored here in vendor/password_compat.
994 | The features it provides are so useful they have been built into PHP 5.5, so if you are running
995 | this version (or later) you can skip adding the new file and the associated
996 | require_once. However if you are running an earlier version, or if you are not
997 | sure which version you have, add all of these changes.
998 |
1003 | So, what does the new code do? It makes use of a new function called
1004 | password_hash(), which takes a password as input and produces what is known as a
1005 | hash. A hash is a mathematical calculation that is strictly one way, which means that
1006 | if sometimes steals our database of password hashes, they will find it very difficult indeed
1007 | to recreate the passwords they were generated from.
1008 |
1011 | Whether or not you are using it, by all means do have a read of the source code in 1012 | password.php. However, an in-depth exploration of it would be rather 1013 | advanced at this stage, so for our purposes we will assume it just works. 1014 |
1015 | 1016 |1017 | The next step is to add a login form, and a link from which to access it: 1018 |
1019 | 1020 | 1021 | 1022 |1023 | In the next change, we add our familiar block of business logic before the main HTML. In this 1024 | case, it checks to see if the form has been submitted; if it has, then it turns on the 1025 | session system (more about that in a minute), creates a hash of the submitted password, 1026 | and compares it with the hash stored in the database. 1027 |
1028 | 1029 |1030 | If the user gets their username wrong (e.g. it is not found) or the password wrong (the 1031 | password hash does not match the one for the username supplied) then we regard this as a login 1032 | failure. It is important for us not to be too helpful here (such as explaining that a username 1033 | does not exist), as this information might be useful to a system cracker. 1034 |
1035 | 1036 |
1037 | If the password matches however, then we call login() to sign in the user, and
1038 | then we redirect to the home page using redirectAndExit().
1039 |
1042 |1065 | 1066 |1043 | What are sessions? 1044 |
1045 |1046 | Sessions are an extremely useful feature that allow web applications to 1047 | remember per-user information. By default, every request to the server is seen in 1048 | isolation, so without sessions, an application would not be able to 1049 | remember that a user had signed in, or what their username was. 1050 |
1051 | 1052 |1053 | To make sessions work, PHP sends the user's browser a cookie containing a random 1054 | identifier, and for every subsequent visit, the browser supplies this back to the server. 1055 | This identifier corresponds to a file on the server containing the variables that have been 1056 | set for each user. 1057 |
1058 | 1059 |1060 | PHP makes this nice and simple for developers: we just turn on sessions using 1061 |
1064 |session_start(), and then we can just read and write to the 1062 |$_SESSIONarray. Easy peasy! 1063 |
1067 | So, with that explained, let's add the changes: 1068 |
1069 | 1070 | 1071 | 1072 |1073 | Now we have a way to determine whether users are logged in or not, let's switch that feature 1074 | on for all pages. Here we also modify the HTML snippet that contains the page header, so 1075 | it can show the appropriate login/logout link. 1076 |
1077 | 1078 | 1079 | 1080 |1081 | You may have noticed, if you tried the logout link, that this page does not yet exist. So let's 1082 | add that now in the following set of changes. Whilst we are at it, let's greet the user by 1083 | their username while they are logged in, as this makes the experience of the site a bit 1084 | more friendly. 1085 |
1086 | 1087 | 1088 | 1089 |1090 | That's all for this chapter, so give your application a good test, especially the new 1091 | login and logout features. When you're ready, we'll proceed in the next chapter with some 1092 | small tweaks to improve what we have. 1093 |
1094 | 1095 | __NEWFILE__ 1096 | 1097 |1106 | We've got a nice set of features working, but there are a good number of improvements and 1107 | refactoring changes that can be made. Doing these periodically can make for easier and 1108 | happier development, which in turn can improve your development efficiency. 1109 |
1110 | 1111 |1112 | The first change is two-fold: pages were replicating some information in the header, and 1113 | various items (the top menu bar and system messages) were using inline style rules rather than 1114 | using class rules that need only be written once. 1115 |
1116 | 1117 | 1118 | 1119 |1120 | Let's take a better look at the second change. Some of the original code was written like 1121 | this: 1122 |
1123 | 1124 |
1125 | <div style="border: 1px solid #ff6666; padding: 6px;"> … </div>
1126 |
1127 |
1128 |
1129 | The purpose of the style attribute to specify CSS rules (otherwise known as
1130 | style rules) to the HTML within. Firstly, we have the border rule, which says
1131 | the content (an error message) should have a red border rendered in an unbroken line, and
1132 | the padding means that there should be six pixels of gap between the border and
1133 | an invisible box around the content.
1134 |
1137 | However, having to write that for every error message takes a bit of effort, and it's a 1138 | pain if we decide that error messages should have a magenta border and not a red one. Thus, 1139 | it makes life easier if we write this instead: 1140 |
1141 | 1142 |
1143 | <div class="error box"> … </div>
1144 |
1145 |
1146 |
1147 | That applies two rules to the block: one called error and another called
1148 | box. That's much easier to remember, easier to read, and — since we
1149 | centralise the definitions in assets/main.css — easier
1150 | to change if we have to.
1151 |
1154 | While we are dealing with CSS changes, let's add a few more. Here we add some rules for 1155 | article synopses, titles and dates on the home page, and comments on individual blog posts. 1156 |
1157 | 1158 | 1159 | 1160 |1161 | You may have noticed that some function calls that need to access the database create their 1162 | own connection rather than use one that we've already created. For low-volume systems this 1163 | might not matter a great deal, but programmers hate this sort of inefficiency, and where it is 1164 | trivial to fix, we should. 1165 |
1166 | 1167 | 1168 | 1169 |1170 | Now we improve the appearance of the comment form. It's worth opening up an article page 1171 | prior to making the improvements, so you can refresh it after the CSS is in place. This will 1172 | allow you to see the change quickly. 1173 |
1174 | 1175 | 1176 | 1177 |
1178 | We now turn our attention to an improvement to the database. This consists of the tables
1179 | post and user, for blog posts and authors respectively. When
1180 | creating a post record, we insert our automatically generated user.id in
1181 | post.user_id to store which user has authored it (of course, we only have one
1182 | user in our test data, but in practice we might have several).
1183 |
1186 | However, it is possible to insert any number in post.user_id, so if we have
1187 | an undiscovered bug in our code, it might cause a non-existent user primary key to be stored
1188 | here. Since we rely on values here always pointing to a user row, if a bad value were to get
1189 | in, it might crash our application.
1190 |
1193 | To protect against that scenario, many database systems will allow the use of foreign key
1194 | constraints. These allow us to specify that values inserted into a particular column must
1195 | exist in another column belonging to another table, and that an error must occur if this
1196 | condition is not met. SQLite offers this feature, although it is unusual in that it needs to
1197 | be turned on explicitly, using the PRAGMA command.
1198 |
1201 | So, let's make these changes: 1202 |
1203 | 1204 | 1205 |user table does not result
1207 | in a constraint violation, since we refuse to run the SQL unless the database file is
1208 | manually deleted.
1209 | 1215 | Here's what's new: 1216 |
1217 | 1218 |INSERT that in the script with a dummy hash value, and
1225 | UPDATE it afterwards with the real hash generated in PHP1232 | As usual: delete your database file, re-run the installer, and check all the changes appear 1233 | to work. 1234 |
1235 | 1236 |1237 | The last change is related to security. Since all of our files are in the web server's 1238 | public directory, a user who knows our directory structure would be able to download files 1239 | that we didn't intend to make accessible. Since SQLite uses a single file to store its data, 1240 | and since this file is often stored in a web-accessible location, it is of particular 1241 | importance to lock this down. 1242 |
1243 | 1244 | 1245 | 1246 | __NEWFILE__ 1247 | 1248 |
1251 | Let's turn our attention to another major feature: writing a new article. We start off with
1252 | setting up a new page, laying out the necessary form input controls, and adding a new logged-in
1253 | menu item. There's also a change here to add a generic class user-form, so our
1254 | forms across the whole application can easily acquire a common look-and-feel; see how I've
1255 | gone back to existing form comment-form.php to update that too.
1256 |
1261 | Now, we only want to show this page for users who are logged in. Thus, if a user who is 1262 | not logged in tries to access it (by typing the URL in directly) we must redirect them 1263 | elsewhere; in this case, the home page will do fine. 1264 |
1265 | 1266 | 1267 | 1268 |
1269 | While we are here, let's take a look at how redirectAndExit() works; this is in
1270 | common.php. It starts by reading the domain we are running
1271 | on (such as localhost), since it is good not to hardwire this into our code.
1272 | It then sends a Location HTTP header to the browser, which will cause it to
1273 | request the specified address. Since the PHP script would happily carry on running at this
1274 | point (until it has detected that the browser has disconnected) we also need to forcibly
1275 | exit and wait for the redirect.
1276 |
1319 | Although we now have the structure of the new post feature, it does not work yet. So let us 1320 | fix that now: 1321 |
1322 | 1323 | 1324 | 1325 |
1326 | In a similar way to saving comments, we first test if we are in a post operation, using
1327 | if ($_POST). This contains an array of input values that will be only be present
1328 | if a user has submitted the form.
1329 |
1332 | We then make some simple checks to ensure the form data is acceptable prior to our attempting 1333 | to insert it into the database. This is a process known as form validation and is 1334 | a frequent task within web application development. If any checks fail, we allow the page to be 1335 | rendered in the POST request itself, plus error messages as appropriate, and it is only if the 1336 | checks succeed that we try to save the data and redirect back to the newly committed article. 1337 |
1338 | 1339 |1340 | However, as it stands this redirect will just display a new, empty post, since we have not 1341 | added the logic to render the edit facility for a specific row. Let's add that now: 1342 |
1343 | 1344 | 1345 | 1346 |
1347 | That was nice and easy: if we find we are not in a post operation and we have a
1348 | post primary key and that row exists, then show it in the edit form. You can see that
1349 | now we need the database connection (in $pdo) for two things, I've moved
1350 | that line so that it is always executed.
1351 |
1361 | As we have done before, this code uses htmlspecialchars() to prevent users
1362 | from entering HTML, which could break our page layout or introduce security problems. It is
1363 | perhaps less of a worry here, since at least these users are authenticated, and hence
1364 | they might be considered more trustworthy than anonymous commenters.
1365 |
1368 | If you tried editing a post, you'll have found that this created a new post, rather than 1369 | updating the old one. So let's fix that also: 1370 |
1371 | 1372 | 1373 | 1374 |
1375 | I've moved the code to read the current post towards the start of the page, since this is
1376 | now useful in two situations. The first is when displaying an article for
1377 | editing, and the second is when submitting the edit form to save any changes.
1378 | In both cases, $_GET['post_id'] will be available, and we can look up that row
1379 | from the post table, and obtain title/body data if it is read successfully.
1380 |
1383 | Within a POST operation, we can then check $postId, and if it has a value we
1384 | know we are editing an existing article rather than creating a new one. Thus, if we are editing,
1385 | we call the new function editPost(), which will run the necessary
1386 | UPDATE command against the database, rather than addPost(), which
1387 | would run an INSERT.
1388 |
1391 | You might have noticed that there has been no attempt to check whether the user editing a 1392 | post is the same as the user who wrote it. Whether one person may edit other person's posts is 1393 | a feature decision, but for the time being it is one that I have deliberately omitted, to 1394 | keep things simple. 1395 |
1396 | 1397 | __NEWFILE__ 1398 | 1399 |1402 | In this chapter, we will build on our new editing functionality, but first, let's simplify 1403 | the title HTML block by moving the menu into a separate file: 1404 |
1405 | 1406 | 1407 | 1408 |1409 | That change allows us to wire in the menu fragment separately, without the 1410 | title, so we can use a custom title in the edit page. You'll see that we set the title to 1411 | "New post" or "Edit post" depending on whether we can find a primary key in the query string. 1412 |
1413 | 1414 | 1415 | 1416 |1417 | Let's also add in a cancel link in the edit page, to make it easier to abandon an edit: 1418 |
1419 | 1420 | 1421 | 1422 |1423 | On the home page, and for logged-in users only, I next added an edit link against each post, 1424 | like so: 1425 |
1426 | 1427 | 1428 | 1429 |
1430 | You may have noticed that the class name (read-more) of the wrapping div was no
1431 | longer accurate, which is why I changed this to something more generic. As it happens, this
1432 | section doesn't have any CSS rules specifically attached to it, but it's good to have something
1433 | for future styles to hook onto).
1434 |
1437 | Lastly, let's make a another nice easy change - there's no point in rendering the login 1438 | page if we are already logged in. So, after the session is started, if we find we are logged 1439 | in already, redirect back to the home page. 1440 |
1441 | 1442 | 1443 | 1444 | __NEWFILE__ 1445 | 1446 |1449 | The next step is to add an All Posts screen for administrative users. We'll start with 1450 | a static mock-up with hard-wired post values: 1451 |
1452 | 1453 | 1454 | 1455 |1456 | Of course, since this is a restricted screen, we must only allow authorised users to see it. 1457 | To do so, we redirect back to the home page, and exit as usual: 1458 |
1459 | 1460 | 1461 | 1462 | 1463 |1464 | Great stuff. Don't forget to test it by logging out, and then trying to visit the page 1465 | manually! 1466 |
1467 | 1468 | 1469 |1476 |1536 | 1537 |1477 | What do square brackets in form elements do? 1478 |
1479 |1480 | Aha! Glad you asked. First, let's consider this simple form element: 1481 |
1482 | 1483 |1484 |1486 | 1487 |1485 |<input type="submit" name="save" value="Save" />1488 | This is pretty simple. If the button is pressed, the form is submitted, and the submitted 1489 | key
1491 | 1492 |savegets the value of the button, "Save". 1490 |1493 | However, consider the scenario where we need several submit buttons carrying out a similar 1494 | purpose, e.g. a delete button per database row. One solution is to add in the primary key 1495 | as part of the name: 1496 |
1497 | 1498 |1499 |1503 | 1504 |1502 |<input type="submit" name="delete_1" value="Delete" /> 1500 | <input type="submit" name="delete_2" value="Delete" /> 1501 | <input type="submit" name="delete_3" value="Delete" />1505 | When this form gets submitted, we can search for keys matching this naming specific pattern, 1506 | and delete the matching row as appropriate. However, this is rather fiddly, so PHP 1507 | provides a cleaner solution - the square bracket syntax: 1508 |
1509 | 1510 |1511 |1515 | 1516 |1514 |<input type="submit" name="delete[1]" value="Delete" /> 1512 | <input type="submit" name="delete[2]" value="Delete" /> 1513 | <input type="submit" name="delete[3]" value="Delete" />1517 | This helps up cheat by doing some of our parsing for us. Here's what happens in that form 1518 | if we press the second Delete button: 1519 |
1520 | 1521 |1522 |1530 | 1531 |1529 |Array 1523 | ( 1524 | [delete] => Array 1525 | ( 1526 | [2] => Delete 1527 | ) 1528 | )1532 | This makes things nice and easy: we just look up the name of the element 1533 | (i.e.
1535 |delete), and then grab the first key value. 1534 |
1538 | Now, the logic we use to read the posts for the front page will be useful also for this 1539 | new screen. Since we don't want to write this twice, let's move it to a function so we can 1540 | reuse it later on. 1541 |
1542 | 1543 | 1544 | 1545 |
1546 | While working on the home page, I noticed I'd used the variable name $row. This
1547 | is rather generic — most things read from a database are rows — so I swapped to
1548 | a better name for it. This is more readable, and fits in with the common naming convention
1549 | of using a plural name for an array and the corresponding singular name for each item.
1550 |
1555 | Next, we'll modify the mock-up by adding in a creation time for posts: 1556 |
1557 | 1558 | 1559 | 1560 |1561 | As with our mock-up approach before, once a layout contains everything required, it is time to 1562 | convert to a working version. So, let's do that now: we'll add a loop and render table data in 1563 | the HTML. 1564 |
1565 | 1566 | 1567 | 1568 |1569 | As it stands, the user may click on a delete button to remove a post, but this is not 1570 | presently handled. So, let's do that now: 1571 |
1572 | 1573 | 1574 | 1575 |1576 | Finally let's add in a post count on this page: 1577 |
1578 | 1579 | 1580 | 1581 | __NEWFILE__ 1582 | 1583 |1586 | In this section, we'll allow authorised users to delete comments from posts. To kick us 1587 | off, here's a nice item of refactoring — the comments list within article pages is quite 1588 | large, and it would be more modular to store it in a separate file. 1589 |
1590 | 1591 | 1592 | 1593 |1594 | Let us now add a delete button next to comments on blog posts, for authorised users only. I've 1595 | added a bit of extra CSS to get it to float to the right-hand side. 1596 |
1597 | 1598 | 1599 | 1600 |1601 | Since we want to do more than one thing with comments (add and delete), we need to 1602 | make a change to explain what we are doing within the form submission. To do this, I've used 1603 | the query string, which sends these items of information: 1604 |
1605 | 1606 |post_id) upon which a comment is being added or
1608 | deletedadd-comment and delete-comment), which
1610 | can be read by our code to determine what the user wishes to do1623 | Let us now make some room in our view post page, by moving some logic to a new function. 1624 |
1625 | 1626 | 1627 | 1628 |
1629 | We now need to read the action key we set up, so we can decide what feature to call.
1630 | If the user is deleting a comment, the new function deleteComment in the next set
1631 | of changes is called; although we could just delete by comment.id, to be sure of
1632 | deleting the right thing we filter additionally by comment.post_id.
1633 |
1636 | Note that we also add an if() clause to ensure that the delete operation is skipped if
1637 | the user is not logged in. Although we do not render delete buttons for anonymous users, it is
1638 | still possible for security crackers to fake a form submission that contains a valid deletion
1639 | request — this clause prevents that.
1640 |
1645 | Finally, I noticed that the logic to add a comment is contained within a function, but the
1646 | logic to delete one is still in the switch() block in
1647 | view-post.php. The following change just tidies that up:
1648 |
1653 | As ever, test everything to check it works, before we proceed to the final chapter! 1654 |
1655 | 1656 | __NEWFILE__ 1657 | 1658 |1661 | Excellent, we're now on the home straight! Our functionality is finished, so we'll just 1662 | do a last round of sprucing up. To start, let's add in a home link for authorised users. This 1663 | is useful on the restricted pages, since they don't have a home link themselves: 1664 |
1665 | 1666 | 1667 | 1668 |1669 | Next, I thought it would be nice to have a comment count for each article on the All Posts page. 1670 | Add the following changes: 1671 |
1672 | 1673 | 1674 | 1675 |1676 | Let's take a closer look at how the new query works. The query now looks like this: 1677 |
1678 | 1679 |1680 |1688 | 1689 |1687 |SELECT 1681 | id, title, created_at, body, 1682 | (SELECT COUNT(*) FROM comment WHERE comment.post_id = post.id) comment_count 1683 | FROM 1684 | post 1685 | ORDER BY 1686 | created_at DESC
1690 | The new part of this is the bracketed expression on the third line. This
1691 | sort of SQL is known as a sub-query since it is a query contained inside another
1692 | one. The result of it can be read like a real column, which we explicitly name by adding
1693 | comment_count at the end of it.
1694 |
1697 | This sub-query will count rows in the comment table, based on which post they
1698 | belong to. Since the job of the outer query is to list posts, we can filter the comment count
1699 | for each row by making a comparison to the outer table, post.
1700 |
1703 | The next change is another easy one: we'll add a header row to the All Posts table: 1704 |
1705 | 1706 | 1707 | 1708 |1709 | While we are on the All Posts page, it would make sense to add in a link to the view page 1710 | for each article. So let's do that now: 1711 |
1712 | 1713 | 1714 | 1715 |1716 | Next up is a refactoring that simplifies the code (quite a bit is removed) and reduces the 1717 | number of calls made to the database. The essence of the modification is in 1718 | lib/view-post.php — we merge two SQL calls together via 1719 | another sub-query. 1720 |
1721 | 1722 |1723 | There's quite a few files to modify here, so make sure you get them all. 1724 |
1725 | 1726 | 1727 | 1728 |1729 | I noticed when doing some testing at this point that posts which have comments cannot be 1730 | deleted - try that now. You'll get an error known as a foreign key constraint violation; 1731 | this is what happens when a row is deleted that a foreign key depends on (in our case we 1732 | are trying to delete a post to which comments are still attached). To fix this, comments 1733 | should be deleted first, prior to the post being deleted: 1734 |
1735 | 1736 | 1737 | 1738 |
1739 | Whilst we are looking at the database side of things, I noticed that the
1740 | user.is_enabled field hasn't yet been used. This was intended mainly for future
1741 | enhancements (in particular for a user administration page), but the application can make an
1742 | initial use of it immediately, by disallowing authenticated features to non-enabled users.
1743 |
1746 | The installer sets the test user to is_enabled=1 already, so all we need
1747 | to do is to adjust the SQL statements that fetch data from the user table.
1748 |
1753 | Our final improvement is to add some labels and make use of existing form styles: 1754 |
1755 | 1756 | 1757 | 1758 |1759 | Well done, you have finished the tutorial! It is a good idea to regenerate the database one last 1760 | time, and then give the whole application a thorough testing. Add some posts and comments, both 1761 | while logged in and while logged out, and delete comments in the admin interface. 1762 |
1763 | --------------------------------------------------------------------------------