Laravel Daily's

Multi-Step Wizards in Inertia 3.x: Precognitive Validation for Laravel + Vue

Laravel and Vue multi-step wizard with validation checkmarks

Multi-step forms become difficult when validation, navigation, drafts, and server state start pulling in different directions.

Laravel Precognition and Inertia 3.x give each concern a clear role:

  • Laravel owns validation rules.
  • Inertia owns page visits and browser history.
  • Vue owns the visible step and field interactions.
  • useForm owns the final Inertia submission.
  • useHttp handles JSON-only checks without navigation.

The result is a wizard that feels immediate without duplicating rules in JavaScript.

This approach works especially well for onboarding, signup, checkout, profile completion, and setup flows built with a modern PHP web framework.

Laravel routes, Vue wizard steps, and Inertia validation flow

Choose the wizard boundary: routes or a step prop

There are two sensible ways to structure a wizard.

Separate routes for separate steps

Use separate routes when each step has a distinct server responsibility.

Route::get('/onboarding/account', [OnboardingController::class, 'account'])
    ->name('onboarding.account');

Route::get('/onboarding/team', [OnboardingController::class, 'team'])
    ->name('onboarding.team');

Route::get('/onboarding/preferences', [OnboardingController::class, 'preferences'])
    ->name('onboarding.preferences');

This structure makes sense when:

  • Each step has different permissions.
  • Steps load unrelated server data.
  • Users should link directly to a specific stage.
  • Each step can be completed independently.
  • Analytics need separate route events.

The trade-off is more controller and navigation code. You also need to decide whether every step saves a server-side draft.

One route with a step prop

A single route is usually simpler for an onboarding flow.

Route::get('/onboarding', [OnboardingController::class, 'show'])
    ->name('onboarding.show');

public function show(Request $request)
{
    return Inertia::render('Onboarding/Wizard', [
        'step' => (int) $request->integer('step', 1),
        'plans' => fn () => Plan::query()->visible()->get(),
        'draft' => fn () => $request->user()?->onboardingDraft,
    ]);
}

The Vue page can render the current stage from step. You can keep the step local for the fastest transitions, or use the route prop when the server must provide new data.

Use the route prop for server concerns. Do not make the server reload just to change a visual panel.

Keep one useForm instance across every step

A wizard should usually have one form object containing the complete payload.

<script setup>
import { useForm } from '@inertiajs/vue3'

const form = useForm('OnboardingWizard', {
    name: '',
    email: '',
    company: '',
    team_size: '',
    plan: '',
    preferences: [],
}).withPrecognition('post', route('onboarding.complete'))

const currentStep = ref(1)

const steps = {
    1: ['name', 'email'],
    2: ['company', 'team_size'],
    3: ['plan', 'preferences'],
}
</script>

The key gives Inertia a stable history entry for the form. When a user moves back or forward through browser history, the form data and errors can be restored with the relevant history entry.

Do not create a new form for each step. Separate form instances make it harder to submit one consistent payload. They also make error handling and draft merging more complicated.

If the form contains sensitive fields, exclude them from history state:

const form = useForm('OnboardingWizard', {
    email: '',
    password: '',
    password_confirmation: '',
}).dontRemember('password', 'password_confirmation')

Put rules in one Laravel Form Request

Precognition works best when the same request class handles live validation and the final submission.

final class CompleteOnboardingRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'email' => ['required', 'email'],
            'company' => ['required', 'string', 'max:150'],
            'team_size' => ['required', 'integer', 'min:1'],
            'plan' => ['required', 'in:starter,growth,scale'],
            'preferences' => ['array'],
            'preferences.*' => ['string'],
        ];
    }
}

Add Precognition middleware to the route:

use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;

Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])
    ->middleware(HandlePrecognitiveRequests::class)
    ->name('onboarding.complete');

Laravel can now run the request rules without executing the controller action. The validation rules remain on the server, and Vue does not need a second copy.

Read the Laravel Precognition documentation for middleware, conditional rules, file validation, and testing details.

Validate the visible step before moving forward

Precognition supports field-level validation as well as grouped validation. Group the fields by step and validate them before changing the current stage.

function nextStep() {
    form.validate({
        only: steps[currentStep.value],
        onSuccess: () => {
            currentStep.value++
            saveDraft()
        },
        onValidationError: () => {
            // Keep the user on the current step.
        },
    })
}

You can also validate fields as they change:

<input
    v-model="form.email"
    type="email"
    @change="form.validate('email')"
/>

<p v-if="form.invalid('email')">
    {{ form.errors.email }}
</p>

<span v-if="form.valid('email')">Email looks good.</span>

Validation requests are debounced. You can tune the delay when a field triggers an expensive rule.

form.setValidationTimeout(500)

Use @change or blur for most fields. Validate on every keystroke only when the feedback is useful and the request is inexpensive.

Onboarding signup wizard with three validated stages

Use useHttp for non-navigating checks

Inertia 3.x introduces useHttp for standalone HTTP requests. It does not trigger an Inertia page visit. That makes it a good fit for JSON-only validation endpoints, availability checks, and draft saves.

For example, an onboarding flow might check whether a workspace slug is available:

