Honey Butter

Let’s Build a Website Like It’s 2002 (But Smarter) – Part 3: Contact Form & Processing

Categories

This is where we step outside the pattern we’ve used so far. We still pull in a small bit of data from JSON, but because HTML forms are so markup-heavy, it’s far more practical to write the form directly in the template. Could we define every field in JSON? Technically, yes – but that would add complexity just for the sake of saying we did it.

In this tutorial, we’ll start introducing some HTML structure that works hand-in-hand with CSS to shape and organize our form layout. We won’t go too deep just yet – we’ll really dig into this in Part 4, when we shift gears and start making the site look good.

We we’re capturing and how

After 27 years of working on provider websites, a clear pattern has emerged when it comes to contact forms. The fields below are a practical example of that pattern.

About You

  • Full Name (text field)
  • Email (email field)
  • Phone Number (tel field)
  • Best Time to Call (select menu)
  • Your Age (number field)
  • Tell Me About Yourself (textarea)

Date Details

  • City Where You’d Like to Meet (text field)
  • Day You’d Like to Meet (date field)
  • Incall or Outcall? (radio buttons)
  • Duration of Date (select menu – we populate this with data we’ve already set for the rates page)
  • Date Start Time (select menu)

Screening

  • LinkedIn Profile (url field)
  • Providers Website & Email (url fields & email fields

So we’re going to do a little layout in this html code as well as functionality. Keep in mind the second part of the tutorial with focus on styling the site.

Building the Form

Our full form code:

<?php

require __DIR__ . '/includes/functions.php';
$data = load_site_data(__DIR__ . '/data/content.json');


$pageName = 'contact';
$form  = $data['contact'] ?? [];
$rates = $data['rates'] ?? [];

include __DIR__ . '/includes/header.php';

?>

<section>
    <form action="#" method="post">
        <div class="form__section form__section--about-you">
            <h2>About You</h2>
            <!-- Full Name & Email -->
            <div class="hb-grid hb-grid--2 form__section-row">
                <div class="hb-grid__column">
                    <label for="full_name">
                        <span class="form__section-field-label"><strong>Full Name<sup>*</sup></strong></span>
                        <input type="text" name="full_name" id="full_name" required>
                    </label>
                </div>
                <div class="hb-grid__column">
                    <label for="email">
                        <span class="form__section-field-label"><strong>Email<sup>*</sup></strong></span>
                        <input type="email" name="email" id="email" required>
                    </label>
                </div>
            </div>
            <!-- Phone Number & Email -->
            <div class="hb-grid hb-grid--3 form__section-row">
                <div class="hb-grid__column">
                    <label for="phone_number">
                        <span class="form__section-field-label"><strong>Phone Number<sup>*</sup></strong></span>
                        <input type="tel" name="phone_number" id="phone_number" required>
                    </label>
                </div>
                <div class="hb-grid__column">
                    <label for="best_time_to_call">
                        <span class="form__section-field-label"><strong>Best Time to Call<sup>*</sup></strong></span>
                        <select name="best_time_to_call" id="best_time_to_call">
                            <option value="">Select...</option>
                            <option value="morning">Morning</option>
                            <option value="afternoon">Afternoon</option>
                            <option value="evening">Evening</option>
                        </select>
                    </label>
                </div>
                <div class="hb-grid__column">
                    <label for="age">
                        <span class="form__section-field-label"><strong>Your Age<sup>*</sup></strong></span>
                        <input type="number" name="age" id="age" min="18" max="100" required>
                    </label>
                </div>
            </div>
            <!-- Tell Me About Yourself -->
            <div class="hb-grid hb-grid--12 form__section-row">
                <div class="hb-grid__column hb-grid__col-span--full">
                    <label for="tell_me_about_yourself">
                        <span class="form__section-field-label"><strong>Tell Me About Yourself</strong></span>
                        <textarea name="tell_me_about_yourself" rows="3" id="tell_me_about_yourself"></textarea>
                    </label>
                </div>
            </div>
        </div>
        <div class="form__section form__section--date-details">
            <h2>Date Details</h2>
            <!-- City & Date -->
            <div class="hb-grid hb-grid--3 form__section-row">
                <div class="hb-grid__column">
                    <label for="date_city">
                        <span class="form__section-field-label"><strong>City Where You'd Like to
                                Meet<sup>*</sup></strong></span>
                        <input type="text" name="date_city" id="date_city" required>
                    </label>
                </div>
                <div class="hb-grid__column">
                    <label for="date_day">
                        <span class="form__section-field-label"><strong>Day You'd Like to
                                Meet<sup>*</sup></strong></span>
                        <input type="date" name="date_day" id="date_day" required>
                    </label>
                </div>
                <div class="hb-grid__column">
                    <span class="form__section-field-label"><strong>Incall or Outcall?<sup>*</sup></strong></span>
                    <div class="form__section-field-radio-container">
                        <label><input type="radio" name="incall_outcall" value="incall" required> Incall</label>
                        <label><input type="radio" name="incall_outcall" value="outcall" required> Outcall</label>

                    </div>

                </div>
            </div>
            <!-- Duration of Date & Date Start Time -->
            <div class="hb-grid hb-grid--2 form__section-row">
                <div class="hb-grid__column">
                    <?php 
$offerings = (!empty($rates['offerings']) && is_array($rates['offerings'])) ? $rates['offerings']: []; ?>
                    <label for="date_duration">
                        <span class="form__section-field-label">Duration of Date<sup>*</sup></span>
                        <select name="date_duration" id="date_duration" required>
                            <option value="">Select...</option>
                            <?php foreach ($offerings as $o): ?>
                            <?php
$duration = $o['duration'] ?? '';
$value    = slugify($duration);
?>
                            <option value="<?php echo e($value); ?>">
                                <?php echo e($duration); ?>
                            </option>
                            <?php endforeach; ?>
                        </select>
                    </label>
                </div>
                <div class="hb-grid__column">
                    <label for="date_time">
                        <span class="form__section-field-label">Date Start Time<sup>*</sup></span>
                        <select name="date_time" id="date_time" required>
                            <option value="">Select...</option>

                            <?php
// -----------------------------------------
// Pull time config from JSON (with fallbacks)
// -----------------------------------------
$timeCfg = (isset($form['time_picker']) && is_array($form['time_picker']))
? $form['time_picker']
: [];

$startStr    = $timeCfg['start'] ?? '10:00 AM';
$endStr      = $timeCfg['end'] ?? '11:30 PM';
$stepMinutes = isset($timeCfg['step_minutes']) ? (int)$timeCfg['step_minutes'] : 30;

// Safety: prevent zero/negative steps
if ($stepMinutes < 1) $stepMinutes = 30;

// Build DateTime / DateInterval
$start = DateTime::createFromFormat('g:i A', $startStr) ?: new DateTime('10:00 AM');
$end   = DateTime::createFromFormat('g:i A', $endStr)   ?: new DateTime('11:30 PM');
$step  = new DateInterval('PT' . $stepMinutes . 'M');

// If someone configures an end earlier than the start, fall back safely
if ($end < $start) {
$start = new DateTime('10:00 AM');
$end   = new DateTime('11:30 PM');
}

// Generate options
for ($time = clone $start; $time <= $end; $time->add($step)) {
$label = $time->format('g:i A');
echo '<option value="' . e($label) . '">' . e($label) . '</option>';
}
?>
                        </select>
                    </label>


                </div>
            </div>
        </div>
        <!-- SCREENING -->
        <div class="form__section form__section--screening">
            <h2>Screening</h2>
            <p>For screening purposes, please choose and complete one of the two options below.</p>
            <div class="hb-grid hb-grid--12 form__section-row">
                <div class="hb-grid__column hb-grid__col-span--full">
                    <label for="linkedin_profile">
                        <span class="form__section-field-label"><strong>Your LinkedIn Profile</strong></span>
                        <input type="url" placeholder="https://" name="linkedin_profile" id="linkedin_profile">
                    </label>
                </div>
                <div class="hb-grid__column hb-grid__col-span--full">

                    <span class="form__section-field-label"><strong>Providers Website & Email</strong></span>

                    <div class="hb-grid hb-grid--2 form__section-row">
                        <div class="hb-grid__column">
                            <label for="provider_1_website">
                                <span class="form__section-field-label"><em>Provider 1 Website</em></span>
                                <input type="url" placeholder="https://" name="provider_1_website"
                                    id="provider_1_website">
                            </label>
                        </div>
                        <div class="hb-grid__column">
                            <label for="provider_1_email">
                                <span class="form__section-field-label"><em>Provider 1 Email</em></span>
                                <input type="email" name="provider_1_email" id="provider_1_email">
                            </label>
                        </div>
                    </div>

                    <div class="hb-grid hb-grid--2 form__section-row">
                        <div class="hb-grid__column">
                            <label for="provider_2_website">
                                <span class="form__section-field-label"><em>Provider 2 Website</em></span>
                                <input type="url" placeholder="https://" name="provider_2_website"
                                    id="provider_2_website">
                            </label>
                        </div>
                        <div class="hb-grid__column">
                            <label for="provider_2_email">
                                <span class="form__section-field-label"><em>Provider 2 Email</em></span>
                                <input type="email" name="provider_2_email" id="provider_2_email">
                            </label>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        </div>
        <div class="form__section form__section--screening">
            <div class="hb-grid hb-grid--12 form__section-row">
                <div class="hb-grid__column hb-grid__col-span--full">
                    <button type="submit" class="form__submit">
                        <span class="form__submit-text">Submit</span>
                        <span class="form__submit-spinner" aria-hidden="true"></span>
                    </button>
                </div>
            </div>
        </div>
    </form>
</section>

<?php include __DIR__ . '/includes/footer.php'; ?>

What this page is responsible for

This is the Contact / Booking Request page template. Its job is to:

  1. Pull the pieces of data this form needs (contact config + rates offerings)
  2. Output a structured HTML form
  3. Dynamically populate select dropdowns (duration + time options)
  4. Submit the form to a separate processing script (form-processing.php)

1) Page context + pulling the form-related data

