Modelless Forms With Ajax & JSON in CakePHP 3.4

Earlier this week, I needed to implement a simple contact form on a project’s landing page. Since I didn’t want the form to interact with a database, this was the perfect opportunity for what CakePHP calls a ‘modelless form’.

This tutorial describes how to implement a modelless form in CakePHP 3.4 using Ajax and JSON to communicate between the client and server.

(In a follow-up to this tutorial, I take the project one step further by Integrating the SparkPost API.)


Table of Contents

  1. Starting Assumptions
  2. Bake a Contact Form
  3. Bake a Mailer Class for the Form
  4. Bake a Form Controller With No Actions
  5. Create the Contact Form View
  6. Create the Ajax Script
  7. Implement and Test
  8. Conclusion

1: Starting Assumptions

Before starting, be sure the following is true:

  • CakePHP 3.4 is installed for your project, and you are familiar with how it works
  • A ‘typical’ email transfer has been implemented and tested (i.e. SMTP, Debug, etc)
  • jQuery is installed for your project
  • You are comfortable working with PHP, Javascript, jQuery, and HTML
  • You are familiar with command-line basics *

* Command-line instructions are shown for Linux Ubuntu 16.04. You may need to make adjustments if you are using a different OS, or if you prefer to use an IDE or GUI-based text-editor (i.e SublimeText).


2: Bake a Contact Form

At the command line, go to the site’s root directory and enter the following to bake a new form:

bin/Cake bake Form Contact

By default, CakePHP saves the new form as src/Form/ContactForm.php. Open the file, and you should see something like this:

<?php
namespace App\Form;

use Cake\Form\Form;
use Cake\Form\Schema;
use Cake\Validation\Validator;

class ContactForm extends Form
{
    protected function _buildSchema(Schema $schema)
    {
        return $schema;
    }

    protected function _buildValidator(Validator $validator)
    {
        return $validator;
    }

    protected function _execute(array $data)
    {
        return true;
    }
}

This file includes three magic function signatures to use as a starting point for coding. CakePHP will run these functions automatically when creating, validating, and executing the form.

Let’s start adding some code to this class. First, add the following two namespaces at the top of the file:

use Cake\Log\LogTrait;
use Cake\Mailer\MailerAwareTrait;

We’ll be using both of these shortly.

Now replace the return statement in the _buildSchema function with the following:

return $schema->addField('name', 'string')
            ->addField('email', ['type' => 'email'])
            ->addField('phone', ['type' => 'tel'])
            ->addField('message', ['type' => 'text']);

This bit of code tells CakePHP’s FormHelper about the inputs the form needs to include.

Next, add the following validators to the _buildValidator function, just above the return statement:

// 'name' field
$validator
    ->requirePresence('name',[
        'message' => __('Please provide your name'),
        ])
    ->notBlank('name',__('Name cannot be blank'))
    ->add('name', [
        'minlength' => [
            'rule' => ['minLength', 2],
            'message' => __('Name must be at least 2 characters long')
            ],
        'maxlength' => [
            'rule' => ['maxLength', 50],
            'message' => __('Name cannot be more than 50 characters long')
            ]
        ]);
// 'email' field
$validator
    ->requirePresence('email',[
        'message' => __('Please provide your email address'),
        ])
    ->notBlank('email',__('Email address cannot be blank'))
    ->add('email', 'format', [
        'rule' => 'email',
        'message' => __('Please provide a valid email address')
        ]);
// 'phone' field
$validator
    ->allowEmpty('phone')
    ->add('phone', [
        'minlength' => [
            'rule' => ['minLength', 7],
            'message' => __('Your phone# must be at least 7 characters long'),
            'on' => function ($context) {return !empty($context['data']['phone']);} // conditional on presence
            ],
        'maxlength' => [
            'rule' => ['maxLength', 30],
            'message' => __('Your phone# cannot be more than 30 characters long')
            ]
        ]);
// 'message' field
$validator
    ->requirePresence('message',[
        'message' => __('Please include your message'),
        ])
    ->notBlank('name',__('Your message cannot be blank'))
    ->add('message', [
        'minlength' => [
            'rule' => ['minLength', 4],
            'message' => __('Your message must be at least 4 characters long')
            ],
        'maxlength' => [
            'rule' => ['maxLength', 2048],
            'message' => __('Your message cannot be more than 2048 characters long')
            ]
        ]);

Basically, this bit of code takes care of the validations that would normally be found in a Model class. These validators help CakePHP’s FormHelper set up the form properly, and are used to validate whatever data the form sends when submitted.

Last, add the following statements in the _execute function above the return statement:

$this->getMailer('ContactUs')->send('submission',[$data]);

$this->log('Someone just sent us a contact message', 'info');

The first statement uses the Mailer to send the form, the second takes care of logging the event.


3: Bake a Mailer Class For the Form

At the command line, go to the site’s root directory and enter the following to bake a new Mailer class dedicated to the form:

bin/Cake bake mailer ContactUs

By default, CakePHP saves the new Mailer as src/Mailer/ContactUsMailer.php. Open the file, and you should see something like this:

<?php
namespace App\Mailer;

use Cake\Mailer\Mailer;

class ContactUsMailer extends Mailer
{
    static public $name = 'ContactUs';
}

Now add the following public function to the class:

public function submission(array $data)
{
    $this
        ->setReplyTo($data['email'], $data['name'])
        ->setSubject($data['name'].' just sent us a message')
        ->set(['content' => (array_key_exists('phone',$data) && $data['phone'] != "") ? '<p>Phone#: '.$data['phone'].'</p>'.$data['message'] : $data['message']])
        ->setTemplate('default')
        ->setLayout('default')
        ->setEmailFormat('both');
}

This new Mailer class sets up the email submission defaults needed to post the message. The mailer doesn’t actually send the email – that happens in the controller.


4: Bake a Form Controller With No Actions

At the command line, go to the site’s root directory and enter the following to bake a new controller without any actions:

bin/Cake bake controller Contacts --no-actions

By default, CakePHP saves the new form as src/Controller/ContactsController.php. Open the file, and you should see something like this:

<?php
namespace App\Controller;

use App\Controller\AppController;

class ContactsController extends AppController
{
}

Once again, this file serves as a starting point for coding. First, add the following namespaces at the top of the file:

use App\Form\ContactForm;
use Cake\Validation\Validator;

Here, we take a step back from the suggested approach in the CakePHP documentation to follow a slightly different path. Instead of creating an index() function, create a function called contactUs() by adding the following public function inside the ContactsController class:

public function contactUs()
{
    $this->autoRender = 'false';
    $this->viewBuilder()->layout('ajax'); // src/Template/Layout/ajax.ctp
    
    // Disallow direct access via browser URL
    $this->request->allowMethod('ajax');

    $mailsent = [
        'status'=>'error'
        ,'message'=>__('Your message could not be sent')
        ,'alert'=>'danger'];

    $form = new ContactForm();

    if ($this->request->is('post')) 
    {
        if ($form->execute($this->request->data)) 
        {
            $mailsent = [
                'status'=>'sent'
                ,'message'=>__('Your message has been sent')
                ,'alert'=>'success'];
        }
        else 
        {
            $errors = $form->errors();

            if (count($errors) > 0) 
            {
                $mailsent['message'] .= ' &mdash; ' . __('Please correct any errors and try again.');

                foreach ($errors as $key => $value) 
                {
                    $mailsent['errors'][$key] = $value;
                }
            }
        }
    } // this is not a post request (error condition)

    $mailsent = json_encode($mailsent, JSON_FORCE_OBJECT);
    $this->set(compact('mailsent'));
}

This function will only be callable via Ajax, and contains the code needed to process the form. It returns a status code, a message, and potentially an array of error objects, all in a JSON format.


5: Create the Contact Form View

At the command line, go to the site’s src/Template directory and enter the following to create a new folder for the view:

mkdir Contacts

Once again, we depart from the suggested approach here. The documentation calls for creating a src/Templates/Contacts/index.ctp page to contain the form. Instead, go into the src/Template/Contacts folder and enter the following to create a new view file that CakePHP can match up with the contactUs() controller function:

touch contact_us.ctp

Open the file, and add the following:

<?= $mailsent ?>

The controller will use this file to pass Ajax responses back to the caller.

Also, before moving on, make sure ownership and permissions are set correctly on both the folder and the file.

Next, embed the form into whatever view file you want. In my case, I placed the form on a website landing page. Here’s the code to generate the form:

<?= $this->Form->create($form,['url'=>['controller'=>'Contacts','action'=>'contactUs'],'id'=>'contactForm','class'=>'async','role'=>'form','name'=>'sentMessage']); ?>
    <?= $this->Form->control('name',['id'=>'name','placeholder'=>'Your Name (required)']); ?>
    <?= $this->Form->control('email',['id'=>'email','placeholder'=>'Your Email (required)']); ?>
    <?= $this->Form->control('phone',['id'=>'phone','placeholder'=>'Your Phone # (optional)']); ?>
    <?= $this->Form->control('message',['id'=>'message','placeholder'=>'Your Message (required)']); ?>
    <div id="responseMessage"></div>
    <?= $this->Form->button('Send Message', ['type' => 'submit','class'=>'btn btn-xl submit']);?>
