A carefully written question about user-facing forms

Hi everybody, I’ve almost finished converting an existing client site to Kirby. Everything is working well, but I need to handle a simple user facing form.

The careful part

Before I continue I should say:

  1. I am aware of existing form plugins (thanks especially to @jenstornell’s plugin list and the various posts here on the forum). I have attempted to use them, but have not succeeded. So before you recommend that I use one, please keep this in mind.
  2. I would really like to learn how to do this using Kirby specifically, and thereby learn a little more about how to write and use PHP.
  3. My code works. I just don’t know how to validate the data and send messages back to the user looking at the form.
  4. I realize there’s a pretty good chance this may be a PHP coding question and not really a Kirby one, and if that’s the case, I’d love any suggestions of how to learn to tackle this.

What I have so far

A comment form

This is the snippet where I define my form ( in snippets/commentform.php ):

<?php if(!defined('KIRBY')) exit ?>
<div class="main" role="main">

    <hr>

    <h3>Leave your comment</h3>

    <form id="commentform" method="post">
    
        <div class="field">
            <input type="text" id="name" name="name" placeholder="Name (required)">
        </div>
        
        <div class="field">
            <input type="email" id="email" name="email" placeholder="Email (required)">
        </div>
        
        <div class="field">
            <input type="url" id="website" name="website" placeholder="Website (optional)">
        </div>
        
        <div class="field">
            <textarea id="comment" name="comment" placeholder="Your comment"></textarea>
        </div>
        
        <input type="submit" name="submit" value="Submit">
    </form> </div>

A controller

I get the data and handle it in controllers/commentform.php:

<?php if(!defined('KIRBY')) exit ?>

<?php 

// If the form has been sent
if(r::method() === 'POST') {

    // Do some useful things
    return function($site, $pages, $page) {   

        // Build an array of the data from the fields
        // get() fetches the form field value with that `name`
        $data = array(
            'name'    => get('name'),
            'email'   => get('email'),
            'website' => get('website'),
            'comment' => get('comment')
        );
        
        // Get and store this moment's date and time for use in constructing the file on disk
        $commentDate = $page->modified('Y-m-d-H-i-s');

        // Assuming everything's OK, create a new page as child of the current page
        // You can also use a different page by using `page('whatever')->children()->create()`
        $p = $page->children()->create(($commentDate . '-'), 'formdata', $data);
    }
};

A blueprint

There’s also blueprints/commentdata.yml that serves as a pattern for the commentdata.txt file written into the system

<?php if(!defined('KIRBY')) exit ?>

# pattern defining the data captured from a reader comment form
title: Comment data
pages: false
files: false
fields:
  username:
  label: Your name
  type:  text
  email:
    label: Email
    type:  email
  website:
    label: Website
    type:  url
  comment:
    label: Comment
    type:  textarea

This is working great. It’s fantastic and writes a uniquely timestamped folder as a child to the blog article with a commentdata.txt in it containing the data.

It’s pretty neat to see it all go. Except the fact that a comment is posted even if every single field is empty.

What I need to do next

I need to validate this stuff before it gets posted.

I would like to step through each field and test its validity, presumably using Kirby’s validators. If something isn’t valid, I’d like to put a message back into the form that I can style and show the user.

A simple statement of rules would be:

name (required)
email (required, a valid email address)
website (entirely optional)
comment (required minimum length of 3 characters, maximum length of, say, 3000)

I’m happy to post more parts or answer questions.

Thanks in advance for your advice and any help.

Jon

1 Like

Hello Jon
Quite an interesting project you got going on here!
I have two questions regarding validation:

  1. Do you have any problems with using javascript to validate in the front-end?
  2. Is the website part going to be displayed in the front-end (i.e. other users could see the URL)?

I don’t have any javascript in the form at all. So no, no problem. :wink:

I haven’t decided about showing the the website as a link, as I’m still developing this.

Your controller could look similar to this:

<?php