$pageName = 'contact';
$form  = $data['contact'] ?? [];
$rates = $data['rates'] ?? [];

include __DIR__ . '/includes/header.php';
  • $pageName = 'contact' identifies the current page for your shared layout (often used for active nav states or page-level settings).
  • $form = $data['contact'] ?? [] pulls the contact-page config (like your time_picker settings later).
  • $rates = $data['rates'] ?? [] pulls the rates section so you can reuse offerings as dropdown options.
  • Then you include the shared header, which prints the top of the document and navigation.

The ?? [] fallback is defensive: if the JSON key is missing, the page still loads without notices.

2) The form: where data goes and how it’s submitted

<form action="form-processing.php" method="post">
  • action="form-processing.php" means when the user submits, the browser sends the data to that PHP file.
  • method="post" sends form values in the request body (better for privacy than GET, and required for larger payloads).

In your architecture: this template displays the form; form-processing.php handles validation, sanitization, emailing/saving, etc.

3) “About You” section

About you form tut

Full Name + Email

<div class="hb-grid hb-grid--2 form__section-row">
 <div class="hb-grid__column">
  <label for="full_name">
   <span class="form__section-field-label"><strong>Full Name</strong></span>
   <input type="text" name="full_name" id="full_name" required>
  </label>
</div>
<div class="hb-grid__column">
  <label for="email">
   <span class="form__section-field-label"><strong>Email</strong></span>
   <input type="email" name="email" id="email" required>
  </label>
