Tutorial: Sample PHP form mailer

Greg K's picture

He has: 2,145 posts

Joined: Nov 2003

The following example is a sample PHP page you can use to create a simple Contact Us form on your site for visitors to send you information.

Some of the features included are easy adding of things such as checkboxes, (can be modified for radio buttons and select boxes easily too), Honeypot field and time checking to help cut back on spam and server side validation which takes you back to the same form already filled out with what you had already entered, indicating what is wrong.

There are more complex, and more simple versions out there, this is not the only way either, just how I like to do it. Here is the whole example (with the actual mailing of the form commented out). Below it I will describe the features/parts in detail. This HTML portion of this is extremely simplified, so that you can easily adapt it into any design you come up with.

<?php
    define
("MAIL_FROM","[email protected]");
   
define("MAIL_TO","[email protected]");
   
define("MAIL_CC","[email protected],[email protected]");
   
define("MAIL_BCC","[email protected]");
   
define("MAIL_SUBJECT","A form submitted from my site");

   
$aryCheckbox = array('Card'=>array('Visa','Master Card','Discover','American Express'));

   
$aryData = array(); // Contains data used
   
$aryErr = array(); // Contains items that had errors

   
if (count($_POST)>0 && isset($_POST['url']) && $_POST['url']=='' && isset($_POST['formhash']) && hexdec($_POST['formhash'])>(time()-604800)) {

       
// This will make sure "empty" values exist for checkboxes that nothing was selected on
       
foreach($aryCheckbox as $key=>$val) { if (!isset($_POST['chk'.$key]) || !is_array($_POST['chk'.$key])) $_POST['chk'.$key]=array(); }

        foreach(
$_POST as $key=>$val) {
           
$strType = substr($key,0,3);
           
$strName = substr($key,3);
            switch (
$strType) {
                case
'txt':
                       
$aryData[$strName] = trim($val);
                        break;
                case
'chk':
                       
$aryData[$strName] = $val;
                        break;
            }
        }

       
/****** START VALIDATING VALUES *********/

       
if ($aryData['Name']=='') {
           
$aryErr['Name'] = "Name is required";
        }
        if (
$aryData['Company']=='') {
           
$aryErr['Company'] = "Company is required";
        }
        if (
$aryData['Email']=='') {
           
$aryErr['Email'] = "E-mail is required";
        }
        elseif (!
preg_match('/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,6}/i',$aryData['Email'])) {
           
$aryErr['Email'] = "Invalid e-mail address";
        }
        if (
$aryData['URL']=='http://') $aryData['URL'] = '';
        if (
$aryData['URL']!='' && !preg_match('%^(http|https)://([-_a-z0-9]+\.)+[a-z]{2,3}.*$%i',$aryData['URL'])) {
           
$aryErr['URL'] = "Invlide link format";
        }

       
/*** if you wanted to make sure they check at least one, uncomment below ***/

        // if (count($aryData['Card'])<1) {
        //     $aryErr['Card'] = "You must select at least one";
        // }

       
if (count($aryErr)==0) {
           
/************* NO ERRORS - EMAIL, SAVE TO DB, WHATEVER! ************/
           
$aryMsg = array();
           
$aryMsg[] = "Someone came by and posted the following:\n"; // Only need \n for double spacing
           
foreach($aryData as $key=>$val) {
                if (
is_array($val)) {
                   
$aryMsg[] = $key.": ";
                    if (
count($val)>0) {
                        foreach(
$val as $valkey) $aryMsg[] = "\t".$aryCheckbox['Card'][$valkey];
                    }
                    else {
                       
$aryMsg[] = "\t(none selected)";
                    }
                }
                else {
                   
$aryMsg[] = $key.": ".$val;
                }
            }
           
$aryMsg[] = "\nSubmitted from IP#".$_SERVER['REMOTE_ADDR'];

           
$strHeader = "From: ".MAIL_FROM."\r\n";
            if (
MAIL_CC!='') $strHeader .= "CC: ".MAIL_CC."\r\n";
            if (
MAIL_BCC!='') $strHeader .= "BCC: ".MAIL_BCC."\r\n";
           
$strHeader .= "X-Mailer: Simple PHP Former";

           
// mail(MAIL_TO,MAIL_SUBJECT,implode("\n",$aryMsg),$strHeader);
       
}
    }
    else {
       
/********** First time call, set "default" values ***************/
       
$aryMsg = false;
       
$aryData['Name'] = '';
       
$aryData['Company'] = '';
       
$aryData['Name'] = '';
       
$aryData['Email'] = '';
       
$aryData['URL'] = 'http://';
       
$aryData['Card'] = array(); // need this instead of ''
   
}

   
// Done using values, prep for displaying on a web page
   
