Creating a custom Drupal form with a multistep workflow
A long Drupal form becomes difficult to scan, slow on mobile and frustrating when one invalid field blocks the whole page. A multistep workflow fixes this by dividing a process such as an event grant application into eligibility, applicant details, project information and review, while preserving one controlled submission.
A Bendigo community organiser may need this flow to serve applicants in Sydney, Melbourne, Brisbane and Perth, whose browsers sit in different Australian time zones. The interface should use familiar Australian wording such as “mobile”, accept local date and phone formats, and avoid assuming a fast connection or a desktop-sized screen.
Drupal Form API supplies form rendering, submission handling and CSRF protection, but it does not decide the workflow’s states. Define those states in a custom module, store them securely and treat AJAX as an enhancement. Every step must still work through a direct request, a refresh or an interrupted connection.
Choosing the right form architecture
Keep rendering, workflow logic and persistence separate. A route builds an ApplicationForm, which loads an ApplicationWorkflow service, renders one step and delegates saves to a storage service. These boundaries make the form class manageable and provide useful targets for unit and kernel tests.
| State strategy | Best fit | Important constraint |
|---|---|---|
| Current form values | Short forms without resume support | A refresh can discard earlier answers |
| Drupal form-state storage or a controlled session | Short wizards with full-page steps | Expiry, concurrency and anonymous users need tests |
| Private TempStore | Drafts attached to a known account | Anonymous ownership and retention require care |
| Custom draft entity | Auditable or long-running applications | Schema, cleanup and permissions become ongoing work |
Current Drupal form-state storage suits many short wizards, while a controlled session can carry a signed draft identifier between requests. Use a custom entity when editors must inspect drafts or a process can remain open for weeks. Private TempStore is convenient for account-owned drafts, but it is not a blanket container for anonymous personal data.
Defining steps, state and validation
Describe the workflow as named states with explicit transitions: start, eligibility, applicant, project, review, submitted and expired. Store the current state on the server and treat any hidden step field as a convenience, never as authority. A user who changes a hidden value must still be limited to a state permitted for that draft.
Give every element a stable machine name, attach required fields to the step that owns them and use callbacks such as validateForm() and #element_validate for rules that HTML cannot enforce. Validate cross-step relationships before the final transition, including date ranges, budget totals and whether an organisation matches the selected applicant type.
Australian forms benefit from explicit labels for day, month and year, DD/MM/YYYY examples and grouped phone numbers using the +61 country code. A state or territory selector should use the Australian abbreviations, while free-text answers need sensible length limits. Keep the validation message beside the relevant field and provide an error summary for people using assistive technology.
Building the form and moving between steps
The buildForm() method should load the draft, derive the permitted step and build only that step’s fields. A progress indicator can be an ordered list or navigation element, but it must describe the process rather than act as the source of truth. Field labels, help text and buttons should come from Drupal strings so translators can adapt them.
The primary controls can use distinct submit handlers without changing the form ID:
$form['actions']['next'] = [
'#type' => 'submit',
'#value' => $is_last_step ? t('Submit application') : t('Continue'),
'#submit' => [$is_last_step ? '::submitApplication' : '::nextStep'],
];
After successful validation, persist the active values before advancing, then rebuild for AJAX or redirect for ordinary navigation. A Back control must not trap someone on an incomplete page; use a CSRF-protected transition that can decrease the step without imposing the next step’s rules. On the review step, show normalised values with clear edit links rather than silently changing stored answers.
Keep the final submit handler idempotent. If a browser sends the request twice, the handler should recognise the submitted draft, return the existing confirmation and avoid creating duplicate content. Queue notification mail only after the application entity or node has been stored successfully, and redirect to a confirmation route containing the draft reference.
Adding AJAX and resilient progress feedback
Add #ajax to Continue and submit controls when a smoother experience matters, keeping the same Form API validation used by the full-page path. Give the form a stable wrapper and replace that region after each transition. The callback should also update the progress indicator, document title or heading so the browser and screen reader do not announce stale context.
A useful architectural analogy comes from real-time event streams: each accepted event advances processing, while a failed event stops that unit of work without corrupting the sequence. A form step should behave similarly by advancing only after server validation and persistence both succeed.
Return validation failures as inline messages plus an error summary. After AJAX, move focus to that summary or the new step heading, depending on the outcome, and announce the result through an appropriate live region. If replacement fails, retain the ordinary form route as a working fallback rather than leaving a disabled button or a false progress state.
Saving drafts and handling failures
Save after each valid transition, not only at submission. A useful draft record contains a random public identifier, owner or anonymous-session binding, current step, normalised values, status, creation and update times, consent evidence and a version number. Store sensitive answers with restricted access, define a retention period and remove abandoned records on a schedule.
Bind each request to the draft owner, enforce route access and expire old sessions or drafts. Keep the identifier random, avoid exposing sequential IDs and revoke the draft’s editing token after submission. Optimistic locking prevents two tabs from overwriting one another; when versions conflict, preserve the submitted version and ask the user to restart rather than merge arbitrary answers.
At final submission, create the application record and mark the draft as submitted in one transaction, then queue transactional mail. Apply the Australian Privacy Principles by stating why information is collected, who can access it and how retention or correction requests are handled. Keep marketing consent separate from application acknowledgement because the Spam Act 2003 and ACMA guidance set specific consent rules for commercial electronic messages.
Testing accessibility, security and real failures
Test the flow with keyboard navigation, browser zoom, VoiceOver and NVDA. Each step needs a meaningful heading, visible focus, correctly associated labels and an announced error state. AJAX changes require managed focus, and the progress marker should use aria-current="step" only on the active item. Colour must never be the sole indicator of valid, invalid or completed work.
Assume hidden values, callbacks and draft identifiers are untrusted. Drupal Form API supplies form tokens, yet the workflow still needs ownership checks, HTTPS, secure cookies, safe session handling, output sanitisation and rate limiting. Use entity APIs or parameterised queries rather than concatenating input into SQL, and escape user content when it later appears in administrative screens or email.
Automate transition tests for skipped steps, invalid totals, expired drafts and repeated submits. Browser tests from Drupal’s test suites should cover both JavaScript and non-JavaScript paths, including refresh, Back, timeout and slow-network conditions. Include Australian dates, +61 numbers and addresses from different states so formatting assumptions fail visibly in development.
Recommendations for a dependable workflow
The following practices reduce common failures while leaving room for Drupal-specific integration:
- Define states, owners, permissions and expiry rules before designing individual fields.
- Render one step at a time and enforce every transition again on the server.
- Persist valid intermediate values with restricted access, versioning and scheduled cleanup.
- Keep a complete no-JavaScript route alongside AJAX controls and replaceable regions.
- Make errors, focus changes and progress understandable without relying on colour alone.
- Test interruption, duplicate submission, ownership changes and Australian data formats.
Treat the form as a small state machine rather than a stack of disconnected pages. Clear state ownership, repeatable validation and accessible transitions produce a workflow that remains maintainable through Drupal upgrades and changing application requirements.