return function($site, $pages, $page) {

  $alert = null;
  $message = null;

  if(r::is('post') && get('submit')) {
   
   //escape the user input before saving to file 
   $data = array(
      'name'  => esc(get('name')),
      'website'  => esc(get('website')),
      'email' => get('email'),
      'comment'  => esc(get('comment'))
    );

    $rules = array(
      'name'  => array('required'),
      'comment'  => array('required', 'min' => 3, 'max' => 3000),
      'email' => array('required', 'email'),
      'website' => array('url')
    );

    $messages = array(
      'firstname'  => 'Please enter a valid first name',
      'lastname'  => 'Please enter a valid last name',
      'email' => 'Please enter a valid email address',
    );

    // some of the data is invalid
    if($invalid = invalid($data, $rules, $messages)) {
      $alert = $invalid;
    } else {

      // everything is ok, let's try to create a new comment
      try {

      // Get and store this moment's date and time for use in constructing the file on disk
        $commentDate = $page->modified('Y-m-d-H-i-s');

        // Assuming everything's OK, create a new page as child of the current page
        // You can also use a different page by using `page('whatever')->children()->create()`
        $p = $page->children()->create(($commentDate . '-'), 'formdata', $data);

        $message = "Your registration was successful";
        $data = array();

      } catch(Exception $e) {
        echo 'Your registration failed' . $e.getMessage();
      }
    }
  }

  return compact('alert', 'data', 'message');
};

Then in your template:

<?php if($message): ?>
  <div class="message">
    <?php echo $message; ?>
  </div>
<?php endif ?>


<?php if($alert): ?>
  <div class="alert">
    <ul>
      <?php foreach($alert as $message): ?>
        <li><?php echo html($message) ?></li>
      <?php endforeach ?>
    </ul>
  </div>
<?php endif ?>

Additionally, you can use a honeypot for spam protection, i.e. a hidden field in your form that should not be filled in by real users.

You would then check if the field was filled in, if so, you would exit the process and just tell the bot that everything was ok.

Let’s suppose the field was called website, but in your case, it should be something different

if(! empty(get('website'))) {
      // lets tell the bot that everything is ok
      go($page->url()); //or any other code that sends a 200 OK response code
      exit;
    }

If you need any more explanations, pls. don’t hesitate to ask.

2 Likes

Brilliant,
I stopped writing my answer when I saw your message. You’re just too fast for me :slight_smile:

About front-end validation:

2 Likes

@texnixe OK, I’m going to try this next. Thanks for such a prompt reply.
@Thiousi Thanks for the pointers to HTML5 validators. Looks useful.

I’ll report back soon.

1 Like

OK.

Tried as suggested by @texnixe. Sadly, comments posted even without all the fields. Then I changed some things. But now I’m sad because I don’t know what I changed. I’m rolling back in my dev repo to try again…

It may be tomorrow before I have things sorted. :frowning:

@texnixe here’s a question that’s bugging me while I sort out the other stuff.

In your controller code example you have