</div>
</div>
  • To get Full Name and Email to sit side by side, we lean on a mix of HTML structure and CSS layout (we’ll dig into the CSS part in the next tutorial).
  • First, we wrap the entire row in .hb-grid--2.form__section-row. This tells our layout system to create a two-column row.
  • Inside that row, each form field is wrapped in a .hb-grid__column div. Each column becomes one slot in the grid, allowing the fields to align neatly next to each other.
  • Each input is wrapped in a <label>. The text inside the label explains what the field is for, and the for="..." value must match the id of the input it’s associated with. This connection is important for accessibility – clicking the label focuses the input, and screen readers understand the relationship.
  • The name="..." attribute is the key that will be sent along with the form submission and show up in $_POST on the processing page.
  • The id="..." attribute links the input back to its label and also gives us a reliable hook for CSS or JavaScript if we need it later.
  • Adding required enables basic browser-level validation, preventing the form from being submitted if the field is left empty.
  • Finally, using type="email" gives us two bonuses for free: built-in email validation and a more convenient keyboard layout on mobile devices.

Purpose: collect who the person is and how to reply.

Phone Number + Best Time to Call + Age

<div class="hb-grid hb-grid--3 form__section-row">
 <div class="hb-grid__column">
  <label for="phone_number">
   <span class="form__section-field-label"><strong>Phone Number</strong></span>
   <input type="tel" name="phone_number" id="phone_number" required>
  </label>
 </div>
 <div class="hb-grid__column">
  <label for="best_time_to_call">
   <span class="form__section-field-label"><strong>Best Time to Call<sup>*</sup></strong></span>
    <select name="best_time_to_call" id="best_time_to_call">
     <option value="">Select...</option>
     <option value="morning">Morning</option>
     <option value="afternoon">Afternoon</option>
     <option value="evening">Evening</option>
    </select>
   </label>
 </div>
 <div class="hb-grid__column">
  <label for="age">
   <span class="form__section-field-label"><strong>Your Age</strong></span>
   <input type="number" name="age" id="age" min="18" max="100" required>
  </label>
 </div>
</div>
  • We’ll use .hb-grid--3 to create a 3-column layout for these fields.
  • type="tel" hints “phone keypad” on mobile; validation is usually loose (you validate server-side).
  • “Best time to call” is plain text, giving the user flexibility (e.g., “weekday evenings”).
  • Age uses numeric bounds (min="18") which supports your eligibility requirement at the browser level.

Purpose: faster coordination + screening baseline.

“Tell me about yourself” textarea

