🚨

LaravelのFormRequestのバリデーションと紐づいたVueのエラーフィードバックコンポーネントを作成する

前書き

LaravelのFormRequestクラスを使ったバリデーションは簡単かつ柔軟に書くことができる。

またバリデーションメッセージも欲しいものは概ね揃っており大変便利。

そして現在のLaravelはInertiaによりSPAのVue(やReact)フロントエンドとシームレスにデータをやり取りすることが出来る。

それでこんなことをやりたくなる

  • フロントエンド側のフォームにLaravelバックエンドからのバリデーションエラーをユーザーにフィードバック表示させるコンポーネントを作りたい

  • フォームから投げるデータのフィールドはTypeScriptで型定義されているので、エラーコンポーネントはインテリセンスを利かせたい。

普通にやるとこんな感じになると思う。

  • errorsの型がerrors: { [key: string]: string },

  • エラー表示コンポーネント

<script setup>
const props = defineProps(['error'])
</script>

<template>
  <p v-if="error" class="text-sm font-bold text-red-600">
    {{ error }}
  </p>
</template>

要はエラーのフィードバックをいちいち返ってきたデータのフィールド指定して一つ一つ文字列を入れていくような作業をしたくない。

どっちみちFormRequest内で、フォームから投げるデータのキーを指定してバリデーションを利かせており、バリエーションエラーがあった場合、その投げるデータ構造と同じデータ構造でjsonとしてバリデーションメッセージが返ってくるのだから、そのままエラーフィードバックのコンポーネントに突っ込みたい。

ここで一例として「入庫」データをフォームで入力してもらうようなアプリケーションを例として挙げる。

FormRequestでバリデーションを作成する

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;

class InStockInfoRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return Auth::check();
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'produce_user_id'   =>  ['required', 'numeric',
                Rule::exists('users', 'id')->where(function ($query) {
                    return $query->where('user_category_id', 1);
                })],
            'reason'   =>  ['string', 'nullable', 'max:255'],
            'import_date'   =>  ['required', 'date'],
            'warehouse_id'   =>  ['required', 'numeric', 'exists:Warehouse,id'],
            'in_stock_details' => ['required', 'array'],
            'in_stock_details.*.item_id' => ['required', 'numeric','exists:Item,id'],
            'in_stock_details.*.item_quantity' => ['required', 'numeric', 'min:0'],
        ];
    }
}

フォームについて

こんなフォームに
正常フォーム

そこにこんな風にエラーを出すエラー表示用のコンポーネントを作る

エラーフィードバック

エラーデータについて

コントローラーにInvalidなpostリクエストを投げると、Inertiaを通して下記のようなデータが返ってくる。