// some of the data is invalid
if($invalid = invalid($data, $rules, $messages)) {
  $alert = $invalid;
} else { ...

I don’t think I get where invalid() is from. Is it part of Kirby’s validators?

Last update for this evening. I rolled back to where I had it working. That’s good. I also simplified my setup for working on this. My template no longer calls in the snippets for article content, comments, and the form. Instead I placed all of those parts in my article.php (site/templates/article.php).

I remade the controller according to @texnixe advice, and placed it at site/controllers/article.php.

So now I have 1 template and 1 matching controller.

An article page will load and display the article, comments and form, but form submission returns an empty page.

Using my old controller with the new template works as before. Using the new controller doesn’t work.

Revised parts

Article template (site/templates/article.php)

The new monolithic version without snippets or honeypot

<?php if(!defined('KIRBY')) exit ?>

<?php snippet('header') ?>
<div class="main" role="main">
    <div class="row">
        <div class="three-quarter">
            <div class="article">
                <section class="content blogarticle">
                    <article>
                        <h1><?php echo $page->title()->html() ?></h1>
                        <time datetime="<?php echo $page->date('c') ?>" pubdate="pubdate"><?php echo $page->date('Y-m-d') ?></time>
                        <p><?php echo $page->text()->kirbytext() ?></p>       
                        <p><a class="fgcolor-rotator" href="<?php echo url('blog') ?>">Back to blog list</a></p>
                    </article>
                </section>
                <div class="text">
                    <hr>
                    <h2>Comments</h2>
                    <?php foreach ($page->children() as $child ): ?>
                        <hr class="comment-divider">
                        <p><?php echo $child->username() ?></p>
                        <p><?php echo $child->email() ?></p>
                        <p><?php echo $child->website() ?></p>
                        <p><?php echo $child->comment() ?></p>
                    <?php endforeach ?>    
                </div><!-- /.text -->
                <div class="main" role="main">
                    <hr>
                    <h3>Leave your comment</h3>
                    <form id="commentform" method="post">
                        <!-- show any message from the controller -->
                        <?php if($message): ?>
                            <div class="message">
                                <?php echo $message; ?>
                            </div>
                        <?php endif ?>                            
                        <!-- Show any alerts from the controller here -->
                        <?php if($alert): ?>
                            <div class="alert">
                                <ul>
                                <?php foreach($alert as $message): ?>
                                    <li><?php echo html($message) ?></li>
                                <?php endforeach ?>
                                </ul>
                            </div>
                        <?php endif ?>
                        <div class="field">
                            <input type="text" id="name" name="name" placeholder="Name (required)">
                        </div>
                        <div class="field">
                            <input type="email" id="email" name="email" placeholder="Email (required)">
                        </div>
                        <div class="field">
                            <input type="url" id="website" name="website" placeholder="Website (optional)">
                        </div>
                        <div class="field">
                            <textarea id="comment" name="comment" placeholder="Your comment"></textarea>
                        </div>
                            <input type="submit" name="submit" value="Submit">
                    </form>
                </div><!-- /.main -->
            </div><!-- /.article -->
        </div><!-- /.three-quarter -->
        <div class="quarter">
            <div id="" class="utility">
                <p>tag cloud here</p>
                <?php snippet('calendar') ?>
            </div><!-- /.utility -->
        </div><!-- /.quarter -->
    </div><!-- end row -->
</div><!-- /.main -->
<?php snippet('footer') ?>

Article controller (site/controllers/article.php)

Totally ripped off of @texnixe example with some edits to make alerts match the fields

<?php if(!defined('KIRBY')) exit ?>

<?php
    return function($site, $pages, $page) {

    $alert = null;
    $message = null;

    if(r::is('post') && get('submit')) {
 
     //escape the user input before saving to file 
     $data = array(
        'username'  => esc(get('username')),
        'email' => get('email'),
        'website'  => esc(get('website')),
        'comment'  => esc(get('comment'))
     );

     $rules = array(
        'username'  => array('required'),
        'email' => array('required', 'email'),
        'website' => array('url'),
        'comment'  => array('required', 'min: 3', 'max: 3000')
     );

     $messages = array(
        'username'  => 'Please enter a valid name',
        'email'  => 'Please enter a valid email address',
        'comment' => 'You\'ve gotta believe this is required'
     );

     // some of the data is invalid
     if($invalid = invalid($data, $rules, $messages)) {
        $alert = $invalid;
     } else {

     // everything is ok, let's try to create a new comment
     try {

        // Get and store this moment's date and time for use in constructing the file on disk
        $commentDate = $page->modified('Y-m-d-H-i-s');

        // Assuming everything's OK, create a new page as child of the current page
        // You can also use a different page by using `page('whatever')->children()->create()`
        $p = $page->children()->create(($commentDate . '-'), 'formdata', $data);

        $message = "Your comment was successful";
        $data = array();

        } catch(Exception $e) {
        echo 'Your comment failed' . $e.getMessage();
      }
    }
   }
  return compact('alert', 'data', 'message');
 };

Oh, I’m sorry. I probably made some mistakes when trying to adapt my working example to your form requirements. What is missing now? I’ll try to check it out as soon as possible.

As regards the additional suggestions by @Thiousi:

  • HTML5 form validation does not work in all browsers, you cannot rely on this
  • JS form validation can be an additional goody, but you cannot solely rely on that either, because users can simply deactivate javascript, so you will always need back end validation additionally
  • and yes, you can of course use enhance form validation with AJAX

Edit: I think the validation syntax for the comments field is wrong, I changed it in my code example above. Probably should not be working late at night …:blush:

2 Likes

OK! Yes! THANKS.

This works. The change in validation syntax to specify the validator to use as a string min and then pass in the test value as an integer 3 works as in the last line here:

$rules = array(
  'username'  => array('required'),
  'email' => array('required', 'email'),
  'website' => array('url'),
  'comment'  => array('required', 'min' => 3, 'max' => 3000)
);

I have to get up at 05:30 PST so that’s it for tonight. Tomorrow afternoon I’m going to implement the honeypot and then tackle keeping user input in the form.

Still curious about the invalid function as being part of Kirby (see above).

Thanks again. More soon.

The max and min validators will check the value instead of the length of a string if it contains only numeric characters.

Note: There are two new validators coming in Kirby 2.3.2 (already available in the dev branch on github), minLength and maxLength.

1 Like

invalid()is a Kirby helper function (kirby/toolkit/helpers.php)

https://getkirby.com/docs/cheatsheet/helpers/invalid

1 Like

Some other things you may want to consider:

  • When outputting such user input (or maybe even at validation time), strip possible tags instead of simply escaping strings
  • do not show the form anymore after the form was sent successfully or disable the submit button
  • use a CSRF token like done in the Uniform plugin
1 Like

Maybe this is a different topic:

What is the difference between stripping tags and escaping text? This is a PHP thing, yes?

Escaping is the equivalent of encoding special characters to render html tags as text so they won’t be interpreted.
Stripping is actually removing all those tags so you’re only left with text.

1 Like

Hi guys,

I finally was able to make some progress on this, refining the UX.

I am learning a lot, and increasing my understanding of PHP, Kirby and general programming concepts.

My most recent changes use the the controller as written almost 2 weeks ago, but I’ve changed the HTML of the form.

Now, if there’s any $alert at all, I populate the fields with any data they had.

Also, if there’s an alert for any particular field, I insert its alert message next to the form element in a classed <span> tag.

This way, any user input is retained, and problems are flagged per field.

One thing that confused me for awhile was testing for a particular value inside the $alert variable.

When testing to see if there was a message for an email error:
This made me sad:
<?php if($alert['messages']['email'] …

This made me happy:
<?php if($alert['email'] …

The way that works is nicer for sure, but I didn’t understand that when $alert was created in the controller, that it was a single array.

I haven’t done displaying a success message or other things yet. Here’s the code so far:

The form

<h3>Leave your comment</h3>
<form id="" method="post">
    <div class="field">
        <input type="text" id="username" name="username" placeholder="Name (required)" value="<?php if($alert) { echo html($data['username']); } ?>">
        <?php if($alert['username']) { echo '<span class="alert">' . html($messages['username']) . '</span>'; } ?>
     </div>
     <div class="field <?php if($alert['email']){ echo html('alert'); } ?>">
         <input type="email" id="email" name="email" placeholder="Email (required)" value="<?php if($alert){ echo html($data['email']); } ?>">
         <?php if($alert['email']){ echo '<span class="alert">' . html($messages['email']) . '</span>'; } ?>
      </div>
      <div class="field">
          <input type="url" id="website" name="website" placeholder="Website (optional)" value="<?php if($alert){ echo html($data['website']); } ?>">
      </div>
      <div class="field <?php if($alert['comment']){ echo html('alert'); } ?>">
          <?php if($alert['comment']){ echo '<span class="alert">' . html($messages['comment']) . '</span>'; } ?>
          <textarea id="comment" name="comment" placeholder="Your comment" ><?php if($alert){ echo html($data['comment']); } ?></textarea>
       </div>
       <input type="submit" name="submit" value="Submit">
</form>

##The Controller
(restated here for convenience)

<?php if(!defined('KIRBY')) exit ?>

<?php

return function($site, $pages, $page) {

  $alert = null;
  $message = null;

  if(r::is('post') && get('submit')) {
   
   //escape the user input before saving to file 
   $data = array(
      'username' => esc(get('username')),
      'email'    => get('email'), // already escaped by kirby email function
      'website'  => esc(get('website')),
      'comment'  => esc(get('comment'))
    );

    $rules = array(
      'username' => array('required'),
      'email'    => array('required', 'email'),
      'website'  => array('url'),
      'comment'  => array('required', 'min' => 3, 'max' => 3000)
    );

    $messages = array(
      'username' => 'Please enter a name',
      'email'    => 'Please enter a valid email address, such as john.doe@email.com',
      'comment'  => 'You\'ve gotta believe a comment is required'
    );

    // some of the data is invalid
    if($invalid = invalid($data, $rules, $messages)) {
      $alert = $invalid;
    } else {

      // everything is ok, let's try to create a new comment
      try {

      // Get and store this moment's date and time for use in constructing the file on disk
        $commentDate = $page->modified('Y-m-d-H-i-s');

        // Assuming everything's OK, create a new page as child of the current page
        // You can also use a different page by using `page('whatever')->children()->create()`
        $p = $page->children()->create(($commentDate . '-'), 'formdata', $data);

        $message = "Your comment was successful";
        $data = array();

      } catch(Exception $e) {
        echo 'Your comment failed' . $e.getMessage();
      }
    }
  }
  return compact('alert', 'data', 'messages');
};

Here’s a green grab after a submit attempt:

2 Likes

It looks like you’re making some good progress!
In terms of accessibility, it is highly recommended to include labels associated to each input element. And optional but nice for the user experience, you can indicate which fields are mandatory.

If you want to read more about accessibility and forms, you can start with this article: https://www.w3.org/WAI/tutorials/forms/labels/

Thanks for keeping us posted on your progress!

1 Like

@Thiousi I have placeholder text and required and optional set inside. I know that labels outside the input are better, but this is where I’m at right now.

More soon!