<?= $this->Form->end() ?>

This code will tell CakePHP everything it needs to know in order to generate an Ajax-ready form inside the view.


6: Create the Ajax Script

Next step is to create the Ajax script that will handle the to-and-fro between client and server. At the command line, go the folder in the site’s webroot where the .js files are stored and enter the following to create the file:

touch ajax.js

Again, make sure ownership and permissions are set correctly, then include this file in your site’s layout template:

<?= $this->Html->script('/js/ajax.js') ?>

Adjust this as needed to target the correct folder for your project. Also, ajax.ctp will use jQuery, so make sure this is included as well.

Now open ajax.js and add the following script:

$(function() {
    "use strict";

    var contentTarget = $("div#responseMessage");

    // handle form submissions
    $(".async").submit(function(e)
    {
        // prevent the form from submitting 
        e.preventDefault();

        // set a convenience variable for the form
        var form = $(this);

        // disable the submit button to prevent repeated clicks
        form.find('.submit').prop('disabled', true);

        // create and post a temporary spinner
        var spinner = '<div class="alert alert-info" role="alert" style="text-align: center;">';
        spinner += '<p class="lead"><span class="fa fa-spinner fa-spin"></span></p>';
        spinner += '</div>';
        contentTarget.html(spinner);

        // create a text string of form variables in standard URL-encoded notation
        var data = form.serialize(); 

        // set up the Ajax submission defaults
        var type = "POST";
        var url = form.attr('action');

        // submit the form
        makeAjaxCall(data,type,url,form);
    });

    // makeAjaxCall function
    function makeAjaxCall(data,type,url,form)
    {
        $.ajax({
            type    : type,
            url     : url,
            data    : data,
            cache    : false,
        }).done(successFunction)
          .fail(failFunction)
          .always(alwaysFunction(form));
    }

    // success function (when the server responds)
    function successFunction(data,textStatus,jqXHR) 
    {
        // create a JS object from the returned string
        var jsonData = JSON.parse(data); // best practice: use native JS instead of jQuery

        // create the server response message
        var message = '<div class="alert alert-'+jsonData.alert+' alert-dismissible" role="alert">';
        message += '<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>';
        message += '<p class="lead">'+jsonData.message+'</p>';
        message += '</div>';

        // process the server response
         if (jsonData.status == 'sent')
         {
            // reset the form fields back to their defaults
            $(".async")[0].reset(); // best practice: using [0] will return the native html element, not the DOM element
        }
        else // aka. status is 'error'
        {
            /**
             * @TODO: on (re)submit, clear any old error messages that were appended to the inputs
             */
            // append error messages to matching inputs 
            $.each(jsonData.errors, function(input,error)
                {
                    $.each(error, function(type,message)
                        {
                            var notice = '<p class="help-block text-danger">'+type+' : '+message+'</p>';
                            var errorTarget = "#"+input;
                            $(errorTarget).parent().append(notice);
                        });
                });
        }
        // post the server response message
        contentTarget.html(message);
    }

    // fail function (when Ajax fails to make the connection to the server)
    function failFunction(request, textStatus, errorThrown) 
    {
        // create & post the 'failed request' message
        var message = '<div class="alert alert-danger alert-dismissible" role="alert">';
        message += '<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>';
        message += '<p class="lead">OOPS! An error occurred during your request: ' + request.status + ' ' + textStatus + ': ' + errorThrown+'</p>';
        message += '</div>';
        // post the fail message
        contentTarget.html(message);
    }

    // always function, executes regardless of success or failure
    function alwaysFunction(form) 
    {
        // re-enable the submit button
        form.find('.submit').prop('disabled', false);
    }
});

Note, the alert messages in this script are marked up to work with Bootstrap 3 and FontAwesome 4. Adjust these as needed for your project.

Also, look closely at this script and you’ll notice it can handle any form with a class of .async, not just the contact form!


7: Implement and Test

At this point, the coding is done. All that remains is to implement and test what you’ve done.

Note that I purposely avoided using a specific testing regime in this tutorial. This is because just about everyone has their own approach and toolset for testing. Simply adjust the instructions provided to suit your preferred methodology.


Conclusion

Assuming all has gone well, you should now have in hand a simple, robust, and highly-performant contact form that uses Ajax, jQuery, and JSON to leverage many of CakePHP’s powerful new features.


Next Steps…

In a follow-up to this tutorial, I take the project one step further by Integrating the SparkPost API With CakePHP 3.4.

Leave a Reply

Your email address will not be published. Required fields are marked *