{
    "data": {
        "message": "選択された生産所は正しくありません。 (and 2 more errors)",
        "errors": {
            "produce_user_id": [
                "選択された生産所は正しくありません。"
            ],
            "import_date": [
                "入荷日は必ず指定してください。"
            ],
            "warehouse_id": [
                "選択された倉庫は正しくありません。"
            ]
        }
    },
    "status": 422,
・・・・

Vue側

TypeScriptで型定義を作成

まず、フォームデータとエラーレスポンスの型を定義します。

// types/InStockInfo.ts
export interface InStockDetail {
  item_id: number
  item_quantity: number
}

export interface InStockFormData {
  produce_user_id: number
  reason?: string
  import_date: string
  warehouse_id: number
  in_stock_details: InStockDetail[]
}

// Laravelから返ってくるエラーの型
export type ValidationErrors<T> = {
  [K in keyof T]?: T[K] extends Array<infer U> 
    ? { [P in keyof U as `${number}.${string & P}`]?: string[] } & string[]
    : string[]
}

// Inertiaのページプロパティに含まれるエラー
export interface PageProps {
  errors: ValidationErrors<InStockFormData>
}

汎用的なエラー表示コンポーネント

<!-- components/ValidationError.vue -->
<script setup lang="ts">
interface Props {
  error?: string | string[]
}

const props = defineProps<Props>()

const errorMessage = computed(() => {
  if (!props.error) return null
  
  if (Array.isArray(props.error)) {
    return props.error[0] // 最初のエラーメッセージを表示
  }
  
  return props.error
})
</script>

<template>
  <p v-if="errorMessage" class="text-sm font-bold text-red-600 mt-1">
    {{ errorMessage }}
  </p>
</template>

フォームコンポーネントの実装

<!-- pages/InStock/Create.vue -->
<script setup lang="ts">
import { useForm, usePage } from '@inertiajs/vue3'
import ValidationError from '@/components/ValidationError.vue'
import type { InStockFormData, ValidationErrors, PageProps } from '@/types/InStockInfo'

const page = usePage<PageProps>()

const form = useForm<InStockFormData>({
  produce_user_id: 0,
  reason: '',
  import_date: '',
  warehouse_id: 0,
  in_stock_details: [
    { item_id: 0, item_quantity: 0 }
  ]
})

// 配列要素のエラーを取得するヘルパー関数
const getArrayError = (fieldName: string, index: number, property: string) => {
  const key = `${fieldName}.${index}.${property}` as keyof ValidationErrors<InStockFormData>
  return page.props.errors[key]
}

const addDetail = () => {
  form.in_stock_details.push({ item_id: 0, item_quantity: 0 })
}

const removeDetail = (index: number) => {
  form.in_stock_details.splice(index, 1)
}

const submit = () => {
  form.post(route('in-stock.store'))
}
</script>

<template>
  <form @submit.prevent="submit">
    <!-- 生産所選択 -->
    <div class="mb-4">
      <label class="block text-sm font-medium text-gray-700">生産所</label>
      <select 
        v-model="form.produce_user_id"
        class="mt-1 block w-full rounded-md border-gray-300"
        :class="{ 'border-red-500': page.props.errors.produce_user_id }"
      >
        <option value="0">選択してください</option>
        <!-- options... -->
      </select>
      <ValidationError :error="page.props.errors.produce_user_id" />
    </div>

    <!-- 入荷日 -->
    <div class="mb-4">
      <label class="block text-sm font-medium text-gray-700">入荷日</label>
      <input 
        type="date"
        v-model="form.import_date"
        class="mt-1 block w-full rounded-md border-gray-300"
        :class="{ 'border-red-500': page.props.errors.import_date }"
      />
      <ValidationError :error="page.props.errors.import_date" />
    </div>

    <!-- 倉庫選択 -->
    <div class="mb-4">
      <label class="block text-sm font-medium text-gray-700">倉庫</label>
      <select 
        v-model="form.warehouse_id"
        class="mt-1 block w-full rounded-md border-gray-300"
        :class="{ 'border-red-500': page.props.errors.warehouse_id }"
      >
        <option value="0">選択してください</option>
        <!-- options... -->
      </select>
      <ValidationError :error="page.props.errors.warehouse_id" />
    </div>

    <!-- 理由(任意) -->
    <div class="mb-4">
      <label class="block text-sm font-medium text-gray-700">理由(任意)</label>
      <input 
        type="text"
        v-model="form.reason"
        class="mt-1 block w-full rounded-md border-gray-300"
        :class="{ 'border-red-500': page.props.errors.reason }"
      />
      <ValidationError :error="page.props.errors.reason" />
    </div>

    <!-- 入庫明細 -->
    <div class="mb-4">
      <label class="block text-sm font-medium text-gray-700 mb-2">入庫明細</label>
      
      <div v-for="(detail, index) in form.in_stock_details" :key="index" class="border p-4 mb-2 rounded">
        <div class="grid grid-cols-2 gap-4">
          <div>
            <label class="block text-sm font-medium text-gray-700">商品</label>
            <select 
              v-model="detail.item_id"
              class="mt-1 block w-full rounded-md border-gray-300"
              :class="{ 'border-red-500': getArrayError('in_stock_details', index, 'item_id') }"
            >
              <option value="0">選択してください</option>
              <!-- options... -->
            </select>
            <ValidationError :error="getArrayError('in_stock_details', index, 'item_id')" />
          </div>
          
          <div>
            <label class="block text-sm font-medium text-gray-700">数量</label>
            <input 
              type="number"
              v-model.number="detail.item_quantity"
              class="mt-1 block w-full rounded-md border-gray-300"
              :class="{ 'border-red-500': getArrayError('in_stock_details', index, 'item_quantity') }"
            />
            <ValidationError :error="getArrayError('in_stock_details', index, 'item_quantity')" />
          </div>
        </div>
        
        <button 
          v-if="form.in_stock_details.length > 1"
          @click="removeDetail(index)"
          type="button"
          class="mt-2 text-red-600 hover:text-red-800"
        >
          削除
        </button>
      </div>
      
      <button 
        @click="addDetail"
        type="button"
        class="mt-2 bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
      >
        明細を追加
      </button>
    </div>

    <!-- 送信ボタン -->
    <div class="mt-6">
      <button 
        type="submit"
        :disabled="form.processing"
        class="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
      >
        {{ form.processing ? '送信中...' : '登録' }}
      </button>
    </div>
  </form>
</template>

さらなる改善:カスタムComposableの作成

より再利用性を高めるために、バリデーションエラーを扱うComposableを作成できます。

// composables/useValidationErrors.ts
import { usePage } from '@inertiajs/vue3'
import type { ValidationErrors } from '@/types/InStockInfo'

export function useValidationErrors<T>() {
  const page = usePage<{ errors: ValidationErrors<T> }>()
  
  const getError = (field: keyof T): string[] | undefined => {
    return page.props.errors[field]
  }
  
  const hasError = (field: keyof T): boolean => {
    return !!page.props.errors[field]
  }
  
  const getArrayFieldError = (
    fieldName: string, 
    index: number, 
    property: string
  ): string[] | undefined => {
    const key = `${fieldName}.${index}.${property}` as keyof ValidationErrors<T>
    return page.props.errors[key]
  }
  
  return {
    errors: page.props.errors,
    getError,
    hasError,
    getArrayFieldError
  }
}

まとめ

このアプローチにより、以下のメリットが得られます:

  1. 型安全性: TypeScriptの型定義により、エラーフィールドの指定時にインテリセンスが効く
  2. 再利用性: ValidationErrorコンポーネントとComposableにより、他のフォームでも同じ仕組みを使える
  3. 保守性: エラー表示ロジックが一箇所にまとまっているため、変更が容易
  4. 配列フィールドのサポート: in_stock_details.*のような配列形式のバリデーションエラーも適切に表示

LaravelのFormRequestとInertia、Vue3の組み合わせにより、バックエンドのバリデーションロジックとフロントエンドのエラー表示を効率的に連携させることができます。

株式会社en-gine

Discussion