Route::post('/onboarding/check-slug', [OnboardingController::class, 'checkSlug'])
    ->middleware(HandlePrecognitiveRequests::class)
    ->name('onboarding.check-slug');

Use a dedicated request class if the endpoint has different rules. Reuse shared rule methods where appropriate.

const slugCheck = useHttp('OnboardingSlugCheck', {
    slug: '',
}).withPrecognition('post', route('onboarding.check-slug'))

function validateSlug(value) {
    slugCheck.slug = value
    slugCheck.validate('slug')
}

The endpoint should return JSON for normal requests:

public function checkSlug(CheckWorkspaceSlugRequest $request)
{
    return response()->json([
        'available' => ! Workspace::whereSlug($request->string('slug'))->exists(),
    ]);
}

Use useForm when the request represents the wizard’s Inertia submission. Use useHttp when the request should return JSON and leave the current page untouched.

This separation is useful across the broader Laravel ecosystem of PHP developer tools. It keeps page navigation, API requests, and validation feedback from becoming one large client-side abstraction.

Laravel and Vue validation interface with inline errors

Keep transitions fast with partial reloads

Partial reloads are useful when the next step needs fresh server data.

Suppose the plan step depends on current pricing or eligibility. Reload only those props:

function loadPlanOptions() {
    router.reload({
        only: ['plans', 'eligibility'],
        preserveState: true,
        preserveScroll: true,
        preserveErrors: true,
    })
}

On the server, return expensive props lazily:

return Inertia::render('Onboarding/Wizard', [
    'step' => $request->integer('step', 1),
    'plans' => fn () => Plan::query()->visible()->get(),
    'eligibility' => fn () => $this->eligibility->for($request->user()),
]);

Inertia merges the returned props into the existing page. Props you did not request remain in memory.

That distinction matters. Partial reloads refresh server props. They do not replace the user’s current form values automatically. Treat the form as user-owned state, and merge server draft data only when you explicitly intend to.

See the Inertia partial reload documentation for only, lazy props, and preserved errors.

Combine browser history with server-side drafts

Browser history is useful for short interruptions. Server-side drafts are better for long interruptions, multiple devices, and authenticated onboarding.

Save a draft after a successful step validation:

const draft = useHttp({
    name: '',
    email: '',
    company: '',
    team_size: '',
    plan: '',
    preferences: [],
})

function saveDraft() {
    Object.assign(draft, form.data)

    draft.patch(route('onboarding.draft'), {
        onSuccess: () => {
            // Keep the current step and form state unchanged.
        },
    })
}

On the server, scope the draft to the authenticated user or an invitation token. Store only fields that are safe to persist. Avoid treating the browser’s remembered state as authoritative.

When the page opens, hydrate missing values from the server draft. Do not overwrite fields the user has already edited in the current session.

A practical precedence order is:

  1. Current useForm values.
  2. Server-side draft values.
  3. Application defaults.

This prevents a partial reload from erasing unsaved input.

Make back and forward navigation predictable

A wizard needs a clear history policy.

If each step should have its own browser history entry, navigate with a regular GET:

function goToStep(step) {
    router.get(
        route('onboarding.show'),
        { step },
        {
            only: ['step', 'plans', 'eligibility'],
            preserveState: true,
            preserveScroll: true,
            preserveErrors: true,
        },
    )
}

The browser Back button can now return to the previous step. The form key restores the associated fields and errors.

If clicking Next should not create a history entry, use replace: true:

router.get(route('onboarding.show'), { step }, {
    replace: true,
    only: ['step'],
    preserveState: true,
})

Use router.push or router.replace for client-side history changes when the server does not need to reload props. Use router.get when the next step needs server data.

The Inertia manual visits documentation covers these navigation options in detail.

Submit once, then hand off the result

The final action should be a normal Inertia form submission.

function finish() {
    form.submit()
}

The controller can create the account, persist the onboarding record, and redirect to the next page:

public function complete(CompleteOnboardingRequest $request)
{
    $onboarding = $request->user()
        ->onboarding()
        ->create($request->validated());

    return to_route('dashboard')
        ->with('success', 'Your workspace is ready.');
}

The redirect is the handoff point. The destination page receives the shared flash data through Laravel’s Inertia middleware. The form does not need to inspect a JSON response or manually coordinate a second navigation.

Clear the server draft after the transaction succeeds. The browser history key can also be changed or discarded when the wizard is complete, so a user does not reopen a finished onboarding session with stale values.

Laravel authentication and signup interface

A useful default architecture

For most onboarding flows, start with:

  • One Inertia page.
  • One useForm instance.
  • A step prop only when server data depends on the step.
  • One Form Request for final validation.
  • form.validate({ only: [...] }) before advancing.
  • useHttp for JSON-only checks and draft persistence.
  • Partial reloads for step-specific props.
  • A remembered form key for browser history.
  • A server-side draft for durable recovery.
  • One final Inertia submission and redirect.

This gives users immediate feedback while keeping business rules in Laravel. The same foundation works if you later expose onboarding data through an API or need to build REST API with PHP.

The wizard stays a form, not a collection of disconnected screens.

Previous
Prompt Injection Defense in Laravel: Hardening AI Agents Before They Ship
Next
Provider Failover in Laravel: Keep Your AI Features Alive When OpenAI or Anthropic Goes Down