Building accessible multistep forms with Laravel and Inertia

A long form becomes much harder when someone has to complete it in difficult circumstances. When I built the EDR forms application, breaking the process into sections mattered as much as the technology behind it. People needed clear questions, a way to save their work and confidence that moving between pages would not lose their answers.

The application uses Laravel, React and Inertia. Its forms have separate routes, server validation, saved records and controls for continuing, going back and saving for later. This tutorial takes that architecture and reduces it to a small example, with explicit accessibility handling. The examples are adaptations, not a copy of the production system or a claim that the entire application meets an accessibility standard.

Implementation notes reviewed: 30 September 2026. The examples below are adapted for this tutorial.

What we are building

Our example has three steps: contact details, request details and review. Laravel owns the saved draft. React handles the current form, and Inertia connects the two without requiring a separate JSON API.

I prefer this to keeping every answer in one large component. Each section has a clear purpose, its own URL and a manageable set of validation rules. A saved draft can also survive closing the browser, provided the person can sign back in and access it.

Before you start

This approach targets Laravel 12, Inertia 2 and React 19, matching the project I worked from. You will need an existing authenticated Laravel application, the Inertia Laravel and React adapters, and an Application model with an ownership policy. For the example, assume nullable contact_name, email and request_details columns, plus a status column that starts as draft.

The model, migrations, policy and page routes are application scaffolding rather than supplied code here. The policy must restrict drafts to their owner and prevent updates after submission. Every GET, update and submit route needs authentication and authorisation. A hidden field or an unguessable record ID cannot replace that.

1. Save each step on the server

In EDR, the controller authorises the record, validates the section and updates selected fields. I use the same separation here. This abbreviated controller action handles the contact step, including saving an unfinished draft.

use App\Models\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;

public function updateContact(Request $request, Application $application)
{
    Gate::authorize('update', $application);

    $intent = $request->validate([
        'intent' => ['required', Rule::in(['continue', 'back', 'save'])],
    ])['intent'];

    // Draft saves allow unfinished answers. Continue is stricter.
    $required = $intent === 'continue' ? 'required' : 'nullable';
    $emailRules = [$required, 'string', 'max:255'];
    if ($intent === 'continue') {
        $emailRules[] = 'email';
    }

    $validated = $request->validate([
        'contact_name' => [$required, 'string', 'max:255'],
        'email' => $emailRules,
    ]);

    $application->update($validated);

    $destination = match ($intent) {
        'continue' => 'applications.details',
        'back' => 'applications.index',
        default => 'applications.contact',
    };

    return redirect()->route($destination,
        $intent === 'back' ? [] : ['application' => $application]);
}

Register that action on an authenticated PUT route such as /applications/{application}/contact. The corresponding GET action authorises access and renders the contact page with only the draft properties that page needs.

There is a deliberate distinction between saving and continuing. An incomplete email should not prevent someone saving and leaving. However, drafts still have type and size limits, and only validated fields reach the model. Unexpected fields such as user_id or status are never copied from the request.

Use the same approach for request details. Keep route destinations on the server rather than accepting a redirect URL from the browser. Laravel handles validation failures through its normal redirect flow; the Laravel validation documentation covers the rules and response behaviour.

2. Give the step clear structure

Progress does not need to be an animated graphic. A short ordered list, a useful page title and a heading explain where someone is. This follows the approach in W3C’s guidance for multipage forms.

<Head title="Step 1 of 3: Contact details" />
<nav aria-label="Application progress">
  <ol>
    <li aria-current="step">Contact details (current step)</li>
    <li>Request details</li>
    <li>Review and submit</li>
  </ol>
</nav>

Future steps are text, not disabled links. If you allow navigation to completed steps, save the current draft before leaving or explicitly explain what remains unsaved. Navigation should not quietly discard work.

3. Connect labels, hints and errors

A placeholder disappears while typing, so every field needs a visible label. Error messages also need a relationship to the affected control. In this contact page, the error summary provides an overview, while each input describes its own hint and error.

import { Head, useForm } from '@inertiajs/react';
import { useEffect, useRef, type FormEvent } from 'react';

type Draft = { id: number; contact_name: string | null; email: string | null };
const fields = ['contact_name', 'email'] as const;
const labels = { contact_name: 'Your name', email: 'Email address' };

