MENU

問い合わせ


    【Laravel+Vue.jsで作る管理画面をECSで動かす 第2回】顧客マスタの一覧と登録:FormRequest でバリデーションする

    管理画面の最初の機能は、どの業務システムにもある「マスタ(=顧客や商品など、繰り返し参照する基本データ)」の管理です。今回は顧客マスタを題材に、Laravel 12 と Vue 3 で一覧・登録・編集・削除の画面を作り、入力チェック(バリデーション)とそのテストまでを実装します。

    「顧客の登録画面を作るだけなら簡単そう。でも、入力ミスや重複登録をどこでどう防げばいいのか、テストはどう書くのかが分からない…」

    結論から言うと、Laravel では入力チェックを FormRequest(=入力チェック専用のクラス)にまとめ、コントローラーではチェックを通過した値だけを保存するのが基本です。画面側は Inertia の useForm を使うと、サーバーが返したエラーを項目ごとに表示できます。

    前回(第1回:全体像と開発環境)では、Laravel 12 公式の Vue スターターキットを土台に、Docker で開発環境を立ち上げました。今回はそのコードに顧客マスタの機能を追加します。

    目次

    顧客マスタの一覧と登録で作るもの

    今回作る画面と処理は次のとおりです。

    URL画面・処理担当するメソッド
    GET /customers顧客一覧(1ページ20件、前へ/次へ)CustomerController@index
    GET /customers/create新規登録フォームcreate
    POST /customers登録(入力チェック → 保存)store
    GET /customers/{id}/edit編集フォームedit
    PUT /customers/{id}更新(入力チェック → 保存)update
    DELETE /customers/{id}削除destroy

    入力項目と、それぞれのチェック内容は次のとおりです。

    項目必須チェック内容
    顧客コード必須20文字以内、半角英大文字・数字・ハイフンのみ、他の顧客と重複しない
    顧客名必須100文字以内
    顧客名(カナ)任意100文字以内
    メールアドレス任意メールアドレスの形式
    電話番号任意20文字以内、半角数字とハイフンのみ
    備考任意1000文字以内

    処理の流れは「ブラウザのフォーム → Laravel のルート → FormRequest でチェック → コントローラーで保存 → 一覧へリダイレクト」です。チェックに失敗すると、Laravel が自動的に入力画面へ戻し(302 リダイレクト)、Inertia がエラーメッセージを項目の下に表示します。

    テーブルとモデルを作る

    マイグレーション:customers テーブル

    マイグレーションは、テーブルの作成や変更を PHP のコードで書き、履歴として管理する仕組みです。

    database/migrations/2025_05_27_000000_create_customers_table.php

    <?php
    
    declare(strict_types=1);
    
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    
    return new class extends Migration
    {
        public function up(): void
        {
            Schema::create('customers', function (Blueprint $table): void {
                $table->id();
                $table->string('code', 20)->unique();          // 顧客コード(重複不可)
                $table->string('name', 100);                    // 顧客名
                $table->string('name_kana', 100)->nullable();   // 顧客名(カナ)
                $table->string('email')->nullable();
                $table->string('phone', 20)->nullable();
                $table->text('note')->nullable();               // 備考
                $table->timestamps();
            });
        }
    
        public function down(): void
        {
            Schema::dropIfExists('customers');
        }
    };

    顧客コードには、データベース側でもユニーク制約(unique())を付けています。画面の入力チェックだけでは、2人が同時に同じコードで登録したときなどにすり抜ける可能性があるため、最後の砦としてデータベースでも重複を防ぎます。

    モデル:保存してよい列を $fillable で明示する

    app/Models/Customer.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\Models;
    
    use Database\Factories\CustomerFactory;
    use Illuminate\Database\Eloquent\Factories\HasFactory;
    use Illuminate\Database\Eloquent\Model;
    
    class Customer extends Model
    {
        /** @use HasFactory<CustomerFactory> */
        use HasFactory;
    
        /**
         * 画面から登録・更新してよい列だけを明示する(id やタイムスタンプは含めない)
         *
         * @var list<string>
         */
        protected $fillable = [
            'code',
            'name',
            'name_kana',
            'email',
            'phone',
            'note',
        ];
    }

    $fillable は、Customer::create([...]) のように配列でまとめて保存するとき(マスアサインメント)に、受け付けてよい列の一覧です。ここに無い列は、送信されても無視されます。テスト用のダミーデータを作るファクトリー(database/factories/CustomerFactory.php)も用意しました。

    FormRequest で入力チェックを書く

    StoreCustomerRequest:登録時のルール

    FormRequest は、authorize()(誰が実行してよいか)と rules()(入力のルール)を持つクラスです。コントローラーの引数に指定するだけで、メソッドの本体が動く前にチェックが実行されます。

    📰 出典:Laravel 12.x Validation(Form Request Validation)

    app/Http/Requests/StoreCustomerRequest.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\Http\Requests;
    
    use App\Models\Customer;
    use Illuminate\Contracts\Validation\ValidationRule;
    use Illuminate\Foundation\Http\FormRequest;
    use Illuminate\Validation\Rule;
    
    class StoreCustomerRequest extends FormRequest
    {
        /**
         * 誰がこの操作をしてよいか。第2回はログイン済みなら許可し、第3回で Policy に置き換える。
         */
        public function authorize(): bool
        {
            return $this->user() !== null;
        }
    
        /**
         * @return array<string, ValidationRule|array<mixed>|string>
         */
        public function rules(): array
        {
            return [
                'code' => ['required', 'string', 'max:20', 'regex:/^[A-Z0-9-]+$/', Rule::unique(Customer::class, 'code')],
                'name' => ['required', 'string', 'max:100'],
                'name_kana' => ['nullable', 'string', 'max:100'],
                'email' => ['nullable', 'string', 'email', 'max:255'],
                'phone' => ['nullable', 'string', 'max:20', 'regex:/^[0-9-]+$/'],
                'note' => ['nullable', 'string', 'max:1000'],
            ];
        }
    
        // attributes()(項目名の日本語化)と messages()(エラーメッセージ)は省略
    }

    省略した attributes() では「code → 顧客コード」のように項目名を日本語にし、messages() では「:attributeを入力してください。」「この:attributeはすでに登録されています。」のようにメッセージを日本語で定義しています。Laravel 12 は日本語の翻訳ファイルを同梱していないため、何もしないと英語のメッセージになります。今回は FormRequest ごとに必要な分だけ日本語にしました。

    UpdateCustomerRequest:重複チェックから自分自身を除く

    更新時のルールは登録時とほとんど同じですが、顧客コードの重複チェックだけ注意が必要です。そのままだと「自分自身のコード」と重複していると判定され、コードを変えずに保存できなくなります。ignore() で自分自身を除外します。

    app/Http/Requests/UpdateCustomerRequest.php

    <?php
    
    declare(strict_types=1);
    
    namespace App\Http\Requests;
    
    use App\Models\Customer;
    use Illuminate\Validation\Rule;
    
    /**
     * 更新時のルールは登録時とほぼ同じ。顧客コードの重複チェックだけ「自分自身」を除外する。
     */
    class UpdateCustomerRequest extends StoreCustomerRequest
    {
        public function rules(): array
        {
            /** @var Customer $customer */
            $customer = $this->route('customer');
    
            return [
                ...parent::rules(),
                'code' => ['required', 'string', 'max:20', 'regex:/^[A-Z0-9-]+$/', Rule::unique(Customer::class, 'code')->ignore($customer)],
            ];
        }
    }

    登録用のクラスを継承しているので、項目名やメッセージの定義も共通になります。

    コントローラーとルートを作る

    CustomerController:validated() だけを保存する

    app/Http/Controllers/CustomerController.php(抜粋)

    class CustomerController extends Controller
    {
        public function index(): Response
        {
            $customers = Customer::query()
                ->select(['id', 'code', 'name', 'email', 'phone', 'updated_at'])
                ->orderByDesc('id')
                ->paginate(20);
    
            return Inertia::render('customers/Index', [
                'customers' => $customers,
            ]);
        }
    
        public function store(StoreCustomerRequest $request): RedirectResponse
        {
            // validated() で、ルールを通過した項目だけを保存する
            Customer::create($request->validated());
    
            return to_route('customers.index')->with('success', '顧客を登録しました。');
        }
    
        public function edit(Customer $customer): Response
        {
            return Inertia::render('customers/Edit', [
                'customer' => $customer->only(['id', 'code', 'name', 'name_kana', 'email', 'phone', 'note']),
            ]);
        }
    
        public function update(UpdateCustomerRequest $request, Customer $customer): RedirectResponse
        {
            $customer->update($request->validated());
    
            return to_route('customers.index')->with('success', '顧客情報を更新しました。');
        }
    
        public function destroy(Customer $customer): RedirectResponse
        {
            $customer->delete();
    
            return to_route('customers.index')->with('success', '顧客を削除しました。');
        }
    }

    ポイントは3つです。

    • $request->validated() を使う:$request->all() をそのまま保存すると、フォームに無い項目を送り付けられたときに意図しない列が書き換わるおそれがあります。validated() はルールを書いた項目だけを返します。
    • 画面に渡す列を絞る:select() や only() で、画面に必要な列だけを渡します。Inertia ではコントローラーが渡したデータはブラウザのHTMLに埋め込まれるため、不要な列を渡さない習慣をつけておきます。
    • ルートモデルバインディング:edit(Customer $customer) のように引数に型を書くと、URL の {customer} の ID から Laravel が自動でレコードを取得し、無ければ 404 を返します。

    paginate(20) は、指定した件数ごとにデータを区切り、総件数や前後のページのURLを一緒に返すメソッドです。

    routes/web.php:リソースルート

    routes/web.php(追加部分)

    Route::middleware(['auth', 'verified'])->group(function () {
        // 顧客マスタ(一覧・登録・編集・削除)。詳細画面は使わないので show は除外
        Route::resource('customers', CustomerController::class)->except(['show']);
    });

    Route::resource を使うと、冒頭の表の6つのURLが1行で定義されます。auth ミドルウェアで、ログインしていない人は /login に戻されます。

    📰 出典:Laravel 12.x Controllers(Resource Controllers)

    完了メッセージを画面に渡す

    登録後の「顧客を登録しました。」は、with('success', ...) でセッションに一時保存し、Inertia の共有データ(=すべての画面に渡すデータ)として渡します。

    app/Http/Middleware/HandleInertiaRequests.php(share() に追加)

                // 登録・更新・削除後の完了メッセージ(redirect()->with('success', ...) で渡したもの)
                'flash' => [
                    'success' => $request->session()->get('success'),
                ],

    📰 出典:Inertia.js v2 Shared data

    Vue の画面:useForm でエラーを表示する

    CustomerForm.vue:登録と編集で共通のフォーム

    登録画面と編集画面は、入力項目が同じなので1つの部品にまとめました。customer を渡すと編集、渡さないと新規登録として動きます。

    resources/js/components/customers/CustomerForm.vue(抜粋)

    <script setup lang="ts">
    import InputError from '@/components/InputError.vue';
    import { Button } from '@/components/ui/button';
    import { Input } from '@/components/ui/input';
    import { Label } from '@/components/ui/label';
    import { type Customer } from '@/types';
    import { Link, useForm } from '@inertiajs/vue3';
    
    // customer を渡すと編集、渡さないと新規登録として動く
    const props = defineProps<{
        customer?: Customer;
    }>();
    
    const form = useForm({
        code: props.customer?.code ?? '',
        name: props.customer?.name ?? '',
        name_kana: props.customer?.name_kana ?? '',
        email: props.customer?.email ?? '',
        phone: props.customer?.phone ?? '',
        note: props.customer?.note ?? '',
    });
    
    const submit = () => {
        if (props.customer) {
            form.put(route('customers.update', props.customer.id));
        } else {
            form.post(route('customers.store'));
        }
    };
    </script>
    
    <template>
        <form class="max-w-xl space-y-6" @submit.prevent="submit">
            <div class="grid gap-2">
                <Label for="code">顧客コード(必須)</Label>
                <Input id="code" v-model="form.code" placeholder="C00001" autocomplete="off" />
                <InputError :message="form.errors.code" />
            </div>
    
            <div class="grid gap-2">
                <Label for="name">顧客名(必須)</Label>
                <Input id="name" v-model="form.name" autocomplete="organization" />
                <InputError :message="form.errors.name" />
            </div>
    
            <!-- 顧客名(カナ)・メールアドレス・電話番号・備考も同じ形なので省略 -->
    
            <div class="flex items-center gap-4">
                <Button type="submit" :disabled="form.processing">{{ customer ? '更新する' : '登録する' }}</Button>
                <Link :href="route('customers.index')" class="text-muted-foreground text-sm underline">一覧に戻る</Link>
            </div>
        </form>
    </template>

    useForm は、入力値・送信中かどうか(processing)・エラー(errors)をまとめて管理する Inertia の仕組みです。FormRequest のチェックに失敗すると、Laravel がエラーをセッションに入れて入力画面へ戻し、Inertia がそれを form.errors.code のように項目ごとに受け取ります。入力値も保持されたままなので、利用者は間違えた箇所だけを直せます。送信中はボタンを無効にして、二重送信を防いでいます。

    📰 出典:Inertia.js v2 Forms

    route('customers.update', ...) は、スターターキットに入っている Ziggy の関数で、Laravel 側のルート名からURLを組み立てます。URLを文字列で直書きしないので、ルートを変えたときの修正漏れを防げます。

    一覧画面:削除は確認してから

    resources/js/pages/customers/Index.vue(script 部分)

    <script setup lang="ts">
    import Heading from '@/components/Heading.vue';
    import { Button } from '@/components/ui/button';
    import AppLayout from '@/layouts/AppLayout.vue';
    import { type BreadcrumbItem, type Customer, type Paginated, type SharedData } from '@/types';
    import { Head, Link, router, usePage } from '@inertiajs/vue3';
    
    defineProps<{
        customers: Paginated<Customer>;
    }>();
    
    const page = usePage<SharedData>();
    
    const breadcrumbs: BreadcrumbItem[] = [{ title: '顧客', href: '/customers' }];
    
    const destroy = (customer: Customer) => {
        if (!confirm(`「${customer.name}」を削除します。よろしいですか?`)) {
            return;
        }
        router.delete(route('customers.destroy', customer.id), { preserveScroll: true });
    };
    </script>

    テンプレート部分では、customers.data を表で表示し、page.props.flash.success があれば完了メッセージを、customers.prev_page_url / next_page_url で「前へ」「次へ」のリンクを出しています。Paginated<T> と Customer の型は resources/js/types/index.d.ts に追加しました。左のメニュー(AppSidebar.vue)にも「顧客」を追加しています。

    Feature テストで入力チェックを確かめる

    画面を手で触るだけでは、ルールを変えたときに他のチェックが壊れていないかを毎回確認できません。そこで、HTTPリクエストを送って結果を確かめる Feature テストを書きます。

    tests/Feature/CustomerTest.php(抜粋)

        public function test_required_fields_are_validated(): void
        {
            $this->actingAs(User::factory()->create())
                ->from('/customers/create')
                ->post('/customers', [])
                ->assertRedirect('/customers/create')    // 302 で入力画面に戻る
                ->assertSessionHasErrors(['code', 'name']);
    
            $this->assertDatabaseCount('customers', 0);
        }
    
        public function test_customer_can_be_created(): void
        {
            $this->actingAs(User::factory()->create())
                ->post('/customers', [
                    'code' => 'C00001',
                    'name' => '株式会社サンプル',
                    'email' => 'info@example.com',
                    'phone' => '03-1234-5678',
                ])
                ->assertRedirect('/customers')
                ->assertSessionHas('success');
    
            $this->assertDatabaseCount('customers', 1);
            $this->assertDatabaseHas('customers', ['code' => 'C00001', 'name' => '株式会社サンプル']);
        }
    
        public function test_duplicate_code_is_rejected(): void
        {
            Customer::factory()->create(['code' => 'C00001']);
    
            $this->actingAs(User::factory()->create())
                ->post('/customers', ['code' => 'C00001', 'name' => '別の会社'])
                ->assertSessionHasErrors(['code' => 'この顧客コードはすでに登録されています。']);
    
            $this->assertDatabaseCount('customers', 1);
        }

    このほか、未ログインで /login へ戻されること、一覧が1ページ20件で総件数が正しいこと(assertInertia で画面に渡したデータを確認)、形式の誤り(小文字のコード・101文字の顧客名・メール形式・電話番号の括弧)、更新時に自分のコードのままなら保存できること、他の顧客のコードには変更できないこと、削除できることの、合計9件のテストを書きました。

    📰 出典:Inertia.js v2 Testing

    動作確認の方法

    docker compose run --rm --no-deps app php artisan test --filter=CustomerTest
    docker compose run --rm --no-deps app php artisan test          # 全体
    docker compose run --rm --no-deps node sh -c "npm run build"
    docker compose up -d mysql app
    docker compose exec app php artisan migrate --seed   # テストユーザーと顧客10件

    筆者の環境では次のことを確認しました。

    • CustomerTest の9件が成功し、スターターキットのテストと合わせて全36件が成功
    • npm run build が成功
    • シーダーで作られる test@example.com(パスワード password)でログインし、一覧に顧客10件が表示される
    • 空のまま登録すると「顧客コードを入力してください。」「顧客名を入力してください。」が返り、正しく入力すると一覧へ戻って「顧客を登録しました。」が表示される
    • 編集・削除のあとも一覧に戻り、件数と完了メッセージが変わる

    つまずきやすい点・セキュリティ上の注意

    • assertInertia が「ページが見つからない」で失敗する:Inertia のテスト機能は、既定で resources/js/Pages(大文字の P)からページのファイルを探します。スターターキットは resources/js/pages(小文字)なので、Linux や Docker の上では見つかりません。サンプルでは config/inertia.php を作り、探す場所を js/pages に変えています。
    • テストが画面のビルド結果に依存する:画面をビルドしていない状態でテストを動かすと、「Vite のマニフェストにファイルが無い」というエラーで失敗します。tests/TestCase.php の setUp() で $this->withoutVite() を呼び、ビルドしなくてもテストできるようにしました(第11回の CI でも効いてきます)。
    • 画面の入力チェックだけに頼らない:HTML の required やブラウザ側のチェックは、開発者ツールなどで簡単に外せます。必ずサーバー側の FormRequest でチェックし、重複はデータベースのユニーク制約でも防ぎます。
    • 削除は取り消せない:今回は確認ダイアログを出して物理削除しています。誤削除からの復元が必要な業務では、削除フラグで残す方式(Laravel の SoftDeletes)も検討します。請求などとひもづく第4回以降は、ひもづくデータがある顧客を削除できないようにする設計も必要になります。

    発注者向けメモ:1画面の工数を左右する「仕様の曖昧さ」

    顧客マスタのような一覧+登録+編集の画面は、管理画面で最も基本的な単位です。1画面の工数感をつかむのにちょうどよい題材ですが、次の点が曖昧だと手戻りが出やすくなります。

    • 必須項目と形式:「電話番号はハイフンありか」「メールアドレスは複数登録するか」など。後から変えると、既存データの修正も必要になります。
    • 重複チェックの基準:顧客コードで判定するのか、会社名や電話番号の一致でも警告するのか。「似た名前の会社を重複とみなす」ような判定は、単純な重複チェックより大幅に工数が増えます。
    • 削除の扱い:本当に消すのか、残して非表示にするのか、関連データがある場合はどうするのか。
    • 誰が登録・削除できるか:次回扱う権限の話です。

    開発会社には、次のように確認してみてください。

    • 「入力チェックはサーバー側でも行いますか?エラーメッセージの文言は誰が決めますか?」
    • 「重複登録はどの項目で判定しますか?同時に登録した場合も防げますか?」
    • 「削除したデータは復元できますか?関連するデータがある場合はどうなりますか?」
    • 「入力チェックの自動テストは納品物に含まれますか?」

    まとめと次回予告

    第2回では、顧客マスタの一覧・登録・編集・削除と、その入力チェックを実装しました。

    • 入力チェックは FormRequest の rules() に書き、保存は $request->validated() の値だけを使う
    • 更新時の重複チェックは Rule::unique(...)->ignore() で自分自身を除外。データベースにもユニーク制約を付ける
    • 画面は Inertia の useForm で、サーバーのエラーを項目ごとに表示し、二重送信を防ぐ
    • Feature テストで「必須項目なしで 302+エラー」「正常登録で1件」などを自動で確かめる

    次回は「ロールと Policy:誰が何をできるかをコードで決める」です。ユーザーに管理者・担当者・閲覧のみのロールを持たせ、閲覧のみのユーザーは顧客を登録・編集できないように、サーバー側の認可と画面のボタン表示を実装します。

    この連載の記事一覧

    この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次