Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Form Validation

Client-side rules, showing errors, and mapping Laravel 422 responses onto fields.

Forms are where users and data meet, so validation turns up in nearly every app. Done well it has two layers:

  • Client-side validation, for instant feedback while typing. It's a convenience: anyone can bypass it.
  • Server-side validation, which is the real rule. Here that's Laravel's StoreTaskRequest, which returns a 422 with an errors object when something is wrong.

This lesson builds both layers, then joins them so a Laravel error appears under the right field. The server examples use this site's real /api/demo/tasks/validate endpoint, which validates a task and returns the result without saving anything.

The simplest version: computed errors

Errors are derived state: given the form values, which messages apply? That makes them a natural fit for computed. Keep the values in one reactive object and the errors in a computed object with the same keys.

This works, but it shows "Enter a valid email" before the user has typed anything, which feels hostile. The next step fixes that.

Only show errors after the user has interacted

Track which fields have been touched (blurred at least once) and whether the form has been submitted. Show a field's error only if one of those is true.

novalidate turns off the browser's own validation bubbles so yours are the only messages. Leave it off if you're happy with the browser's built-in required and type="email" checks.

Reusable rule functions

Writing the same if for every form gets repetitive. A rule is just a function that returns an error message or '', so you can build a small library and list each field's rules, much like Laravel's 'title' => ['required', 'min:3'].

Libraries such as VeeValidate (with Zod or Yup schemas) and Vuelidate follow this same idea: rules per field, errors derived from values, and touched/dirty tracking. Knowing the plain version makes them easy to pick up.

Server-side validation: Laravel's 422 response

Client rules can drift from the server's, and some checks only the server can do, like "does this project exist?" (exists:projects,id). Here's the request Laravel validates. Send it as it is, then fix the body until it returns 200.

The 422 body always has the same shape: a summary message, plus errors keyed by field name, each holding an array of messages. The keys are the same field names you sent, which is what lets you put each message under its input:

{
  "message": "Pick one of the existing projects. (and 3 more errors)",
  "errors": {
    "project_id": ["Pick one of the existing projects."],
    "title": ["The title field must be at least 3 characters."],
    "priority": ["The selected priority is invalid."],
    "due_on": ["The due date cannot be in the past."]
  }
}

The rules behind it live in app/Http/Requests/StoreTaskRequest.php:

public function rules(): array
{
    return [
        'project_id' => ['required', 'integer', 'exists:projects,id'],
        'title' => ['required', 'string', 'min:3', 'max:120'],
        'description' => ['nullable', 'string', 'max:2000'],
        'priority' => ['required', Rule::enum(TaskPriority::class)],
        'due_on' => ['nullable', 'date', 'after_or_equal:today'],
        'tag_ids' => ['array'],
        'tag_ids.*' => ['integer', 'exists:tags,id'],
    ];
}

Mapping server errors onto fields

Store the server's errors in a ref, show serverErrors[field][0] under each input, and clear a field's server error as soon as the user edits it (otherwise a fixed field still looks broken).

Accessible ids with useId()

An error message only helps a screen reader user if the input points at it: <label for> needs the input's id, and aria-describedby needs the message's id. Hardcoding ids like "title-error" breaks as soon as the same form component appears twice on a page.

Vue 3.5 added useId(), which returns an id that is unique per component instance and stable between server and client rendering:

<!-- Illustrative: useId() needs Vue 3.5+, and this page runs Vue 3.4 -->
<script setup>
import { useId } from 'vue';

const id = useId();            // e.g. "v-0", different for every instance
const titleId = `${id}-title`;
const errorId = `${id}-title-error`;
</script>

<template>
    <label :for="titleId">Title</label>
    <input :id="titleId" v-model="form.title"
           :aria-invalid="!!errors.title"
           :aria-describedby="errors.title ? errorId : undefined">
    <p v-if="errors.title" :id="errorId" class="error">{{ errors.title }}</p>
</template>

On Vue 3.4 the usual workaround is a module-level counter (let next = 0, then 'field-' + ++next per instance). It works in the browser, but ids can differ between server and client in an SSR app, which is the problem useId() solves.

A complete task form

The finished form combines both layers:

  • Client rules that mirror StoreTaskRequest, shown after blur or submit.
  • Real projects loaded from the API, plus a "deleted project" option that only the server can reject.
  • Server validation through the live endpoint, with 422 errors mapped onto fields and cleared on edit.
  • Submit handling: the button is disabled while sending, and focus moves to the first invalid field.
  • Accessibility: aria-invalid and aria-describedby let screen readers announce each field's error.

Use Skip client checks to send whatever you've typed straight to Laravel and watch the server catch it.

Key takeaways

  • Validate twice: on the client for fast feedback, on the server because it's the only check that can be trusted.
  • Errors are derived from values, so compute them. Show them after touched or submitted, not on page load.
  • Small rule functions (required, min(3), oneOf([...])) compose like Laravel's rule arrays.
  • Laravel returns 422 with errors: { field: [messages] }. Map errors[field][0] under each input and clear it when that field changes.
  • Disable submit while sending, focus the first invalid field, and link messages to inputs with aria-describedby. Generate unique ids with useId() (Vue 3.5+).