Dan Q's PHP Cookery Class

An intro to PHP for Web Revivalists

Lesson 5: Accepting Input

Objectives

  • Understand the difference between GET and POST requests
  • Know to use htmlspecialchars() before outputing anything that came from a user
  • Accept input from a user and display it back to them
  • Act upon user input: do different things based on what they submitted
  • Validate user input to ensure it's among the expected values
  • Think about how JavaScript fetch() requests might be integrated with PHP backend processing
  • Make a fun quiz!

One area where PHP really shines is when you want to accept input from your visitors. Feedback forms, commenting, guestbooks, search boxes, user-generated content and more all work best using server-side code.

There are security implications when working with user input. As you go through the lesson, keep an eye out for warnings like this one with information about the relevant dangers; or if you're familiar them skim the appendix on security for a summary of the risks.

GET and POST requests

User input comes in many forms - the web address they've typed in, the contents of forms, even that's in their cookies or their browser's name and version number. But for the time being we're going to focus on the data they supply via GET and POST requests.

GET parameters

If a web address contains a question mark (?), then everything after it (up until any # symbols) are called the "query string". If it has any ampersands (&) or semicolons (;), PHP will split the query string into variables and values around these. Those variables all look like $_GET['variable_name'].

If you've spotted that $_GET must be an array, well done!

Consider the following partial web address:

/05-accepting-input/whats-your-name.php?name=Dan&pronoun=him

When the user visits that page, whats-your-name.php will have access to the following variables:

POST parameters

POST parameters are sent in the body of the request, rather than in the URL. They're associated with forms, but they can be sent in other ways too.

Just because the user can't easily see their POST parameters doesn't mean the y can't tamper with them. They can, so you have to be just as careful with them as with any other user input. We'll look at how to stay safe in a little bit.

The parameters sent in a POST request are stored in the $_POST array.

If you don't care what kind of input you're getting, the $_REQUEST array holds both the GET and POST arrays (sometimes among other data).

Asking the user's name

Let's start by asking the user's name and repeating it back to them. Create a new file called whats-your-name.php with the following code:

<?php
// Check if we're provided a name and pronoun via GET params:
if( isset( $_GET['name'] ) && isset( $_GET['pronoun'] ) ) {
  // Strip any HTML from the name and pronoun:
  $name = htmlspecialchars( $_GET['name'] );
  $pronoun = htmlspecialchars( $_GET['pronoun'] );
  // Construct a greeting string:
  $greeting = "Today I met $name. I was so pleased to meet $pronoun!";
}
?>

<h1>
  Welcome to my friendly website!
</h1>

<?php if( isset( $greeting ) ) { /* We have a greeting! */ ?>

  <h2>
    <?php echo $greeting; ?>
  </h2>
  <p>
    I wonder who I will <a href="whats-your-name.php">meet next?</a>
  </p>

<?php } else { /* No greeting: show the form! */ ?>

  <p>
    Hi! Who are you?
  </p>
  <form method="get" action="whats-your-name.php">
    <label for="name">Name:</label>
    <input type="text" id="name" name="name" autofocus>
    <label for="pronoun">Pronouns:</label>
    <select id="pronoun" name="pronoun">
      <option value="them">they/them</option>
      <option value="him">he/him</option>
      <option value="her">she/her</option>
      <option value="it">it/its</option>
      <option value="aer">ae/aer</option>
      <option value="eir">ey/em/eir</option>
      <option value="faer">fae/faer</option>
      <option value="zir">ze/zir</option>
      <option value="hir">ze/hir</option>
    </select>
    <button type="submit">Hi!</button>
  </form>

<?php } ?>

Here's how it works:

  1. On the first visit to the page, neither $_GET['name'] nor $_GET['pronoun'] are set, so we skip over the initial block of code and start rending the HTML.
  2. This means that $greeting is not set, so skip ahead to rendering "Hi! Who are you?" and the form.
  3. The HTML <form> has two attributes:
    • method="get" means that the form will be submitted using a GET request; that is - the user's input will appear in the address bar as part of the URL.
    • action="whats-your-name.php" means that the form will be submitted to the whats-your-name.php page - i.e. the user will come back to the same page, but now with the GET parameters added to the URL.
  4. The form has two fields with a name="..." attribute. These will become the names of the variables once they're passed to the PHP script.
  5. When they submit the form they'll come back to the same page again, but this time $_GET['name'] and $_GET['pronoun'] will be set. The code will make a variable called $greeting containing a string that acknowledges the user's name and the subject pronoun associated with the value they picked from the dropdown.
  6. The page will then render the greeting, and a link to the same page again so the user can have another go!

We use htmlspecialchars() to escape any HTML that the user entered, making it safe to display in the browser. This prevents Cross-Site Scripting (XSS) attacks. If we didn't do this, a malicious user could enter HTML code that could run their JavaScript on our page. If they could trick another user into visiting a page which they'd tampered with, they could potentially steal their cookies or other sensitive data.
Try going to ?name=%3Cem style%3D%22color%3Ared%3B%22%3Ea wannabe hacker%3C%2Fem%3E&pronoun=them and see that the page is protected. If you want to, try removing the htmlspecialchars() function and see what happens! More info in the appendix.

It's probably easier to experience than explain, so go introduce yourself and see what happens!

In this instance, we didn't perform any validation of the user's input. You can see the effect of this by editin the URL in the address bar to change your pronoun to one that isn't in the list! Try e.g. ?name=Dan+Q&pronoun=that+sloppy+coder! If that's something you care about, you'll need to make sure your code checks that the user's input is valid before using it. More info in the appendix.

Could you add a "(no pronouns)" option to the dropdown? A user who selects "(no pronouns)" should see their own name used a second time, rather than "him"/"her"/"them" etc. (🏁 view solution)

Fruits and filters revisited

In lesson 3 we came up with the idea for a web page that would randomly select a fruit and a CSS effect and combine them. Let's revisit that, but this time we'll let the user choose which fruit and which CSS effect they want to use. This time, we'll use a form with a POST request:

In general, the rule is that idempotent requests (ones that don't change anything, where if you did the same thing twice it would work the same way) should use a GET request, POST requests should be used for requests that change things. So a search form should use a GET request because searching for the same thing twice should yield the same results, but a login form should use a POST request because it's likely to result in a cookie being set or changed (plus: users don't like their passwords appearing in the URL bar in case somebody's standing behind them!). We're ignoring that "best practice" guideline for now, but you should be aware of it. More info in the appendix.

<?php
/**
 * Returns a sorted array of all of the fruit images in the
 * img/fruit directory.
 */
function get_fruits() {
  // Get a sorted list of fruits like we've done before:
  $jpgs = glob('../img/fruit/*.jpg');
  $webps = glob('../img/fruit/*.webp');
  $files = array_merge( $jpgs, $webps );
  sort( $files );
  // Return the list to whichever code called get_fruits()
  return $files;
}

/**
 * Returns an array of CSS filters that can be applied to an
 * image.
 */
function get_filters() {
  return [
    'filter: grayscale(100%)',
    'filter: blur(2px)',
    'filter: brightness(1.5)',
    'filter: contrast(1.5)',
    'filter: hue-rotate(90deg)',
    'filter: invert(1)',
    'filter: sepia(1)',
    'transform: rotateZ(90deg);',
    'transform: skewX(-15deg);',
  ];
}

/**
 * Returns true if the user provided a fruit_id and filter_id.
 * Returns false otherwise.
 */
function user_requested_a_fruit_and_filter() {
  // If they haven't provided a fruit_id or filter_id, false:
  if( ! isset( $_POST['fruit_id'] ) ) return false;
  if( ! isset( $_POST['filter_id'] ) ) return false;
  // Othewise, true!
  return true;
}

/**
 * Shows the requested fruit with the applied CSS filter.
 */
function show_a_filtered_fruit() {
  // Note that we don't ask the user to send us the filename
  // but the NUMBER of the fruit (and filter), in its list.
  // This is a strong validation step - the user can't inject
  // anything we didn't expect them to!
  $fruit_id = intval( $_POST['fruit_id'] );
  $filter_id = intval( $_POST['filter_id'] );
  $fruit = get_fruits()[ $fruit_id ];
  $filter = get_filters()[ $filter_id ];

  // Output the fruit image with the filter applied:
  ?>
  <img src="<?php echo $fruit; ?>"
     width="200"
    height="200"
     style="border: 1px solid black; <?php echo $filter; ?>;">
  <?php
}
?>

<h1>Fruity Filters</h1>

<?php if( user_requested_a_fruit_and_filter() ) { ?>
  <h2>
    Here's your filtered fruit:
  </p>
  <?php show_a_filtered_fruit(); ?>
<?php } ?>

<h2>
  <?php if( user_requested_a_fruit_and_filter() ) { ?>
    Filter another fruit!
  <?php } else { ?>
    Filter a fruit!
  <?php } ?>
</h2>
<form method="post" action="fruits-and-filters.php">
  <p>
    <label for="fruit">Fruit:</label>
    <select id="fruit" name="fruit_id">
      <?php foreach( get_fruits() as $id => $fruit ) { ?>
        <option value="<?php echo $id; ?>">
          <?php echo basename( $fruit ); ?>
        </option>
      <?php } ?>
    </select>
  </p>

  <p>
    <label for="filter">Filter:</label>
    <select id="filter" name="filter_id">
      <?php foreach( get_filters() as $id => $filter ) { ?>
        <option value="<?php echo $id; ?>">
          <?php echo $filter; ?>
        </option>
      <?php } ?>
    </select>
  </p>

  <button type="submit">Submit</button>
</form>

Here's how it works:

  1. When the page loads, the user_requested_a_fruit_and_filter() is used to check if a request has been made. It looks for the $_POST variables that are set by submitting the form.
  2. If the user has requested a fruit and a filter, the show_a_filtered_fruit() function is used to display the fruit image with the filter applied. It uses get_fruits() and get_filters() to get the list of fruits and filters, then finds the correct-numbered fruit and filter from the list, based on the user's request. Then it displays that image.
  3. Either way, the page then renders a form with two <select> (dropdown) fields. Each lists the relevant fruits/filters, but the value="..." of each <option> is not the fruit/filter name but its index (position) in the list. This means a malicious user can't tamper with the form by changing the value into a fruit or filter of their choice: it has to be one that's in the list.

Users who are allowed to select from a list of files on your server are a special case for security. If we accepted the file name from the user, and we allowed PHP to read and send the file (e.g. with readfile()), the user could tweak the request to ask for any file on your system, e.g. /etc/passwd or /home/user/my-secrets.txt. Our program is safe because we're only passing ID numbers around and then using those to look up fruits from the approved list, but there's a relevant section in the appendix if you want to let your users specify paths to files on your server.

Wouldn't it be nice if the <select> boxes retained the values that were last selected, when the page reloads? To do that, you'll need to add a selected attribute of the <option> tags. Tip: everything you get from the user is a string, so you'll need to convert it to an integer before comparing it to the ID number. (🏁 view solution)

If you enjoy JavaScript, you'll be pleased to know that you can integrate JavaScript and PHP together. Can you make it so that, if JavaScript is enabled, the form is submitted using a fetch() request, and if PHP sees this come in then it returns some JSON to tell JavaScript what to update on the page? It should still work even with JavaScript disabled. (🏁 view solution)

Let's make a quiz!

Quizzes are a great example of something for which using PHP would be better than using JavaScript, because it means that the user can't cheat my looking at the source code. Let's make a quiz that tests the user's PHP knowledge with three questions, and then tells them their score at the end!

Create a new file called quiz.php:

<?php
// Let's store the questions in an array for easy modification:
$questions = [
  [
    'question' =>
      'Which of these PHP globals might contain user input?',
    'options' => [
      '$_GET',
      '$_POST',
      '$_COOKIE',
      'All of the above!'
    ],
    'correct_answer' => 'All of the above!',
  ],

  [
    'question' =>
      'How would I reverse a string in PHP?',
    'options' => [
      'reverse()',
      'strrev()',
      'string_reverse()',
      "You can't reverse a string in PHP"
    ],
    'correct_answer' => 'strrev()',
  ],

  [
    'question' =>
      'How would I loop through the items in a PHP array?',
    'options' => [
      'foreach()',
      'each()',
      'array_each()',
      'for $item of $array'
    ],
    'correct_answer' => 'foreach()',
  ],
];

/**
 * Display a single question and it's options:
 */
function show_question($number, $data){
  echo '<p>' . $data['question'] . '</p>';
  echo '<ul>';
  foreach( $data['options'] as $option ){
    echo '<li><label>';
    // Notice how we assemble the name of this field - like:
    // <input name="answer[1]" value="some answer">
    // PHP recognises the name with square brackets as an
    // array and gives you one at the other end!
    echo '<input type="radio"';
    echo ' name="answer[' . $number. ']"';
    echo ' value="' . $option . '">';
    echo $option;
    echo '</label></li>';
  }
  echo '</ul>';
}

/**
 * Show the form with the questions:
 */
function quiz($questions){
  ?>
  <h1>Quiz time!</h1>
  <form method="post" action="<?php echo $_SERVER['REQUEST_URI']; ?>">
  <?php
  foreach( $questions as $number => $question ){
    show_question( $number, $question );
  }
  ?>
  <input type="submit" name="submit" value="See score">
  </form>
  <?php
}

/**
 * Score the user's answers and give them their results:
 */
function results($questions){
  // Make a variable to keep their score in:
  $score = 0;
  // Loop through each question, and see if they got it right:
  foreach( $questions as $number => $question ){
    // The correct answer comes from the questions array:
    $correct_answer = $question['correct_answer'];
    // Their answer comes from an array that was posted to us:
    $their_answer = $_POST['answer'][$number];
    // If they match, give them a point!
    if( $their_answer === $correct_answer ){
      $score++;
    }
  }
  // What's the best possible score? The number of questions:
  $max_score = count($questions);
  ?>
  <h1>Quiz results</h1>
  <p>
    You scored <?php echo $score; ?> out of <?php echo $max_score; ?>!
  </p>
  <p>
    <a href="<?php echo $_SERVER['REQUEST_URI']; ?>">
      Have another go?
    </a>
  </p>
  <?php
}

// If the submit button was pressed (something with
// name="submit"), show the results, otherwise show the quiz:
if( $_POST['submit'] ){
  results($questions);
} else {
  quiz($questions);
}

Here's how it works:

  1. If the user isn't submitting the form, we show the quiz. We loop through an array and show each question and the candidate answers associated with it. The array also contains the correct answer, but that's never sent to the user's browser.
  2. The name="..." of an <input> is used by PHP to choose where it appears in the $_POST array; we use a special syntax with square brackets to tell PHP to store the answers given by the user in an array with each answer being numbered after the number of the question it belongs to.
  3. Once submitted, we score the quiz by looping through the questions again and comparing the correct answer to the one the user gave. If they match, we increment the score.
  4. Throughout, we use the <?php echo $_SERVER['REQUEST_URI']; ?> as a convenient shorthand for "the URL of the current page".

Could you give the user a ranking based on their score, using a series of if statements? Or even a switch statement? Or perhaps you could show their score as a percentage?

Maybe you would like to tell the user which questions they got wrong, so they can learn from their mistakes?