<div class="hb-grid hb-grid--12 form__section-row">
 <div class="hb-grid__column hb-grid__col-span--full">
  <label for="tell_me_about_yourself">
   <span class="form__section-field-label"><strong>Tell Me About Yourself</strong></span>
   <textarea name="tell_me_about_yourself" rows="3" id="tell_me_about_yourself"></textarea>
  </label>
 </div>
</div>
  • We’ll use .hb-grid--12 and .hb-grid__col-span--full to achieve a full width layout for this field
  • A multi-line text field for context.
  • Not marked required, so it’s optional.

Purpose: gives the provider a quick vibe check / context before replying.

4) “Date Details” section: logistics of the request

Date details form tut

City + Day

<div class="hb-grid__column">
 <label for="date_city">
  <span class="form__section-field-label"><strong>City Where You'd Like to Meet</strong></span>
   <input type="text" name="date_city" id="date_city" required>
  </label>
</div>
<div class="hb-grid__column">
 <label for="date_day">
  <span class="form__section-field-label"><strong>Day You'd Like to Meet</strong></span>
   <input type="date" name="date_day" id="date_day" required>
  </label>
</div>
  • City is plain text.
  • type="date" uses a native date picker in most browsers.

Purpose: where and when.

Incall vs Outcall (radio buttons)

<div class="hb-grid__column">
 <span class="form__section-field-label"><strong>Incall or Outcall?</strong></span>
 <div class="form__section-field-radio-container">
  <label><input type="radio" name="incall_outcall" value="incall" required> Incall</label>
  <label><input type="radio" name="incall_outcall" value="outcall" required> Outcall</label>
 </div>
</div>
  • Radio buttons share the same name, so only one can be selected.
  • required ensures they pick one option.

Purpose: clarifies the type of meeting request.

5) Dynamic dropdown: “Duration of Date” pulled from JSON rates

Date duration form tut

This part genuinely makes me happy. Earlier, I mentioned the importance of not repeating yourself in your code – and this is a perfect example of that in action. Instead of hard-coding values, we’re pulling the duration directly from our rates page data.

$offerings = (!empty($rates['offerings']) && is_array($rates['offerings'])) ? $rates['offerings'] : [];
  • Safely extracts the offerings array from the rates section.
  • Falls back to an empty array if missing or malformed.

Then the select:

<select name="date_duration" id="date_duration" required>
  <option value="">Select...</option>
  <?php foreach ($offerings as $o): ?>
    <?php
      $duration = $o['duration'] ?? '';
      $value    = slugify($duration);
    ?>
    <option value="<?php echo e($value); ?>">
      <?php echo e($duration); ?>
    </option>
  <?php endforeach; ?>
</select>

What’s happening here:

  • You loop through each offering from JSON.
  • $duration is the human-readable label (“2 hours”, “Dinner date”, etc.).
  • slugify($duration) turns that label into a clean value suitable for form submission (e.g., "2-hours").
  • e() escapes output for safety.

Purpose: you avoid duplicating “duration options” in multiple places. The Rates page and Contact form stay in sync automatically.

6) Dynamic dropdown: “Date Start Time” generated from JSON config

Date time form tut

You might remember that we added data nodes in content.json for things like available times and age range. That’s on purpose – those are the kinds of details that change, and no one wants a client poking around in a scary HTML form. Update the JSON once and you’re done.

Example from our JSON

"time_picker": {
      "start": "10:00 AM",
      "end": "11:30 PM",
      "step_minutes": 30
    }

Pull the time picker configuration from $form:

$timeCfg = (isset($form['time_picker']) && is_array($form['time_picker']))
  ? $form['time_picker']
  : [];

$startStr    = $timeCfg['start'] ?? '10:00 AM';
$endStr      = $timeCfg['end'] ?? '11:30 PM';
$stepMinutes = isset($timeCfg['step_minutes']) ? (int)$timeCfg['step_minutes'] : 30;
  • If contact.time_picker exists in JSON, use it.
  • Otherwise default to 10:00 AM → 11:30 PM in 30-minute steps.

Then it hardens the config:

if ($stepMinutes < 1) $stepMinutes = 30;
  • Prevents broken configs like 0-minute steps.

Then it creates time objects:

$start = DateTime::createFromFormat('g:i A', $startStr) ?: new DateTime('10:00 AM');
$end   = DateTime::createFromFormat('g:i A', $endStr)   ?: new DateTime('11:30 PM');
$step  = new DateInterval('PT' . $stepMinutes . 'M');
  • Builds DateTime objects for start/end.
  • Builds a DateInterval like PT30M.

Safety check if end < start:

if ($end < $start) {
  $start = new DateTime('10:00 AM');
  $end   = new DateTime('11:30 PM');
}

Finally, it generates <option> tags in a loop:

for ($time = clone $start; $time <= $end; $time->add($step)) {
  $label = $time->format('g:i A');
  echo '<option value="' . e($label) . '">' . e($label) . '</option>';
}
  • Starts at $start, adds $step each iteration, stops at $end.
  • Produces a list like: 10:00 AM, 10:30 AM, 11:00 AM, etc.
  • Uses e() to escape the label.

Purpose: the dropdown updates automatically when you change the JSON config – no template edits needed.

7) Screening section

This part collects references to help verify the request.

LinkedIn profile

Linkedin form tut
<input type="url" placeholder="https://" name="linkedin_profile" id="linkedin_profile">
  • type="url" provides browser-level URL validation.
  • Placeholder suggests the expected format.

Provider references (two providers)

Provider form tut

Each provider collects:

<input type="url" name="provider_1_website">
<input type="email" name="provider_1_email">

…and repeats for provider 2.

8) Submit button

Submit form tut
I love a fat submit button.
<button type="submit" class="form__submit">
  <span class="form__submit-text">Submit</span>
  <span class="form__submit-spinner" aria-hidden="true"></span>
</button>
  • type="submit" triggers form submission.
  • The spinner span allows us a loading state (via CSS/JS) when the user clicks the button.
  • aria-hidden="true" prevents screen readers from announcing the spinner decoration.

Big picture pattern this file demonstrates

This page is a great example of your “JSON-driven site” approach:

  • Content and configuration live in content.json
  • Templates pull a section of $data and render it
  • Dropdowns are generated from JSON so they stay in sync
  • Helper functions (e(), slugify(), safe_rich_html()) keep output safe and consistent
  • The processing logic is separated into form-processing.php

Processing the Form Data

Now that our form is capturing the information we need, the next step is making sure it actually goes somewhere useful. This is where PHP comes back into the picture – handling the form submission behind the scenes and delivering the data to the site owner’s inbox quickly and seamlessly.

What this file is for

form-processing.php is meant to be the action target for your HTML form. When the user submits the form, the browser sends a POST request to this file, and the file:

  1. Rejects non-POST requests (blocks direct visits)
  2. Loads config from JSON (age min/max)
  3. Safely reads and validates form fields
  4. Builds a plain-text email
  5. Sends the email
  6. Redirects to a thank-you page (or shows an error)

1) Strict typing + header comment

declare(strict_types=1);

This makes PHP stricter about type conversions. It helps catch bugs earlier (like accidentally passing arrays where strings are expected).

2) Block direct access (must be POST)

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  http_response_code(403);
  exit('Forbidden');
}

If someone tries to visit form-processing.php in the browser directly (a GET request), this exits immediately with a 403 Forbidden.

This is good practice: form processors shouldn’t be publicly “browsable.”


2) Load JSON config for rules (age min/max)

$configPath = __DIR__ . '/data/content.json';
$configRaw  = @file_get_contents($configPath);
  • __DIR__ = the directory this PHP file lives in.
  • It reads data/content.json.
  • The @ suppresses PHP warnings (you handle failure manually right after).

If it can’t read the file:

http_response_code(500);
exit('Server error: config file could not be loaded.');

That’s a server-side problem (500).

Then it decodes JSON:

$data = json_decode($configRaw, true);
if (!is_array($data)) { ... }

true means “decode JSON into associative arrays.”

Pulling just the contact config safely

$contactCfg = (isset($data['contact']) && is_array($data['contact'])) ? $data['contact'] : [];
$ageCfg     = (isset($contactCfg['age_picker']) && is_array($contactCfg['age_picker'])) ? $contactCfg['age_picker'] : [];

This prevents “undefined index” errors. If keys are missing, it falls back to empty arrays.

Age defaults + safety checks

$AGE_MIN = isset($ageCfg['min_age']) ? (int)$ageCfg['min_age'] : 18;
$AGE_MAX = isset($ageCfg['max_age']) ? (int)$ageCfg['max_age'] : 100;

if ($AGE_MIN < 0) $AGE_MIN = 0;
if ($AGE_MAX < $AGE_MIN) $AGE_MAX = $AGE_MIN;

So you can control age range in JSON, but the script won’t allow nonsense values.

3) Helper functions (sanitizing + reading POST safely)

This section is the “toolbelt.” The big idea: never trust raw $_POST, and normalize things before using them.

h() (HTML escaping)

function h(string $s): string {
  return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}

This converts <, >, ", ' into HTML entities.
In this file, you don’t actually use h(), because you’re not outputting HTML—you’re building an email and doing redirects/errors. But it’s a common helper to keep around.

get_post_string()

$val = filter_input(INPUT_POST, $key, FILTER_UNSAFE_RAW);
  • Reads a value from POST using PHP’s input system (instead of $_POST directly).
  • Trims whitespace.
  • If required and empty, returns null.

