How to build the custom form in CraftCMS without any plugin
Home » BLOG » Web development » How to build the custom form in CraftCMS without any plugin

How to build the custom form in CraftCMS without any plugin

category:  Web development

I have a requirement from my last project to build an appointment form and no data needs to be saved. The form will show in the modal window. Moreover, it needs to show the thank you message in the modal window as well. That means I need to use Ajax for submitting the form. Today this post, I will share with you how to build the appointment form without any plugin.

Requirements

  • An appointment form is not required to save the entry in the database.
  • An appointment form will display in the modal window.
  • An appointment form needs a simple form validation.
  • An appointment form needs to send the notification email to the administrator email and the applicant’s email that fill in on the form.
  • The receipt’s email message needs to be simple style.
  • After success sending an email, the thank you message needs to show in the same modal window.

Modal

In order to add any features the clients require, I choose to build a custom form for the appointment form. I will use Bootstrap 5 for the modal window. If you don’t use the Bootstrap5 modal, you can create your own modal using the simple code from this modal tutorial.

Modal template (twig)

Let’s create a new modal template as _appointment_modal.twig. I will use the start code from the Live demo from Bootstrap 5.

<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="exampleModalLabel">Modal title</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        ...a form will go here...
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
        <button type="button" class="btn btn-primary">Save changes</button>
      </div>
    </div>
  </div>
</div>

With Bootstrap modal, it can be triggered by data attributes or javascript. In this tutorial, we will trigger the modal via the data attribute. For performance reasons, if you can trigger the Bootstrap modal via data attributes, I suggest doing so.

Trigger the modal

We will trigger the modal by the button. We will add the button along with the data attributes that trigger the modal on the index page. In my CraftCMS, the index page uses the index.twig. So I add the button and included the modal template as shown below in the index.twig.

