Building a Multi-Step Form in October CMS 2 with jQuery, HTML5 Validation, and Centralized Saving

Building a Multi-Step Form in October CMS 2 with jQuery, HTML5 Validation, and Centralized Saving
To build a clean, maintainable user flow, we designed a multi-step form in October CMS 2 with these goals:
Keep form experience seamless via AJAX
Minimize duplication using a single controller handler
Use native HTML5 validation to prevent invalid data from reaching the server
In this article, I’ll walk through the setup using just two steps + a confirmation, and explain why we centralized the saving logic the way we did.
Why HTML5 Validation?
Using HTML5 validation means:
No custom JS required for basic required/format checks
Error display is automatic (reportValidity())
Invalid form data is blocked before any backend request
We call form.checkValidity() before running AJAX. This avoids wasted network calls and ensures real-time feedback for the user.
Remember you should still add validation on the server side including in the model creation.
Why a Single onSaveForm Handler?
When building multi-step forms, it’s tempting to write onSaveStep1, onSaveStep2, etc. But that leads to:
Repetition of shared logic
Difficult-to-maintain state flow
Extra logic for managing public_key or tracking steps
Instead, we use one AJAX handler (onSaveForm) that dispatches to private methods like handleStepOne() or handleStepTwo() based on the posted form_step.
Step 1: Step One Form
<div id="stepContainer">
<form id="multiStepForm">
<input type="hidden" name="form_id" value="form-multi-step">
<input type="hidden" name="form_step" value="1">
<input type="text" name="first_name" required>
<input type="email" name="email" required>
<button type="submit">Next</button>
</form>
</div>
This is the first step of the multi-step form. It includes:
Two user-facing fields (first_name and email) with required HTML5 validation
Hidden inputs to identify the form (form_id) and the current step (form_step)
The form will be submitted via JavaScript, not a normal HTTP post jQuery Handler: $(document).on('submit', '#multiStepForm', function (e) { e.preventDefault(); const form = this; if (!form.checkValidity()) { form.reportValidity(); return; } $.request('onSaveForm', { data: $(form).serialize(), update: { '#stepContainer': '#stepContainer' } }); }); This jQuery handler:
Intercepts the form submission (e.preventDefault())
Checks if the form is valid using HTML5 validation (checkValidity())
If valid, it sends the form data via October CMS’s $.request() method to the onSaveForm AJAX handler
Updates the #stepContainer element with the response (i.e., the next step’s HTML) Centralized Controller Method public function onSaveForm() { $formData = post(); $step = $formData['form_step'] ?? null; if ($formData['form_id'] !== 'form-multi-step') { throw new \ApplicationException("Invalid form submission."); } $record = $this->loadOrCreateRecord($formData['public_key'] ?? null); switch ($step) { case '1': return $this->handleStepOne($formData, $record); case '2': return $this->handleStepTwo($formData, $record); default: throw new \ApplicationException("Unknown step."); } }
This centralized handler:
Accepts form data from the AJAX request
Verifies that the form is recognized via form_id
Loads or creates the in-progress record using a public_key (if one exists)
Routes the request to a private step handler (handleStepOne, handleStepTwo) based on the current step
Returns a partial response with the next step’s content and state data.
Step Handler protected function handleStepOne($data, $record) { $record->first_name = $data['first_name'] ?? ''; $record->email = $data['email'] ?? ''; $record->public_key = $record->public_key ?: \Str::uuid(); $record->current_step = 2; $record->save(); return [ '#stepContainer' => $this->renderPartial('@_step2_form.htm', ['record' => $record]), 'public_key' => $record->public_key ]; }
This method:
Saves the values from Step 1 to the current record
If the record doesn’t have a public_key, it generates one (to persist progress across steps)
Advances the step state by setting current_step = 2
Returns a partial (the Step 2 form) to replace the content inside #stepContainer
Also includes public_key in the response so it can be passed to the next form submission
Loading Steps Dynamically: public function onLoadStep() { $step = post('step') ?? 1; $publicKey = post('public_key'); $record = $publicKey ? SurveyRecord::where('public_key', $publicKey)->first() : null; return [ '#stepContainer' => $this->renderPartial("@_step{$step}_form.htm", ['record' => $record]) ]; }
This method allows you to manually load a step without saving any new data. It:
Uses the posted step and public_key to look up the relevant form state
Renders the appropriate step partial (e.g., _step1_form.htm, _step2_form.htm)
Returns it to update the UI (same #stepContainer target)
By combining HTML5 validation, jQuery-powered AJAX, and a centralized save handler in October CMS 2, we’ve created a flexible and scalable structure for multi-step forms. This approach simplifies data handling, improves user experience, and makes it easier to maintain or extend the form over time.