Then:

return str_replace(["\r\n", "\r"], "\n", $val);

This normalizes line endings. That’s mostly an email safety/consistency move.

get_post_email()

Uses FILTER_VALIDATE_EMAIL to only accept real email-ish strings. If invalid and required, returns null.

get_post_url()

  • Reads raw
  • If empty: required => null, otherwise ''
  • Validates with FILTER_VALIDATE_URL

So you don’t end up emailing junk like not-a-url.

get_post_int()

Validates integer range:

filter_var($raw, FILTER_VALIDATE_INT, [
  'options' => ['min_range' => $min, 'max_range' => $max]
]);

That’s how your age field becomes “must be between AGE_MIN and AGE_MAX.”

normalize_incall_outcall()

return in_array($val, ['incall', 'outcall'], true)
  ? ucfirst($val)
  : '';

This is a whitelist. Only those two values are accepted, and it returns nicely formatted Incall / Outcall. Anything else becomes an empty string (which later triggers “missing required field”).

fail()

function fail(string $msg, int $code = 400): void {
  http_response_code($code);
  exit($msg);
}

Your unified error exit. Great for keeping the code readable.

4) Pull + validate fields (your form schema)

This is where the script defines the “contract” of your form.

Example:

$fullName = get_post_string('full_name', true);
$email    = get_post_email('email', true);
...
$age      = get_post_int('age', $AGE_MIN, $AGE_MAX, true);
  • Every required field uses true.
  • Optional field tell_me_about_yourself uses false.

For incall/outcall:

$incallOutcall = normalize_incall_outcall(
  get_post_string('incall_outcall', true)
);

So even if the field is present, it still must match your allowed values.

Required field checks (human-friendly list)

Instead of failing at the first missing field, you collect them all:

$missing = [];
if ($fullName === null) $missing[] = 'Full Name';
...
if ($incallOutcall === '') $missing[] = 'Incall/Outcall';
...
if (!empty($missing)) {
  fail('Please complete all required fields: ' . implode(', ', $missing) . '.', 400);
}

That’s a nice UX: the user gets one message listing everything they missed.

Validate date format

if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$dateDay)) {
  fail('Please enter a valid date (YYYY-MM-DD).', 400);
}

This checks format only, not “is it a real date” (like 2026-99-99 would still match). But it’s a good baseline.

5) Email settings + building the email body

$to      = 'you@yourdomain.com';
$subject = 'New Contact Form Submission';

Then the script constructs a plain-text email as an array of lines:

$bodyLines[] = "Name: {$fullName}";
...
$body = implode("\n", $bodyLines);

This is a clean pattern because it’s easy to add/remove lines without messy string concatenation.

It also includes:

  • The optional “tell me about yourself” block only if it’s filled out
  • A “META” section with:
    • IP address
    • User agent
    • Timestamp

6) Send email + redirect

if ($mailSent) {
  header('Location: thank-you.php');
  exit;
}
fail('Sorry, something went wrong sending the message.', 500);
  • If mail succeeds, the user is redirected to a friendly page.
  • If it fails, they get a 500 error message.

7) But while we’re waiting, let’s let the user know something is happening

By default, submitting a form feels like this:

Click Submit → awkward pause → “Did it work?” → confirmation

That moment of uncertainty isn’t great UX.

To fix it, we’ll add a small loading state to the submit button. As soon as the user clicks Submit, the button visually responds and lets them know the request is in motion – hang tight, something’s happening.

This JavaScript waits for the page to finish loading, targets the contact form, and applies a “loading” class to the submit button the moment the form is submitted.

It doesn’t hijack the form or submit anything with JavaScript. The browser still handles the submission normally – we’re just adding a little reassurance during that in-between moment.

The full function

document.addEventListener('DOMContentLoaded', () => {
    
  const form = document.querySelector('form[action="form-processing.php"]');
  if (!form) return;

  const submitBtn = form.querySelector('.form__submit');

  form.addEventListener('submit', () => {
    submitBtn.classList.add('is-loading');
  });
});

1) Wait until the DOM is ready

document.addEventListener('DOMContentLoaded', () => {

This ensures the script runs after all HTML elements are available. Without this, the script could run before the form exists and fail silently.


2) Select the correct form

const form = document.querySelector('form[action="form-processing.php"]');
if (!form) return;
  • This targets only the form that submits to form-processing.php.
  • The guard clause (if (!form) return;) keeps the script safe if the form isn’t present on the page (for example, if this script is loaded globally).

3) Grab the submit button

const submitBtn = form.querySelector('.form__submit');

This selects the submit button inside the form so its state can be updated visually.


4) Listen for form submission