foreach($aryData as $key=>$val) {
        if (
is_string($val)) $aryData[$key] = htmlspecialchars($val);
    }

    function
errOut($key) {
        global
$aryErr;
        if (isset(
$aryErr[$key])) {
           
// Change the following line to output the error the way you want
           
echo '<span style="color:red;">'.$aryErr[$key].'</span><br />';
        }
    }

?>

<html>
<head><title>Sample Form</title></head>
<body>
    <h1>Sample Form</h1>
    <?php if ($aryMsg) { ?>
        <p>Thank you for sumbitting the form, here is what was sent:</p>
        <hr>
        <pre><?php echo implode("\n",$aryMsg); ?></pre>
        <hr>
        <a href="<?php echo $_SERVER['SCRIPT_NAME']; ?>" >Try another!</a>
    <?php } else { ?>
        <p>Please fill out the following form. You'll love it!</p>
        <?php if (count($aryErr)>0) { ?>
        <p style="color:red;"><strong>There were some errors, please fix them and try again.</strong></p>
        <?php } ?>
        <form action="<?php echo $_SERVER['SCRIPT_NAME']; ?>" method="post" >

            <?php errOut('Name'); ?>
            <label for="txtName">Name</label><br />
            <input type="text" name="txtName" id="txtName" value="<?php echo $aryData['Name']; ?>"/><br /><br />

            <?php errOut('Company'); ?>
            <label for="txtCompany">Company</label><br />
            <input type="text" name="txtCompany" id="txtCompany" value="<?php echo $aryData['Company']; ?>"/><br /><br />

            <?php errOut('Email'); ?>
            <label for="txtEmail">Email</label><br />
            <input type="text" name="txtEmail" id="txtEmail" value="<?php echo $aryData['Email']; ?>"/><br /><br />

            <?php errOut('URL'); ?>
            <label for="txtURL">URL</label><br />
            <input type="text" name="txtURL" id="txtURL" value="<?php echo $aryData['URL']; ?>"/><br /><br />

            <?php errOut('Card'); ?>
            How can you Pay?<br />
            <?php
               
foreach($aryCheckbox['Card'] as $key=>$strCard) {
                    echo
'<input type="checkbox" name="chkCard[]" id="chkCard_'.$key.'" value="'.$key.'" ';
                    if (
in_array($key,$aryData['Card'])) echo 'checked="checked"';
                    echo
'>'.htmlspecialchars($strCard)."</label><br>\n";
                }
           
?>


            <fieldset style="display:none;">
            <label for="url">This field must be left blank</label>
            <input type="text" name="url" id="url" value=""/>
            </fieldset>

            <br />
            <input type="hidden" name="formhash" value="<?php echo dechex(time()); ?>" />
            <input type="submit" name="submit" value="Process" />
        </form>
    <?php } ?>
</body>
</html>

OK! Now that you have that copied into your editor, lets break it down to what it does!

    define("MAIL_FROM","[email protected]");
    define("MAIL_TO","[email protected]");
    define("MAIL_CC","[email protected],[email protected]");
    define("MAIL_BCC","[email protected]");
    define("MAIL_SUBJECT","A form submitted from my site");