{# Base Layout #}
{% extends "partials/_layout.twig" %}

{% block navbar %}
    {{ include("partials/_navbar.twig") }}
{% endblock %}

{# //- Page Content #}
{% block content %}

    {# -- notification message from flash message -- #}
    {# we will remove this notification message section once we switch to Ajax #}
    <div class="container text-center mt-3">
        {% if craft.app.session.hasFlash('notice') %}
            <p class="message notice text-success">{{ craft.app.session.getFlash('notice') }}</p>
        {% elseif craft.app.session.hasFlash('error') %}
            <p class="message error text-red">{{ craft.app.session.getFlash('error') }}</p>
        {% endif %}
    </div>

    <div class="container text-center mt-5">
        {# -- appointment modal -- #}
        {{ include("components/_appointment_modal.twig") }}

        <!-- Button trigger modal -->
        <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal">
        Launch demo modal
        </button>
    </div>

{% endblock %}

{% block footer %}
     {{ include("partials/_footer.twig") }}
{% endblock %}

We still use the sample code from Bootstrap. So when you click on the button, the modal should pop up. If not, make sure you add the Bootstrap CSS and Bootstrap JS in your template following getting started.

You will notice that we add the notification message section as well. We add this section so we can check whether the email is being sent from our form or not. Sometime we may have an error. The error will throw out on this notification message section. Later on, we will remove this section and show the error via Ajax instead.

Add the form into the modal template

Next, we will add the appointment form to the modal template. Again, I will use the sample form from Bootstrap 5. Here are the fields we will add to the form.

  • Appointment date
  • Appointment time
  • Applicant name
  • Applicant email address
  • Applicant phone number

Date picker component

Since Bootstrap 5 doesn’t come with a date picker component, we will use an extra date picker library from FlatPickr. First, you will need to add flatpickr CSS and flatpickr JS to the head section of your index template. Here is their links.

Add the appointment form and fields

Here is the form we want.

{# -- Form starts -- #}
<form id="frmAppointment" method="post" accept-charset="UTF-8">
    {{ csrfInput() }}
    {# the action URL is created in module. #}
    {{ actionInput('module/sample/send-appointment') }}

        {# --- appointment form --- #}
        <div id="appointment-form">
            <p>{{ 'Thank you for contacting Apple Rinquest. I am happy to help your team to achieve the business goal. Let\'s schedule date and time then we will have a nice chat!' }}</p>

            {# spacer #}
            <div class="mt-16"></div>

            <div class="container">
                <div class="row">

                    <div class="col-md-6 ps-0 pe-md-23 pe-0">
                        <div class="pe-md-23">
                            <h2>{{ 'Select a date and time' }}</h2>

                            {# Date picker #}
                            <div class="input-group">
                                <input class="form-control date-picker rounded pe-5 d-none" 
                                    type="text" 
                                    name="appointmentDate"
                                    id="appointment-date"
                                    placeholder="{{ 'Choose date' }}" 
                                    data-datepicker-options='{"altInput": true, "altFormat": "F j, Y", "dateFormat": "j F Y", "defaultDate": "today", "minDate": "today", "inline": true, "locale": "en"}'>
                                <i class="fi-calendar position-absolute top-50 end-0 translate-middle-y me-3"></i>
                            </div>
                            
                            {# spacer #}
                            <div class="mt-13"></div>

                            {# time select box #}
                            <select class="form-select" aria-label="{{ 'Select time' }}" name="appointmentTime" id="appointment-time">
                                <option value="9:00" selected>9:00</option>
                                <option value="9:30">9:30</option>
                                <option value="10:00">10:00</option>
                                <option value="10:30">10:30</option>
                                <option value="11:00">11:00</option>
                                <option value="11:30">11:30</option>
                                <option value="12:00">12:00</option>
                                <option value="12:30">12:30</option>
                                <option value="13:00">13:00</option>
                                <option value="13:30">13:30</option>
                                <option value="14:00">14:00</option>
                                <option value="14:30">14:30</option>
                                <option value="15:00">15:00</option>
                                <option value="15:30">15:30</option>
                                <option value="16:00">16:00</option>
                                <option value="16:30">16:30</option>
                                <option value="17:00">17:00</option>
                                <option value="17:30">17:30</option>
                                <option value="18:00">18:00</option>
                                <option value="18:30">18:30</option>
                                <option value="19:00">19:00</option>
                                <option value="19:30">19:30</option>
                                <option value="20:00">20:00</option>
                                <option value="20:30">20:30</option>
                                <option value="21:00">21:00</option>
                                <option value="21:30">21:30</option>
                                <option value="22:00">22:00</option>
                                <option value="22:30">22:30</option>
                                <option value="23:00">23:00</option>
                                <option value="23:30">23:30</option>
                                <option value="0:00">0:00</option>
                            </select>                      
                        </div>
                    </div>

                    <div class="col-md-6 ps-0 ps-md-23 pe-0">
                        <div class="ps-0 ps-md-23">

                            {# spacer #}
                            <div class="mt-13 mt-md-0"></div>

                            <h2>{{ 'Contact details' }}</h2>

                            {# spacer #}
                            <div class="mt-13"></div>

                            <!-- Floating label: Text input -->
                            <div class="form-floating mb-3">
                                {{ input('text', 'fromName', '', {
                                    id: 'from-name',
                                    class: 'form-control',
                                    placeholder: "{{ 'Name' }}",
                                    required: 'required'
                                }) }}                              
                                <label for="from-name">{{ 'Name' }}</label>
                            </div>     

                            <div class="form-floating mb-3">
                                {{ input('tel', 'fromTel', '', {
                                    id: 'from-tel',
                                    class: 'form-control',
                                    placeholder: "{{ 'Phone number' }}",
                                    required: 'required'
                                }) }}                               
                                <label for="from-tel">{{ 'Phone number' }}</label>
                            </div>    

                            <div class="form-floating mb-3">
                                {{ input('email', 'fromEmail', '', {
                                    id: 'from-email',
                                    class: 'form-control',
                                    placeholder: "{{ 'E-mail address' }}",
                                    required: 'required'
                                }) }}                              
                                <label for="from-email">{{ 'E-mail address' }}</label>
                            </div>   

                            <!-- Inline radio buttons -->
                            <div class="form-check form-check-inline">
                                <input class="form-check-input" type="radio" id="tel_call" name="contactChoice" checked value="{{ 'Telephone call' }}">
                                <label class="form-check-label" for="tel_call">{{ 'Telephone call' }}</label>
                            </div>     
                            <div class="form-check form-check-inline">
                                <input class="form-check-input" type="radio" id="video_call" name="contactChoice" value="{{ 'Video call' }}">
                                <label class="form-check-label" for="video_call">{{ 'Video call' }}</label>
                            </div>   

                        </div>
                    </div>
                </div>
            </div>        

            {# spacer #}
            <div class="mt-16"></div>
        </div>    

</form>
{# -- Form ends -- #}

{% js %}
(function() {
     flatpickr("#appointment-date", {
        inline: true,
        altInput: true,
        altFormat: "j F Y",
        dateFormat: "j F Y",
        locale: "nl",
        defaultDate: "today",
        minDate: "today"
    });
})();
{% endjs %}

Hint! I basically use the contact form plugin as an example. Then changes some code to match my needs.

In the code above I use {{ ‘text’ }} to print out the static text. Because I am lazy to clean the print-out tag (twig syntax). The original code looks like {{ ‘text’|t }}. If your site supports language translation, you should use {{ ‘text’|t }}. Just let you know that you can use only the plain text you want without {{ }}.

CSRF protection

Craft has built-in protection against Cross-Site Request Forgery attacks (CSRF) that is enabled by default in Craft 3. With CSRF protection on, all POST requests must be added “CSRF token” in the form. If Craft doesn’t see the token, Craft will reject the request with a 400 error. Below is the error page you will see. At the top of the page, the error message will show up with “HTTP 400 – Bad Request – yii\web\BadRequestHttpException: It is not possible to verify the information you have provided.”.

Below is the code you will add inside the form tag. This code will render the unique CSRF token for your form. Later, the token will be checked on the submitting event. In our code above, we already add this csrfInput().

{{ csrfInput() }}

Learn more about CSRF protection.

What the appointment form looks like in the modal

A form in the Bootstrap modal
An appointment form in the Boostrap modal

Form validation

Validation from client side with default browser validation method

In this tutorial, we will use the default browser validation method from HTML5 since the required fields are name, phone number, and email. The rest of the fields have the default value. The required fields are set by the “required” attribute at the input field. In the form code above, we already have the “required” attribute in the input field.

Validation from server side

If you don’t submit the form via Ajax, you should do the form validation from the server side. The validation message should be displayed via the errorList macro.

Validation from client side with JS plugin

If you want to do the form validation via the client side and want something more efficient than the default browser validation method, you can use the jQueryValidator plugin. The plugin is very flexible and works great.

Adjust the modal and form markup

For the form markup, we see the save button is out of the form tag, and the button style is not set as the submit type. That means the save button won’t trigger the submit form event.

We will adjust the code a bit. We will move the form tag over the modal-content div tag. This way, the form tag will wrap the form and the save button. Then we change the save button type to the submit type. Below is the code.

<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
  <div class="modal-dialog modal-lg">

    {# -- Form starts -- #}
    <form id="frmAppointment" method="post" accept-charset="UTF-8">
        {{ csrfInput() }}
        {{ actionInput('yb-shop-module/yb/send-appointment') }}  {# the action URL is created in module. #}

        {# -- modal content: START -- #}
        <div class="modal-content">
            <div class="modal-header px-4">
                <h5 class="modal-title" id="exampleModalLabel">Modal title</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            {# -- modal-body: START -- #}
            <div class="modal-body px-5">

                {# --- appointment form --- #}
                <div id="appointment-form">
                    <p>{{ 'Thank you for contacting Apple Rinquest. I am happy to help your team to achieve the business goal. Let\'s schedule date and time then we will have a nice chat!' }}</p>

                    {# spacer #}
                    <div class="mt-16"></div>

                    <div class="container">
                        <div class="row">

                            <div class="col-md-6 ps-0 pe-md-23 pe-0">
                                <div class="pe-md-23">
                                    <h2>{{ 'Select a date and time' }}</h2>

                                    {# Date picker #}
                                    <div class="input-group">
                                        <input class="form-control date-picker rounded pe-5 d-none" 
                                            type="text" 
                                            name="appointmentDate"
                                            id="appointment-date"
                                            placeholder="{{ 'Choose date' }}" 
                                            data-datepicker-options='{"altInput": true, "altFormat": "F j, Y", "dateFormat": "j F Y", "defaultDate": "today", "minDate": "today", "inline": true, "locale": "en"}'>
                                        <i class="fi-calendar position-absolute top-50 end-0 translate-middle-y me-3"></i>
                                    </div>
                                    
                                    {# spacer #}
                                    <div class="mt-13"></div>

                                    {# time select box #}
                                    <select class="form-select" aria-label="{{ 'Select time' }}" name="appointmentTime" id="appointment-time">
                                        <option value="9:00" selected>9:00</option>
                                        <option value="9:30">9:30</option>
                                        <option value="10:00">10:00</option>
                                        <option value="10:30">10:30</option>
                                        <option value="11:00">11:00</option>
                                        <option value="11:30">11:30</option>
                                        <option value="12:00">12:00</option>
                                        <option value="12:30">12:30</option>
                                        <option value="13:00">13:00</option>
                                        <option value="13:30">13:30</option>
                                        <option value="14:00">14:00</option>
                                        <option value="14:30">14:30</option>
                                        <option value="15:00">15:00</option>
                                        <option value="15:30">15:30</option>
                                        <option value="16:00">16:00</option>
                                        <option value="16:30">16:30</option>
                                        <option value="17:00">17:00</option>
                                        <option value="17:30">17:30</option>
                                        <option value="18:00">18:00</option>
                                        <option value="18:30">18:30</option>
                                        <option value="19:00">19:00</option>
                                        <option value="19:30">19:30</option>
                                        <option value="20:00">20:00</option>
                                        <option value="20:30">20:30</option>
                                        <option value="21:00">21:00</option>
                                        <option value="21:30">21:30</option>
                                        <option value="22:00">22:00</option>
                                        <option value="22:30">22:30</option>
                                        <option value="23:00">23:00</option>
                                        <option value="23:30">23:30</option>
                                        <option value="0:00">0:00</option>
                                    </select>                      
                                </div>
                            </div>

                            <div class="col-md-6 ps-0 ps-md-23 pe-0">
                                <div class="ps-0 ps-md-23">

                                    {# spacer #}
                                    <div class="mt-13 mt-md-0"></div>

                                    <h2>{{ 'Contact details' }}</h2>

                                    {# spacer #}
                                    <div class="mt-13"></div>

                                    <!-- Floating label: Text input -->
                                    <div class="form-floating mb-3">
                                        {{ input('text', 'fromName', '', {
                                            id: 'from-name',
                                            class: 'form-control',
                                            placeholder: "{{ 'Name' }}",
                                            required: 'required'
                                        }) }}                              
                                        <label for="from-name">{{ 'Name' }}</label>
                                    </div>     

                                    <div class="form-floating mb-3">
                                        {{ input('tel', 'fromTel', '', {
                                            id: 'from-tel',
                                            class: 'form-control',
                                            placeholder: "{{ 'Phone number' }}",
                                            required: 'required'
                                        }) }}                               
                                        <label for="from-tel">{{ 'Phone number' }}</label>
                                    </div>    

                                    <div class="form-floating mb-3">
                                        {{ input('email', 'fromEmail', '', {
                                            id: 'from-email',
                                            class: 'form-control',
                                            placeholder: "{{ 'E-mail address' }}",
                                            required: 'required'
                                        }) }}                              
                                        <label for="from-email">{{ 'E-mail address' }}</label>
                                    </div>   

                                    <!-- Inline radio buttons -->
                                    <div class="form-check form-check-inline">
                                        <input class="form-check-input" type="radio" id="tel_call" name="contactChoice" checked value="{{ 'Telephone call' }}">
                                        <label class="form-check-label" for="tel_call">{{ 'Telephone call' }}</label>
                                    </div>     
                                    <div class="form-check form-check-inline">
                                        <input class="form-check-input" type="radio" id="video_call" name="contactChoice" value="{{ 'Video call' }}">
                                        <label class="form-check-label" for="video_call">{{ 'Video call' }}</label>
                                    </div>   

                                </div>
                            </div>
                        </div>
                    </div>        

                    {# spacer #}
                    <div class="mt-16"></div>
                </div>     

            </div>
            {# -- modal-body: END -- #}

            <div class="modal-footer px-4">
                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
                <button type="submit" class="btn btn-primary">Save changes</button>
            </div>  
        </div>
        {# -- modal content: END -- #}            
    </form>
    {# -- Form ends -- #}

  </div>
</div>

{% js %}
(function() {
     flatpickr("#appointment-date", {
        inline: true,
        altInput: true,
        altFormat: "j F Y",
        dateFormat: "j F Y",
        locale: "nl",
        defaultDate: "today",
        minDate: "today"
    });
})();
{% endjs %}

Now, refresh your browser and click on the save button. You should see the form validation message in the name field. That means the save button triggers the form submission event.

Alternatively, you can submit the form using Javascript if you like. I like to make less code as much as possible. So I can maintain the code or customize the code easier.

Create a new module

For the form tag, we need to supply the action attribute which tells the form where the request will send to. We will build our custom action in the module.

As you know the CraftCMS is built on top of Yii2. First, you will set up your own module and learn more about modules from the Yii2 doc. I don’t cover the module tutorial here because it is simple to create and set up. The available document is very clear and easy to follow. I leave the links below.

In my module, I create the controller name “sample“. My controller file is “SampleController.php“. I create the action calls “sendAppoitment“. So my code will look like this.

<?php

// https://craftcms.com/docs/3.x/extend/controllers.html

namespace modules\controllers;

use Craft;
use craft\helpers\UrlHelper;
use craft\web\Controller;
use craft\web\Request;
use craft\web\View;
use yii\web\BadRequestHttpException;
use yii\web\HttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use craft\mail\Message;
use yii\helpers\Html;

class SampleController extends Controller 
{

    /**
     * @inheritdoc
     * 
     * allow anonymouse to access the action
     */
    protected $allowAnonymous = [
        'send-appointment' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE,
    ];

    /**
     * An appointment form
     * 
     * @return Response|null
     */
    public function actionSendAppointment()
    {
      ...we will add our code here...
    }

} // class ends

$allowAnonymous

At the beginning of the class, we grant access permission for the send-appointment action to everyone so they can access our send-appointment action. Otherwise, only the logging-in user can access the action. That means nobody can submit the form without login-in. We don’t want that. We want everyone can submit the form, so we add our send-appointment action in the $allowAnonymous array.

If you forget to add the control access to the action, the guest user will see the “HTTP 403 – Forbidden – yii\web\ForbiddenHttpException” error message on the screen when they try to submit the form.

SendAppointment action

Now is a fun part, we will add the code for sending the email with the entry from the form. Below is the code.

    /**
     * An appointment form
     * 
     * @return Response|null
     */
    public function actionSendAppointment()
    {
        // # requirePostRequest:
        // https://craftcms.com/docs/3.x/extend/controllers.html#request-validation-methods
        // https://docs.craftcms.com/api/v3/craft-web-controller.html#method-requirepostrequest
        // Desc: Throws a 400 error if this isn’t a POST request
        // 
        // Form: https://craftcms.com/knowledge-base/enabling-csrf-protection
        $this->requirePostRequest();

        // get the request
        $request = Craft::$app->getRequest();

        // Form data
        $submission = New \stdClass();
        $submission->appointmentDate = $request->getBodyParam('appointmentDate');     
        $submission->appointmentTime = $request->getBodyParam('appointmentTime');     
        $submission->fromName = $request->getBodyParam('fromName');
        $submission->fromTel = $request->getBodyParam('fromTel');   
        $submission->fromEmail = $request->getBodyParam('fromEmail');        
        $submission->contactChoice = $request->getBodyParam('contactChoice');     
        $submission->subject = Craft::t('site','Schedule an appointment');


        // -- Get the system admin email --
        // 
        $settings = Craft::$app->projectConfig->get('email');
        // debug:
        // \var_dump($settings);
        // exit;

        // Using a default Mailer from CraftCMS (PHP based)
        $mail = Craft::$app->getMailer();

        // https://docs.craftcms.com/api/v3/craft-mail-message.html
        // Message class represents an email message.
        $message = new Message(); 

        // Recipients
        $message->setFrom([$settings['fromEmail'] => $settings['fromName']]);  // Add a recipient, Name is optional
        $message->setTo([
            $settings['fromEmail'] => $settings['fromName'], 
            $submission->fromEmail => $submission->fromName
        ]);
        $message->setReplyTo([$submission->fromEmail => $submission->fromName]);
        // $message->setCc([$submission->fromEmail => $submission->fromName]);
        // $message->setBcc([$settings['fromEmail'] => $settings['fromName']]);           

        // Content        
        $message->setSubject($submission->subject);    
        $content = '
            <ul>
                <li><b>'. Craft::t('site','Appointment date') . ': </b>' . $submission->appointmentDate .'</li>
                <li><b>'. Craft::t('site','Appointment time') . ': </b>' . $submission->appointmentTime .'</li>
                <li><b>'. Craft::t('site','Name') . ': </b>' . $submission->fromName .'</li>
                <li><b>'. Craft::t('site','Phone number') . ': </b>' . $submission->fromTel .'</li>
                <li><b>'. Craft::t('site','E-mail address') . ': </b>' . $submission->fromEmail .'</li>
                <li><b>'. Craft::t('site','Preferred contact method') . ': </b>' . $submission->contactChoice .'</li>
            </ul>
            <p>'.$settings['fromName'].'</p>
        ';           
        $message->setHtmlBody($content);        
        $message->setTextBody($content); 
    

        // Sending an email:
        // https://www.yiiframework.com/doc/api/2.0/yii-mail-basemailer#send()-detail
        // 
        // Flash message:
        // https://www.yiiframework.com/doc/guide/2.0/en/runtime-sessions-cookies#flash-data
        if(!$mail->send($message)) {
            // Fail
            // if ($request->getAcceptsJson()) {
            //     return $this->asJson([
            //         'errors' => Craft::t('site', 'There was a problem with your submission, please check the form and try again!')
            //     ]);
            // }

            // ----- Custom error message will show in Flash message in CraftCMS ----- //
            Craft::$app->getSession()->setError(Craft::t('contact-form', 'There was a problem with your submission, please check the form and try again!'));
            /** @var UrlManager $urlManager */
            $urlManager = Craft::$app->getUrlManager();
            $urlManager->setRouteParams([
                'variables' => ['message' => $submission],
            ]);
            // ----------------------------------------------------------------------- //

            return null;
        }

        // Success
        // if ($request->getAcceptsJson()) {
        //     return $this->asJson([
        //         'success' => true
        //     ]);
        // }
        
        // ----- Custom message will show in Flash message in CraftCMS ----- //
        Craft::$app->getSession()->setNotice('Success Sent!');
        // ----------------------------------------------------------------------- //

        // Note: since we use the appointment form in the modal and submit the form via ajax. we don't need to redirect
        return $this->redirectToPostedUrl($submission);

    }  

Hint!Craft::t() is a text translation function in CraftCMS.

Explanation

First, the submit event will check the CSRF token. If no CSRF token is found, the 400 error will throw out on the screen. You don’t need to write the CSRF token checking. Craft does it for you.

Next, in the action, we check the POST request with “$this->requirePostRequest()“. If the request is not POST, the 400 error will be thrown out.

After that, we get the form data and assign it to the submission object. You don’t need to use the object I believe. You can use the array variable or the single variable. It is up to you.

Then, we get the system admin email which will be used as the “From email” parameter in the mailer function. Where are the email settings in CraftCMS? In the control panel, go to “Settings>Email>Email Settings“.

Hint! You can print out the value from any variables in the module using PHP var_dump() and then exit() to terminate the current script. So after exit(), the script doesn’t continue.

Next, we use the Message class in CraftCMS for sending the email. So we add the form data as well as other attributes to the message class such as subject, setForm, setTo, and so on. Notice that, we use the Mailer class as well and we call the Mailer class before creating the new Message class. Later on, we will call Mailer send method and pass the message class that we create. You can send the email by calling $message->send() if you want. But I prefer this setup. If you are wondering what is different between mailer and message classes, check this post.

After that, we send the message. For now, we submit the form without Ajax. So we will return the success or fail message via the flash message. Later, we will change it to return as a JSON string when we change to submit the form via Ajax.

The last line is redirected to the redirectInput setting at the form tag. If no Url is set, Craft will redirect to the same Url you are on.

Test by submitting the form (without Ajax)

Now you will test the form by filling in the data and submitting the form. After submitting the form, you should see the “Success Sent!” message on the index.twig. Otherwise, you should see the error message on the index.twig. Remember we add the notification message on the index.twig. The message comes from the flash message we set in our controller.

Error or No email is sent

Below is the checklist. If none of them solves your issue, I suggest asking at StackExchange.com.

  • check the email configuration in CraftCMS, especially the password. On Craft email configuration, there is a test button so try it.
  • Why Doesn’t Craft Send Emails?
  • check “$message->setFrom” that you use the email with your domain or not. If not, you may try to change the email address using your domain. For example, your domain is abc.com. You should use “info@abc.com” as “$message->setFrom“.
  • Use the system email address (Craft::$app->projectConfig->get(’email’)) as “$message->setFrom“. The system email address is at the Craft email configuration.

Submit the form via Ajax

After sending the email is successful, we will change the code to submit the form via Ajax. In order to use Ajax, we need to include the jQuery library in our template.

The modal template

At the modal template, we will add the JS code below at the end of the template.

{% js %}
{# ---- submit an appointment form ---- #}
{# Note: Currently no entry is saved to database #}
$('#frmAppointment').submit(function(ev) {
    // Prevent the form from actually submitting
    ev.preventDefault();

    // get a form instand
    var formData = new FormData($(this)[0]);

    // Send it to the server
    $.ajax('/', {
        url:  '/',
        data: formData,
        type: 'POST',
        dataType: 'json',  // dataType should be json
        async: false,
        cache: false,
        contentType: false,
        processData: false,
        success: function(response) {
            if (response.success) {

                console.log('success');

                // hide the form
                //$('#appointment-form').hide();
                //$('#appointment-footer').hide();
                //$('#appointment-fail').hide();

                // show thank you message
                //$('#appointment-thankyou').show();

                // reset the form
                $('#frmAppointment')[0].reset();

            } else {

                console.log(response.errors);

                // show fail message
                //$('#appointment-fail').show();

                // hide the form and thank you message
                //$('#appointment-form').hide();
                //$('#appointment-footer').hide();
                //$('#appointment-thankyou').hide();
            }
        }
    });
});
{% endjs %}

For the Ajax call, we send the form data object, post request, and JSON data type. In the SendAppointment action in our module, it will return the success status as JSON. If there is an error, it will return the error message as JSON as well.

SendAppointment action

Next, we need to change the code to send out JSON data instead of the flash message. We will change the code in the SendAppointment action in our module. Because we don’t use the flash message anymore, you will delete the notification message section with the flash message on the index.twig.

Below is the code we change in the SendAppointment action.

if(!$mail->send($message)) {
    // Fail
    if ($request->getAcceptsJson()) {
        return $this->asJson([
            'errors' => Craft::t('site', 'There was a problem with your submission, please check the form and try again!')
        ]);
    }

    // ----- Custom error message will show in Flash message in CraftCMS ----- //
    // Craft::$app->getSession()->setError(Craft::t('contact-form', 'There was a problem with your submission, please check the form and try again!'));
    // /** @var UrlManager $urlManager */
    // $urlManager = Craft::$app->getUrlManager();
    // $urlManager->setRouteParams([
    //     'variables' => ['message' => $submission],
    // ]);
    // ----------------------------------------------------------------------- //

    return null;
}

// Success
if ($request->getAcceptsJson()) {
    return $this->asJson([
        'success' => true
    ]);
}

// ----- Custom message will show in Flash message in CraftCMS ----- //
// Craft::$app->getSession()->setNotice('Success Sent!');
// ----------------------------------------------------------------------- //

// Note: since we use the appointment form in the modal and submit the form via ajax. we don't need to redirect
// return $this->redirectToPostedUrl($submission);

Test by submitting a form via Ajax

Before submitting the form, let’s open the Chrome browser inspection and click on the Network tab. We will check the POST request from our form and see what it will return back from the request in the Preview tab of our request.

After submitting the form, you should see the JSON data return true (success: true). We did the test without Ajax and had success before. So changing to Ajax, the sending email should work. If not, you should check the errors that return back. The error may come from your ajax setting in the modal template.

Thank you message

If sending the email is successful, you will see the modal still be there and the form is reset. But we want to show the thank you message in the modal. To do that, we will add the thank you section.

Below is the thank you section. We add it in the modal template at the modal-body div tag.

{# --- Thank you message --- #}
<div id="appointment-thankyou" style="display:none;">
    <div class="d-flex align-items-center" style="height: 71vh;">
        <div class="position-relative" style="bottom: 1.5rem;">
            <h1 class="text-gray-800 text-center px-lg-20">{{ 'Thank you for making the appointment.'|t }}</h1>
            {# spacer #}
            <div class="mt-14"></div>
            <div class="text-center">
                <button type="button" class="btn btn-primary btn-sm rounded-pill h2" data-bs-dismiss="modal" aria-label="Close">{{ 'Close window'|t }}</button>
            </div>
        </div>
    </div>
</div>

Friendly warning message

If something unexpected occurs, Craft will send the error back from the request. But we don’t want to show the technical message to the user. So we will add the friendly warning message section in the modal template at the modal-body div tag. The code is below.

{# --- Error/warning message --- #}
<div id="appointment-fail" style="display:none;">
    <div class="d-flex align-items-center">
        <div class="position-relative" style="bottom: 1.5rem;">
            <h1 class="text-warning text-center px-lg-20">{{ 'There was a problem with your submission, please check the form and try again!'|t }}</h1>
            {# spacer #}
            <div class="mt-14"></div>
            <div class="text-center">
                <button type="button" class="btn btn-primary btn-sm rounded-pill h2" data-bs-dismiss="modal" aria-label="Close">{{ 'Close window'|t }}</button>
            </div>
        </div>
    </div>
</div>

Hide the submit button when thanking you message shows up

We want to hide the submit button when the thank you message shows up. To do that, we add the “appointment-footer” id at the modal-footer div tag like below. Then we can hide the modal footer via JS code.

<div class="modal-footer px-4" id="appointment-footer">
      <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
      <button type="submit" class="btn btn-primary">Save changes</button>
</div>  

Adjust the JS code

Next, we will adjust the JS code for showing or hiding the thank you message, submit button, and friendly warning message. The code looks like this.

if (response.success) {
    //console.log('success');

    // hide the form
    $('#appointment-form').hide();
    $('#appointment-footer').hide();
    $('#appointment-fail').hide();

    // show thank you message
    $('#appointment-thankyou').show();

    // reset the form
    $('#frmAppointment')[0].reset();

} else {
    //console.log(response.errors);

    // show fail message
    $('#appointment-fail').show();

    // hide the form and thank you message
    $('#appointment-form').hide();
    $('#appointment-footer').hide();
    $('#appointment-thankyou').hide();
}

Now, you submit the form again, you will see the thank you message in the modal and the submit button will be hidden as shown below.

And that’s all for building the custom form in CraftCMS.

Final source code

Index.twig

{# Base Layout #}
{% extends "partials/_layout.twig" %}

{% block navbar %}
    {{ include("partials/_navbar.twig") }}
{% endblock %}

{# //- Page Content #}
{% block content %}
   
    <div class="container text-center mt-5">
        {# -- appointment modal -- #}
        {{ include("components/_appointment_modal.twig") }}

        <!-- Button trigger modal -->
        <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal">
        Launch demo modal
        </button>
    </div>  

{% endblock %}

{% block footer %}
	{{ include("partials/_footer.twig") }}
{% endblock %}

_appointment_modal.twig

<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
  <div class="modal-dialog modal-lg">

    {# -- Form starts -- #}
    <form id="frmAppointment" method="post" accept-charset="UTF-8">
        {{ csrfInput() }}
        {{ actionInput('yb-shop-module/yb/send-appointment') }}  {# the action URL is created in module. #}

        {# -- modal content: START -- #}
        <div class="modal-content">
            <div class="modal-header px-4">
                <h5 class="modal-title" id="exampleModalLabel">Modal title</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            {# -- modal-body: START -- #}
            <div class="modal-body px-5">

                {# --- appointment form --- #}
                <div id="appointment-form">
                    <p>{{ 'Thank you for contacting Apple Rinquest. I am happy to help your team to achieve the business goal. Let\'s schedule date and time then we will have a nice chat!' }}</p>

                    {# spacer #}
                    <div class="mt-16"></div>

                    <div class="container">
                        <div class="row">

                            <div class="col-md-6 ps-0 pe-md-23 pe-0">
                                <div class="pe-md-23">
                                    <h2>{{ 'Select a date and time' }}</h2>

                                    {# Date picker #}
                                    <div class="input-group">
                                        <input class="form-control date-picker rounded pe-5 d-none" 
                                            type="text" 
                                            name="appointmentDate"
                                            id="appointment-date"
                                            placeholder="{{ 'Choose date' }}" 
                                            data-datepicker-options='{"altInput": true, "altFormat": "F j, Y", "dateFormat": "j F Y", "defaultDate": "today", "minDate": "today", "inline": true, "locale": "en"}'>
                                        <i class="fi-calendar position-absolute top-50 end-0 translate-middle-y me-3"></i>
                                    </div>
                                    
                                    {# spacer #}
                                    <div class="mt-13"></div>

                                    {# time select box #}
                                    <select class="form-select" aria-label="{{ 'Select time' }}" name="appointmentTime" id="appointment-time">
                                        <option value="9:00" selected>9:00</option>
                                        <option value="9:30">9:30</option>
                                        <option value="10:00">10:00</option>
                                        <option value="10:30">10:30</option>
                                        <option value="11:00">11:00</option>
                                        <option value="11:30">11:30</option>
                                        <option value="12:00">12:00</option>
                                        <option value="12:30">12:30</option>
                                        <option value="13:00">13:00</option>
                                        <option value="13:30">13:30</option>
                                        <option value="14:00">14:00</option>
                                        <option value="14:30">14:30</option>
                                        <option value="15:00">15:00</option>
                                        <option value="15:30">15:30</option>
                                        <option value="16:00">16:00</option>
                                        <option value="16:30">16:30</option>
                                        <option value="17:00">17:00</option>
                                        <option value="17:30">17:30</option>
                                        <option value="18:00">18:00</option>
                                        <option value="18:30">18:30</option>
                                        <option value="19:00">19:00</option>
                                        <option value="19:30">19:30</option>
                                        <option value="20:00">20:00</option>
                                        <option value="20:30">20:30</option>
                                        <option value="21:00">21:00</option>
                                        <option value="21:30">21:30</option>
                                        <option value="22:00">22:00</option>
                                        <option value="22:30">22:30</option>
                                        <option value="23:00">23:00</option>
                                        <option value="23:30">23:30</option>
                                        <option value="0:00">0:00</option>
                                    </select>                      
                                </div>
                            </div>

                            <div class="col-md-6 ps-0 ps-md-23 pe-0">
                                <div class="ps-0 ps-md-23">

                                    {# spacer #}
                                    <div class="mt-13 mt-md-0"></div>

                                    <h2>{{ 'Contact details' }}</h2>

                                    {# spacer #}
                                    <div class="mt-13"></div>

                                    <!-- Floating label: Text input -->
                                    <div class="form-floating mb-3">
                                        {{ input('text', 'fromName', '', {
                                            id: 'from-name',
                                            class: 'form-control',
                                            placeholder: "{{ 'Name' }}",
                                            required: 'required'
                                        }) }}                              
                                        <label for="from-name">{{ 'Name' }}</label>
                                    </div>     

                                    <div class="form-floating mb-3">
                                        {{ input('tel', 'fromTel', '', {
                                            id: 'from-tel',
                                            class: 'form-control',
                                            placeholder: "{{ 'Phone number' }}",
                                            required: 'required'
                                        }) }}                               
                                        <label for="from-tel">{{ 'Phone number' }}</label>
                                    </div>    

                                    <div class="form-floating mb-3">
                                        {{ input('email', 'fromEmail', '', {
                                            id: 'from-email',
                                            class: 'form-control',
                                            placeholder: "{{ 'E-mail address' }}",
                                            required: 'required'
                                        }) }}                              
                                        <label for="from-email">{{ 'E-mail address' }}</label>
                                    </div>   

                                    <!-- Inline radio buttons -->
                                    <div class="form-check form-check-inline">
                                        <input class="form-check-input" type="radio" id="tel_call" name="contactChoice" checked value="{{ 'Telephone call' }}">
                                        <label class="form-check-label" for="tel_call">{{ 'Telephone call' }}</label>
                                    </div>     
                                    <div class="form-check form-check-inline">
                                        <input class="form-check-input" type="radio" id="video_call" name="contactChoice" value="{{ 'Video call' }}">
                                        <label class="form-check-label" for="video_call">{{ 'Video call' }}</label>
                                    </div>   

                                </div>
                            </div>
                        </div>
                    </div>        

                    {# spacer #}
                    <div class="mt-16"></div>
                </div>     

                {# --- Thank you message --- #}
                <div id="appointment-thankyou" style="display:none;">
                    <div class="d-flex align-items-center" style="height: 71vh;">
                        <div class="position-relative" style="bottom: 1.5rem;">
                            <h1 class="text-gray-800 text-center px-lg-20">{{ 'Thank you for making the appointment.'|t }}</h1>
                            {# spacer #}
                            <div class="mt-14"></div>
                            <div class="text-center">
                                <button type="button" class="btn btn-primary btn-sm rounded-pill h2" data-bs-dismiss="modal" aria-label="Close">{{ 'Close window'|t }}</button>
                            </div>
                        </div>
                    </div>
                </div>            
                
                {# --- Error/warning message --- #}
                <div id="appointment-fail" style="display:none;">
                    <div class="d-flex align-items-center">
                        <div class="position-relative" style="bottom: 1.5rem;">
                            <h1 class="text-warning text-center px-lg-20">{{ 'There was a problem with your submission, please check the form and try again!'|t }}</h1>
                            {# spacer #}
                            <div class="mt-14"></div>
                            <div class="text-center">
                                <button type="button" class="btn btn-primary btn-sm rounded-pill h2" data-bs-dismiss="modal" aria-label="Close">{{ 'Close window'|t }}</button>
                            </div>
                        </div>
                    </div>
                </div>             

            </div>
            {# -- modal-body: END -- #}

            <div class="modal-footer px-4" id="appointment-footer">
                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
                <button type="submit" class="btn btn-primary">Save changes</button>
            </div>  
        </div>
        {# -- modal content: END -- #}            
    </form>
    {# -- Form ends -- #}

  </div>
</div>

{% js %}
(function() {
     flatpickr("#appointment-date", {
        inline: true,
        altInput: true,
        altFormat: "j F Y",
        dateFormat: "j F Y",
        locale: "nl",
        defaultDate: "today",
        minDate: "today"
    });
})();
{% endjs %}

{% js %}
{# ---- submit an appointment form ---- #}
{# Note: Currently no entry is saved to database #}
$('#frmAppointment').submit(function(ev) {
    // Prevent the form from actually submitting
    ev.preventDefault();

    // get a form instance
    var formData = new FormData($(this)[0]);

    // Send it to the server
    $.ajax('/', {
        url:  '/',
        data: formData,
        type: 'POST',
        dataType: 'json',  // dataType should be json
        async: false,
        cache: false,
        contentType: false,
        processData: false,
        success: function(response) {
            if (response.success) {
                //console.log('success');

                // hide the form
                $('#appointment-form').hide();
                $('#appointment-footer').hide();
                $('#appointment-fail').hide();

                // show thank you message
                $('#appointment-thankyou').show();

                // reset the form
                $('#frmAppointment')[0].reset();

            } else {
                //console.log(response.errors);

                // show fail message
                $('#appointment-fail').show();

                // hide the form and thank you message
                $('#appointment-form').hide();
                $('#appointment-footer').hide();
                $('#appointment-thankyou').hide();
            }
        }
    });
});    
{% endjs %}

SendAppointment action in the module

    /**
     * An appointment form
     * 
     * @return Response|null
     */
    public function actionSendAppointment()
    {
        // # requirePostRequest:
        // https://craftcms.com/docs/3.x/extend/controllers.html#request-validation-methods
        // https://docs.craftcms.com/api/v3/craft-web-controller.html#method-requirepostrequest
        // Desc: Throws a 400 error if this isn’t a POST request
        // 
        // Form: https://craftcms.com/knowledge-base/enabling-csrf-protection
        $this->requirePostRequest();

        // get the request
        $request = Craft::$app->getRequest();

        // Form data
        $submission = New \stdClass();
        $submission->appointmentDate = $request->getBodyParam('appointmentDate');     
        $submission->appointmentTime = $request->getBodyParam('appointmentTime');     
        $submission->fromName = $request->getBodyParam('fromName');
        $submission->fromTel = $request->getBodyParam('fromTel');   
        $submission->fromEmail = $request->getBodyParam('fromEmail');        
        $submission->contactChoice = $request->getBodyParam('contactChoice');     
        $submission->subject = Craft::t('site','Schedule an appointment');


        // -- Get the system admin email --
        // 
        $settings = Craft::$app->projectConfig->get('email');
        // debug:
        // \var_dump($settings);
        // exit;

        // Using a default Mailer from CraftCMS (PHP based)
        $mail = Craft::$app->getMailer();

        // https://docs.craftcms.com/api/v3/craft-mail-message.html
        // Message class represents an email message.
        $message = new Message();     
        
        // Recipients
        $message->setFrom([$settings['fromEmail'] => $settings['fromName']]);  // Add a recipient, Name is optional
        $message->setTo([
            $settings['fromEmail'] => $settings['fromName'], 
            $submission->fromEmail => $submission->fromName
        ]);
        $message->setReplyTo([$submission->fromEmail => $submission->fromName]);
        // $message->setCc([$submission->fromEmail => $submission->fromName]);
        // $message->setBcc([$settings['fromEmail'] => $settings['fromName']]);        

        // Content        
        $message->setSubject($submission->subject);    
        $content = '
            <ul>
                <li><b>'. Craft::t('site','Appointment date') . ': </b>' . $submission->appointmentDate .'</li>
                <li><b>'. Craft::t('site','Appointment time') . ': </b>' . $submission->appointmentTime .'</li>
                <li><b>'. Craft::t('site','Name') . ': </b>' . $submission->fromName .'</li>
                <li><b>'. Craft::t('site','Phone number') . ': </b>' . $submission->fromTel .'</li>
                <li><b>'. Craft::t('site','E-mail address') . ': </b>' . $submission->fromEmail .'</li>
                <li><b>'. Craft::t('site','Preferred contact method') . ': </b>' . $submission->contactChoice .'</li>
            </ul>
            <p>'.$settings['fromName'].'</p>
        ';           
        $message->setHtmlBody($content);        
        $message->setTextBody($content); 
    

        // Sending an email:
        // https://www.yiiframework.com/doc/api/2.0/yii-mail-basemailer#send()-detail
        // 
        // Flash message:
        // https://www.yiiframework.com/doc/guide/2.0/en/runtime-sessions-cookies#flash-data
        if(!$mail->send($message)) {
            // Fail
            if ($request->getAcceptsJson()) {
                return $this->asJson([
                    'errors' => Craft::t('site', 'There was a problem with your submission, please check the form and try again!')
                ]);
            }

            // ----- Custom error message will show in Flash message in CraftCMS ----- //
            // Craft::$app->getSession()->setError(Craft::t('contact-form', 'There was a problem with your submission, please check the form and try again!'));
            // /** @var UrlManager $urlManager */
            // $urlManager = Craft::$app->getUrlManager();
            // $urlManager->setRouteParams([
            //     'variables' => ['message' => $submission],
            // ]);
            // ----------------------------------------------------------------------- //

            return null;
        }

        // Success
        if ($request->getAcceptsJson()) {
            return $this->asJson([
                'success' => true
            ]);
        }
        
        // ----- Custom message will show in Flash message in CraftCMS ----- //
        // Craft::$app->getSession()->setNotice('Success Sent!');
        // ----------------------------------------------------------------------- //

        // Note: since we use the appointment form in the modal and submit the form via ajax. we don't need to redirect
        // return $this->redirectToPostedUrl($submission);

    }    
}

FlatPickr plugin – Trip

If you want the date picker component shows only the working dates only. You can use the JS code below.

{% js %}
(function() {
    // Get the next working day
    function getNextWorkDay(date) {
        let d = new Date(+date);
        let day = d.getDay() || 7;
        d.setDate(d.getDate() + (day > 4? 8 - day : 1));

        return d;
    }

    flatpickr("#appointment-date", {
        inline: true,
        altInput: true,
        altFormat: "j F Y",
        dateFormat: "j F Y",
        locale: "nl",
        defaultDate: getNextWorkDay(new Date()),
        minDate: getNextWorkDay(new Date()),
        disable: [
            function(date) {
                return (date.getDay() === 0 || date.getDay() === 6);
            }
        ]
    });
})();
{% endjs %}

Wrap up

Apart from building your own custom form, you can use the free plugins such as Contact Form and Contact Form Extensions (require the Contact Form plugin). You can also use the paid plugins that are available in the plugin store.

Hope my post is useful and saves you time. If so, please consider buying me a coffee. Next post, I will share how to use our custom email template when sending the email from our custom form.