form.addEventListener('submit', () => {
  submitBtn.classList.add('is-loading');
});
  • As soon as the user clicks Submit, this event fires.
  • A CSS class (is-loading) is added to the button.
  • Your CSS can then:
    • hide the button text
    • show a spinner
    • disable hover states
    • or visually lock the button

Importantly, the form still submits normally — there’s no AJAX here and no interference with the browser’s default behavior.

5) Styling the spinner

Full CSS

.form__submit-spinner {
  display: none;
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

.form__submit.is-loading .form__submit-text {
  visibility: hidden;
}

.form__submit.is-loading .form__submit-spinner {
  display: inline-block;
  width: 1.25rem;
  height: 1.25rem; 
}

.form__submit.is-loading {
  pointer-events: none;
  opacity: 0.8;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

This CSS defines a spinner animation and controls how the submit button behaves when it enters a loading state. When the button gets the .is-loading class, the button text is hidden, a spinner appears in its place, and the button becomes temporarily non-interactive.

The spinner element (hidden by default)

.form__submit-spinner {
  display: none;
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

This defines the spinner itself.

  • display: none;
    The spinner is hidden by default so it doesn’t appear until the form is submitted.
  • width / height
    These set the size of the spinner. Using em ties the size to the button’s font size.
  • border: 2px solid currentColor;
    The spinner inherits the button’s text color automatically.
  • border-right-color: transparent;
    One side of the circle is invisible, creating the illusion of a spinning gap.
  • border-radius: 50%;
    Makes the element perfectly circular.
  • animation: spin ... infinite;
    Continuously rotates the spinner.

Hide the button text during loading

.form__submit.is-loading .form__submit-text {
  visibility: hidden;
}

When the submit button gets the .is-loading class:

  • The button text is hidden.
  • visibility: hidden keeps the text’s layout space intact, so the button doesn’t resize or jump.

Show and size the spinner during loading

.form__submit.is-loading .form__submit-spinner {
  display: inline-block;
  width: 1.25rem;
  height: 1.25rem; 
}

Once the button enters the loading state:

  • The spinner becomes visible.
  • Its size is slightly increased for better visibility.
  • Using inline-block allows it to sit naturally inside the button.

If you swap the CSS spinner for an SVG spinner:

  • This ensures the SVG stays white while loading.
  • It only applies during the loading state.

(This rule doesn’t hurt anything even if you’re currently using the CSS border spinner.)

Disable interaction while loading

.form__submit.is-loading {
  pointer-events: none;
  opacity: 0.8;
}

This prevents accidental double submissions:

  • pointer-events: none disables clicks and hovers.
  • opacity: 0.8 subtly dims the button to reinforce the “busy” state.

The spin animation

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

This animation rotates the spinner one full turn repeatedly, creating the loading effect.

In short

  • The spinner exists but stays hidden by default.
  • When JavaScript adds .is-loading to the button:
    • the text disappears
    • the spinner appears and animates
    • the button becomes temporarily disabled
  • When the page navigates away or reloads, the loading state naturally resets.

This is a clean, CSS-driven loading pattern that pairs perfectly with traditional HTML form submissions.

Redirect to “thank you” page

Hb tut thank you

A natural pattern when submitting a form is simple: first, reassure the user that their information was successfully sent, and second, thank them for taking the time to reach out and make it all the way through a (let’s be honest) slightly annoying form.

In this final part of the tutorial, that’s exactly what we’ll do – deliver a clear confirmation, a warm thank-you, and a little moment of delight as a reward for their effort.

So the user’s data has been survived the form-processing gauntlet and has been sent off to the site’s owner’s email inbox. Next, we’ll redirect the user to a page we’ll call thank-you.php.

 "thank_you": {
    "header": "Thank You",
    "thank_you_text": "<p class='intro'>Your information has been received</p><p>I appreciate you taking the time to connect. I'll review your details and be in touch soon if we're a good fit to move forward.</p><p>In the meantime, you're welcome to keep up with me on my social channels, where I occasionally share thoughts, travel moments, and updates. It's a lovely way to get a feel for my world while you wait.</p><p>I look forward to speaking with you soon.</p><p>— Emily</p>",
    "thank_you_img": {
    "src": "img/ev-4.jpg",
    "alt": "alt text goes here"
  }
},

The page title comes from the header value, while the actual confirmation message lives in thank_you_text, wrapped in semantic HTML so it can be styled and formatted freely. Alongside that, we include a companion image via thank_you_img, which is rendered next to the message.

You’ll notice we’re not using a featured image on this page. That’s intentional. Featured images on this site take up a lot of vertical space, and at this point in the flow the user doesn’t need a big visual hook – they’ve already converted. What they’re really looking for now is reassurance that their information was received successfully.

By removing the featured image and getting straight to the confirmation message, we keep the experience focused, calm, and respectful of the user’s time.

The full page code

<?php

require __DIR__ . '/includes/functions.php';
$data = load_site_data(__DIR__ . '/data/content.json');

$pageName = 'thank_you';
include __DIR__ . '/includes/header.php';

?>

<?php 
if (!empty($data['thank_you'])): $thank_you = $data['thank_you']; 
$text = $thank_you["thank_you_text"];
$img = $thank_you["thank_you_img"];
?>

<article class="section section--thank-you">
 <div class="hb-grid hb-grid--2 hb-grid__align--center hb-grid__gap--4">

 <div class="section__text">
  <?php echo safe_rich_html($text); ?>
 </div>

<?php if (!empty($img['src'])): ?>
 <figure class="section__image">
  <img
   src="<?php echo htmlspecialchars($img['src'], ENT_QUOTES); ?>"
   alt="<?php echo htmlspecialchars($img['alt'] ?? '', ENT_QUOTES); ?>"
   loading="lazy"
  />
 </figure>
<?php endif; ?>
   </div>
 </article>

<?php endif; ?>

<?php
// Shared footer include (copyright, links, etc.)
include __DIR__ . '/includes/footer.php';
?>

At the top of the file, we’ve got the usual setup we’ve already covered a few times, so we’ll skip over that part here. What matters next is the data coming from our JSON file, where we’ve defined the content specifically for the thank-you.php page.

1) Only render the page content if the JSON has a thank_you section

if (!empty($data['thank_you'])):

This is a guard. It prevents errors if the JSON doesn’t contain thank_you.

Inside the block, it pulls the values it needs:

$thank_you = $data['thank_you']; 
$text = $thank_you["thank_you_text"];
$img = $thank_you["thank_you_img"];

So now the template has:

  • $text → your thank-you message (likely rich HTML)
  • $img → an image array with src and alt

4) Render the “Thank You” section layout

<article class="section section--thank-you">
  <div class="hb-grid hb-grid--2 hb-grid__align--center hb-grid__gap--4">

This outputs a styled layout using your grid utility classes.

Render the text safely (rich HTML allowed)

<?php echo safe_rich_html($text); ?>

This is important:

  • Unlike htmlspecialchars(), this is meant for trusted rich HTML (like paragraphs, links, emphasis).
  • The function name suggests you’re sanitizing/allowlisting tags so you can store formatted content in JSON without risking unsafe output.

5) Render the image only if a src exists