These are set up here so that down the road, it is easy to change the values without having to find them down in the code, which if you are making this for a site for someone else, they don't need much knowledge of PHP to change them. The CC and BCC can be levt blank, and have more than one e-mail address, separated by commas. NOTE there is no validating of these e-mails.

    $aryCheckbox = array('Card'=>array('Visa','Master Card','Discover','American Express'));

This is for the checkbox, it is overkill a bit having an array of an array for one simple set of checkboxes, but lets you easily change their values, especially if you have a few different groups. Note, the display order and the listing in the e-mail will match the same order here.

    $aryData = array(); // Contains data used
    $aryErr = array(); // Contains items that had errors

These hold values used for processing the form. $aryData will be the values minus their "type prefix". (ie. Name not txtName )

$aryErr will contain any errors you find in validating the the values that finally end up in $aryData. Note that for this sample I have each error displaying right above its respective input, but if that is not fitting for your form you can have it output them all at the top of the form with the main message by doing something like:

    echo "<ul>";
    foreach($aryErr as $msg) { echo "<li>".$msg."</li>"; }
    echo "</ul>\n";

Also if you use the above method, you can remove the function that outputs the error.

    if (count($_POST)>0 && isset($_POST['url']) && $_POST['url']=='' && isset($_POST['formhash']) && hexdec($_POST['formhash'])>(time()-604800)) {

This checks to see if we should process the form if submitted and passing Anti-bot protection without resorting to captcha!

In form, we have a very basic input named "url", a name a bot likes to look for and fill out. (Do not confuse this with the real input, txtURL). This input is styles to not display for people with styles turned on. Bots will still see it and put something in there, so if there is something in here, safe to act like it is the first visit to the form (part of the hidden input is a label that informs those using a reader or without styles to leave it blank.)

Also, one other method used is someone comes across your form, fills it out, but then keeps track of what was sent to the page to submit data. Once they have a set that is valid, they can run a bot that resubmits that over and over. So again, to try to avoid dealing out ugly captcha, there is a hidden field on the form that is a hex'd copy of the timestamp. When you submit the form, it checks that it was within the past week (60 * 60 * 24 * 7) before processing. Now there is consideration for the form getting cached in the person's browser, which is why for this sample I put it out to 7 days. A better method would be to set it less (3600 - one hour) and then set this page to not cache longer than an hour.

I have used this method on a site for someone when they were trying to figure out why they were getting 2 spams a day with the "human check" (What is 3+5?) properly filled out. Checking the log file reveiled that each of these were direct POST's back to the form, never visiting it from scratch. Someone just set a bot to include the field filled out with 8.

Between these two methods, you should have as little spam without resulting to captcha.

        // This will make sure "empty" values exist for checkboxes that nothing was selected on
        foreach($aryCheckbox as $key=>$val) { if (!isset($_POST['chk'.$key]) || !is_array($_POST['chk'.$key])) $_POST['chk'.$key]=array(); }

One of the problem spots people have with checkboxes/radios/selects is missing the possibility of what if they didn't choose anything at all? On this form, if you don't fill in a checkbox, when you submit the form, you don't get a $_POST['chkCard'] as an empty array (would be nice), $_POST['chkCard'] just doesn't even exist.

So we do this check to create it if it didn't exist. Notice how if you had more than one, the array defining the lists of checkboxes makes it easier? Just add another set to the array and add the code to the form, changing 'Card' to what you called the new one.

        foreach($_POST as $key=>$val) {
            $strType = substr($key,0,3);
            $strName = substr($key,3);
            switch ($strType) {
                case 'txt':
                        $aryData[$strName] = trim($val);
                        break;
                case 'chk':
                        $aryData[$strName] = $val;
                        break;
            }
        }

Ok, now we go through all the $_POST values and grab out ones with known prefixes (A complete list, I use the following:

  • hid = Hidden values needing passed through forms.
  • txt = Text/TextAreas
  • rad = Radio buttons, these are NOT set up as array (no [] at the end of the name, treat like a text
  • chk = Checkboxes, these should be set up as an array (even if just one so you don't have to recode this)
  • opt = inputs, coded similar to checkboxes
  • fil = files (more advanced than this sample)

Again this may seem like overkill, you could have just assigned them all back instead of breaking out in a SELECT/CASE, but this makes it easier to add other items down the road.

        /****** START VALIDATING VALUES *********/

        if ($aryData['Name']=='') {
            $aryErr['Name'] = "Name is required";
        }
        if ($aryData['Company']=='') {
            $aryErr['Company'] = "Company is required";
        }
        if ($aryData['Email']=='') {
            $aryErr['Email'] = "E-mail is required";
        }
        elseif (!preg_match('/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,6}/i',$aryData['Email'])) {
            $aryErr['Email'] = "Invalid e-mail address";
        }
        if ($aryData['URL']=='http://') $aryData['URL'] = '';
        if ($aryData['URL']!='' && !preg_match('%^(http|https)://([-_a-z0-9]+\.)+[a-z]{2,3}.*$%i',$aryData['URL'])) {
            $aryErr['URL'] = "Invlide link format";
        }

        /*** if you wanted to make sure they check at least one, uncomment below ***/
        // if (count($aryData['Card'])<1) {
        //     $aryErr['Card'] = "You must select at least one";
        // }

Pretty simple validation here. I think this will give you an idea how to do them. Notice that for e-mail, to make it more user friendly, I split apart the checking say if it was completely left blank or if it was just badly formatted.

URL is prepopulated with 'http://', so if it is still this, we want to set it back to empty. Then we only validate that it looks like a valid URL if something was actually entered. (There are many Regex expressions for URL's, I use this one for my basic needs of most sites someone would enter.)

        if (count($aryErr)==0) {
            /************* NO ERRORS - EMAIL, SAVE TO DB, WHATEVER! ************/
            $aryMsg = array();
            $aryMsg[] = "Someone came by and posted the following:\n"; // Only need \n for double spacing
            foreach($aryData as $key=>$val) {
                if (is_array($val)) {
                    $aryMsg[] = $key.": ";
                    if (count($val)>0) {
                        foreach($val as $valkey) $aryMsg[] = "\t".$aryCheckbox['Card'][$valkey];
                    }
                    else {
                        $aryMsg[] = "\t(none selected)";
                    }
                }
                else {
                    $aryMsg[] = $key.": ".$val;
                }
            }
            $aryMsg[] = "\nSubmitted from IP#".$_SERVER['REMOTE_ADDR'];

            $strHeader = "From: ".MAIL_FROM."\r\n";
            if (MAIL_CC!='') $strHeader .= "CC: ".MAIL_CC."\r\n";
            if (MAIL_BCC!='') $strHeader .= "BCC: ".MAIL_BCC."\r\n";
            $strHeader .= "X-Mailer: Simple PHP Former";

            // mail(MAIL_TO,MAIL_SUBJECT,implode("\n",$aryMsg),$strHeader);
        }

Ok, when we get to here, there were no errors, so we have valid data. We build the e-mail message here. Note, as I normally deal with longer mails that this, I don't use $strMail .= "whatever" over and over, as this is less efficient. I just add each new line as an array element. Note, as commented, you only need to use the \n line break where you want more than one new line.

This will output the values in the order they were on the form. If you want to change the order, then one thing you can do it back above where you originally set $aryData=array(), set the initial blank values ($aryData = array('Name'=>'','Company'=>''); that will define the order for you.

        /********** First time call, set "default" values ***************/
        $aryMsg = false;
        $aryData['Name'] = '';
        $aryData['Company'] = '';
        $aryData['Name'] = '';
        $aryData['Email'] = '';
        $aryData['URL'] = 'http://';
        $aryData['Card'] = array(); // need this instead of ''

We set the values for the first call to the page. Since we echo these out to the form as their "values", we need them. Some people will just skip it as it "works", but behind the scenes, PHP is spitting out warnings you are using a variable not already defined. Also, this lets you preset the $aryData['Card'] to be a blank array, and set URL to start with http:// If you were wanting to preselect a credit card to being with, say Visa & AMEX, you would do $aryData['Card']=array(0,3);

    // Done using values, prep for displaying on a web page
    foreach($aryData as $key=>$val) {
        if (is_string($val)) $aryData[$key] = htmlspecialchars($val);
    }

At this point, we are done with everything in $aryData except for displaying it on the page, so we need to convert it so if they enter html into a field, it doesn't get rendered by your browser. One note where this can cause a problem: If you set the keys for your checkbox items at the top, instead of just letting it be numbered automatically, you need to make sure you do not use anything in the key that htmlspecialchars will alter. If you do it will not match when checking to see if it is already selected.

    function errOut($key) {
        global $aryErr;
        if (isset($aryErr[$key])) {
            // Change the following line to output the error the way you want
            echo '<span style="color:red;">'.$aryErr[$key].'</span><br />';
        }
    }

This is the function I used to check if an error was set, if so display it. You will need to modify the code here for any special formatting you need. (this makes the actual HTML for the form below more readable, less checking repetitive code down there (Hint, this comes from code I normally use where actually displaying the inputs is from a function as well.)

Now we are on to the main page that get displayed.

$aryMsg will only be set if a form was good, so we check this if we should display a thank you or a form

$aryErr has the errors, so if it is not empty, trip there were errors. (otherwise it was the first call to the form).

We submit the form back to itself using the $_SERVER['SCRIPT_NAME'] global variable. Many tutorials show using $_SERVER['PHP_SELF'] (I started out using it 11 years ago), however there is the issue, think what happens when i browse to:

http://www.example.com/contact.php?"><iframe src="http://bad-site.com"></iframe><input name="

The outputted source code will now be:

<form action="http://www.example.com/contact.php?"><iframe src="http://bad-site.com"></iframe><input name="" method="post" >

Hmm, notice that the tag got closed by the URL I entered? and after that I started an iframe? Just a good idea to prevent anything like that!

Ok, the main guts of the form are pretty straight forward I think. until you get to here:

<?php errOut('Card'); ?>
How can you Pay?<br />
<?php
   
foreach($aryCheckbox['Card'] as $key=>$strCard) {
        echo
'<input type="checkbox" name="chkCard[]" id="chkCard_'.$key.'" value="'.$key.'" ';
        if (
in_array($key,$aryData['Card'])) echo 'checked="checked"';
        echo
'>'.htmlspecialchars($strCard)."</label><br>\n";
    }
?>

Here it will set up the checkboxes, not only saving you from typing over and over, but also handles naming the ID's correctly so the labels for attribute can match up, but also checks to see if you already had items checked and setting those. This is where the array at the top is nice, you can add/remove/reorder the list however you want without changing the code.

<fieldset style="display:none;">
    <label for="url">This field must be left blank</label>
    <input type="text" name="url" id="url" value=""/>
</fieldset>

This is the honeypot. I'm using display:none, however some people just use a positioning to kick it off the left side of the screen. The idea is for most people , they never see it, so they never put anything in it. Those that can see it, well, we hope they read labels. The form will not process with something in this field.

<input type="hidden" name="formhash" value="<?php echo dechex(time()); ?>" />

And here is the last bit of bot protection, we take the timestanp and get a hex value of it. This just keeps it from looking less like the timestamp if someone looks at the source code. I have a more complex hashing I use for numbers, but gave a basic one here.

Anyhow that about does it, if you have any comments or questions, let me know, I'm glad to help!