export default function Contact({ application }: { application: Draft }) {
  const heading = useRef<HTMLHeadingElement>(null);
  const summary = useRef<HTMLDivElement>(null);
  const form = useForm({
    contact_name: application.contact_name ?? '',
    email: application.email ?? '',
  });

  useEffect(() => { heading.current?.focus(); }, [application.id]);

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const button = (event.nativeEvent as SubmitEvent).submitter;
    const intent = button instanceof HTMLButtonElement ? button.value : 'continue';
    form.transform(data => ({ ...data, intent }));
    form.put(`/applications/${application.id}/contact`, {
      preserveScroll: true,
      onError: () => requestAnimationFrame(() => summary.current?.focus()),
      onFinish: () => form.transform(data => data),
    });
  }

  return (
    <main>
      <Head title="Step 1 of 3: Contact details" />
      <h1 ref={heading} tabIndex={-1}>Contact details: step 1 of 3</h1>
      <form onSubmit={submit} noValidate aria-busy={form.processing}>
        {form.hasErrors && (
          <div ref={summary} tabIndex={-1} aria-labelledby="error-heading">
            <h2 id="error-heading">Check your contact details</h2>
            <ul>{fields.filter(field => form.errors[field]).map(field => (
              <li key={field}>
                <a href={`#${field}`}>{form.errors[field]}</a>
              </li>
            ))}</ul>
          </div>
        )}
        <fieldset>
          <legend>Your contact details</legend>
          {fields.map(field => (
            <div key={field}>
              <label htmlFor={field}>{labels[field]} (required to continue)</label>
              <p id={`${field}-hint`}>
                {field === 'email' ? 'Use an address you can access.' : 'Enter your full name.'}
              </p>
              <input id={field} name={field} required
                type={field === 'email' ? 'email' : 'text'}
                autoComplete={field === 'email' ? 'email' : 'name'}
                value={form.data[field]}
                onChange={event => form.setData(field, event.target.value)}
                aria-invalid={Boolean(form.errors[field]) || undefined}
                aria-describedby={`${field}-hint${form.errors[field] ? ` ${field}-error` : ''}`}
              />
              {form.errors[field] && <p id={`${field}-error`}>{form.errors[field]}</p>}
            </div>
          ))}
        </fieldset>
        <button type="submit" value="continue" disabled={form.processing}>Save and continue</button>
        <button type="submit" value="save" disabled={form.processing}>Save draft</button>
        <button type="submit" value="back" disabled={form.processing}>Save and back to applications</button>
        <p role="status">{form.processing ? 'Saving your answers...' : form.recentlySuccessful ? 'Answers saved.' : ''}</p>
      </form>
    </main>
  );
}

Place the progress list above the heading. Continue is the first submit button in the DOM, so pressing Enter in a text field continues rather than saving and leaving. Keep that visual order too. Use ordinary CSS to give inputs, buttons and links a clearly visible focus indicator. The fieldset and legend group related questions, following W3C’s grouping guidance. Radio groups on later steps need their own short legend, individual labels and a shared name.

4. Manage focus after navigation and validation

Changing an Inertia page does not behave exactly like loading a fresh document. Each new step should update the title and move focus to its heading. The example does that when the contact page mounts. Apply the same pattern to the other page components.

When server validation fails, focus moves to the error summary after React renders it. It contains links to the invalid fields, so someone can understand and correct the problem. An alternative is focusing the first invalid field, which I have used in the EDR permissions section. Choose a consistent behaviour and test it with a screen reader. W3C’s notification guidance explains both overall and inline feedback.

Inertia’s Laravel adapter shares redirected validation errors, and useForm exposes them through form.errors. Input state is preserved on these requests. There is no need to write a separate handler for a JSON 422 response in this flow. See the Inertia 2 validation documentation.

5. Review everything before final submission

The review page should show the saved answers and provide clearly labelled edit links. Final submission then validates the complete saved draft, even if individual steps were checked earlier.

use Illuminate\Support\Facades\Validator;

Gate::authorize('update', $application);
$complete = Validator::make($application->fresh()->only([
    'contact_name', 'email', 'request_details',
]), [
    'contact_name' => ['required', 'string', 'max:255'],
    'email' => ['required', 'email', 'max:255'],
    'request_details' => ['required', 'string', 'max:4000'],
]);

if ($complete->fails()) {
    return redirect()->route('applications.review', $application)
        ->withErrors($complete);
}

$application->update(['status' => 'submitted']);
return redirect()->route('applications.complete', $application);

The review page needs its own error summary linking each error to the relevant edit page, and focus handling when errors arrive. For applications with concurrent editing, wrap the final check and state transition in a transaction with appropriate locking. Treat repeated submissions as a separate backend concern. Disabling the button prevents accidental clicks, but does not provide that protection.

Things to watch

Authorise every route, including review and confirmation. Session expiry needs a clear sign-in route that does not imply unsaved answers were stored. For final submission, make the state transition idempotent: retries should return the existing result, rather than creating duplicate entries or sending notifications twice. The abbreviated controller above does not implement that machinery.

Check the behaviour

  • Complete every step using only the keyboard, including error links and back navigation.
  • Press Enter in each contact input. Confirm it uses Save and continue, never Back or Save draft.
  • Check labels, descriptions, progress and focus with a screen reader.
  • Submit empty and malformed values. Confirm clear errors and retained answers.
  • Save a partial draft, sign out, sign back in and confirm it resumes correctly.
  • Test another user’s draft and direct navigation to review. Verify authorisation and final validation.
  • Check narrow screens, zoom, connection failures, expired sessions and repeated submissions.

I see accessibility here as part of making the form dependable. The most useful work is often quite plain: clear wording, predictable navigation, honest feedback and answers that remain saved when someone needs a break.

References

More about me