<?php if (!empty($img['src'])): ?>

Another guard: if there’s no image configured, the page still works and just shows text.

If there is an image:

<img
  src="<?php echo htmlspecialchars($img['src'], ENT_QUOTES); ?>"
  alt="<?php echo htmlspecialchars($img['alt'] ?? '', ENT_QUOTES); ?>"
  loading="lazy"
/>

What’s happening here:

  • htmlspecialchars(...) escapes the src and alt text to prevent broken HTML and injection issues.
  • $img['alt'] ?? '' means: use the alt text if provided, otherwise default to an empty string.
  • loading="lazy" tells the browser not to load the image until it’s near the viewport (nice performance win).

6) Close the conditional + include the shared footer

<?php endif; ?>
...
include __DIR__ . '/includes/footer.php';
  • If thank_you content exists, it renders the article.
  • Either way, the footer is included so you get the consistent page wrapper (footer links, scripts, closing tags, etc.).

In plain English

This page is a “thank you” template powered by JSON & PHP:

  • It loads your site data from content.json
  • Looks for a thank_you block
  • Outputs formatted thank-you text (sanitized)
  • Optionally outputs an image (escaped and lazy-loaded)
  • Uses shared header/footer includes for consistent layout

Wrapping up

At this point, we’ve built a complete, end-to-end form experience – from the initial inputs, to thoughtful client-side feedback, to solid server-side validation, and finally a calm, reassuring confirmation.

We started with a plain HTML form, keeping things accessible and predictable. From there, we layered in just enough JavaScript to improve the experience without hijacking the browser’s natural behavior. The submit button responds immediately, the user knows something is happening, and double-submits are avoided – all without introducing unnecessary complexity.

On the server side, we kept things intentional and defensive. Every field is validated, configuration lives in JSON instead of being hard-coded, and nothing is trusted blindly. The form processor does one job well: accept clean input, send a clear notification, and redirect the user to a friendly destination.

And that final redirect matters. Instead of leaving the user in limbo, the thank-you page closes the loop – confirming success, setting expectations, and offering a moment of reassurance while the request is reviewed.

What’s next

From here on out, we’re done with PHP and JSON. The foundation is in place.

Next, it’s time to make things beautiful – layering in brand, layout, and personality with CSS (my favorite part of the process). After that, we’ll bring the experience to life with JavaScript, adding subtle interaction, motion, and polish where it actually matters.