🌽

【Inertia】Laravel+Vueでモデル作成からCRUDの処理まで

2024/10/18に公開

Laravelを使っていると、モデルの作成からCRUD処理までを実装したいことがよくあります。
難しくはないのですが、いろんなファイルを行き来して、必要な処理を作成するのが結構面倒です。
また、Inertiaについての記事は少なく、手順を忘れてしまった時に確認するのに時間がかかります。

なので、コピペだけでモデルの作成からCRUD処理までの一連の流れを実装できるように記事にまとめていきます。
テンプレートとして、1学年のテスト結果をTestモデルとして作成する場合を想定します。

※Laravel9でInertiaを使っています。

プロジェクト作成からInertiaの導入までは別記事でまとめました。
https://zenn.dev/pitapaka/articles/f0740ff3dd0c11

データベースの準備

必要なファイルを一斉作成する

php artisan make:model Test -a

モデル、ファクトリー、マイグレーション、シーダー、リクエスト、リソースコントローラー、ポリシーをまとめて作成

Testの部分に大文字単数系の単語を入れる

migrationを編集する

Testモデルはクラス(crassroom)、人(person)、点数(score)を持つことにします。

2024_(省略)_create_tests_table.php
public function up()
{
    Schema::create('tests', function (Blueprint $table) {
        $table->id();
        $table->string('classroom');
        $table->string('person');
        $table->integer('score');
        $table->timestamps();
    });
}

ダミーデータを作成する

Testのダミーデータを100個作成します。

TestFactory.php
public function definition()
{
    return [
        'classroom' => $this->faker->randomElement(['A', 'B', 'C']),
        'person' => $this->faker->name,
        'score' => $this->faker->numberBetween(10, 100),
    ];
}

DatabaseSeederにTestのダミーデータを作成する処理を追加します。
※Userのダミーデータを作成していない場合は$this->callの部分は無視してください。

DatabaseSeeder.php
class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $this->call([
            UserSeeder::class,
        ]);

        // この行を追加
        \App\Models\Test::factory(100)->create();
    }
}

データベースをリフレッシュする

php artisan migrate:fresh --seed

ここまででデータベース側の準備は完了しました。

一覧表示を作る(CRUDのR)

routeの登録

まずはrouteを登録します。
一番最初の一斉作成コマンドでリソースコントローラーが作成されているので、まとめてルートの登録ができます。

web.php
Route::resource('tests', TestController::class)
->middleware(['auth', 'verified']);

コントローラーに一覧表示の処理を追加

コントローラーのindexメソッドに一覧表示用の処理を作成します。

TestsController
public function index()
{
    $tests = Test::all();
    return Inertia::render('Tests/Index', [
        'tests' => $tests
    ]);
}

Index.vueの作成

js/PagesにTestsディレクトリを作成し、そこに表示用のvueファイルを作成していきます。
まずは一覧表示用のIndex.vueを作成します。

Pages/Tests/Index.vue
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Link, Head } from '@inertiajs/vue3';

defineProps({
    tests: Object,
});
</script>

<template>

    <Head title="テスト一覧" />

    <AuthenticatedLayout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">テスト一覧</h2>
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
                <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                    <div class="p-6 text-gray-900">
                        <table class="w-full text-center">
                            <thead>
                                <tr>
                                    <th>id</th>
                                    <th>クラス</th>
                                    <th>人物</th>
                                    <th>点数</th>
                                    <th>作成日</th>
                                    <th>更新日</th>
                                </tr>
                            </thead>
                            <tbody>
                                <tr v-for="test in tests" :key="test.id">
                                    <td>
                                        <Link :href="route('tests.show', test.id)">
                                        {{ test.id }}
                                        </Link>
                                    </td>
                                    <td>
                                        {{ test.classroom }}
                                    </td>
                                    <td>
                                        {{ test.person }}
                                    </td>
                                    <td>
                                        {{ test.score }}
                                    </td>
                                    <td>
                                        {{ test.created_at }}
                                    </td>
                                    <td>
                                        {{ test.updated_at }}
                                    </td>
                                </tr>
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
        </div>
    </AuthenticatedLayout>

</template>

ここまでできたら、/testsにアクセスするとテストのダミーデータ一覧が表示されます。

詳細表示を作る(CRUDのR)

次に一覧表示のIDをクリックしたら、個別の情報が見れる画面を作成していきます。

コントローラーに詳細表示の処理を追加

コントローラーの中にshowメソッドがあるので、詳細表示の処理を追加します。

TestsController
public function show(Test $test)
{
    return Inertia::render('Tests/Show', [
        'test' => $test
    ]);
}

Show.vueの作成

日付のフォーマットのためにday.jsを使うので、先にインストールしておきます。

npm i dayjs

続いて、詳細表示用のVueファイルを作成します。

Pages/Tests/Show.vue
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, Link, router } from '@inertiajs/vue3';
import dayjs from 'dayjs';

const props = defineProps({
    test: Object,
});
</script>

<template>

    <Head title="テスト詳細" />

    <AuthenticatedLayout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">テスト詳細</h2>
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
                <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                    <div class="p-6 text-gray-900">
                        <section class="text-gray-600 body-font relative">
                            <div class="container px-5 py-8 mx-auto">
                                <div class="lg:w-1/2 md:w-2/3 mx-auto">
                                    <div class="flex flex-wrap -m-2">

                                        <div class="p-2 w-full">
                                            <div class="relative">
                                                <label for="classroom"
                                                    class="leading-7 text-sm text-gray-600">クラス</label>
                                                <div id="classroom"
                                                    class="w-full  text-base outline-none text-gray-700 py-1 leading-8">
                                                    {{ test.classroom }}
                                                </div>
                                            </div>
                                        </div>

                                        <div class="p-2 w-full">
                                            <div class="relative">
                                                <label for="name" class="leading-7 text-sm text-gray-600">人物</label>
                                                <div id="name"
                                                    class="w-full  text-base outline-none text-gray-700 py-1 leading-8">
                                                    {{ test.person }}
                                                </div>
                                            </div>
                                        </div>
                                        <div class="p-2 w-full">
                                            <div class="relative">
                                                <label for="score" class="leading-7 text-sm text-gray-600">点数</label>
                                                <div id="score"
                                                    class="w-full  text-base outline-none text-gray-700 py-1 leading-8">
                                                    {{ test.score }}
                                                </div>
                                            </div>
                                        </div>

                                        <div class="p-2 w-full">
                                            <div class="relative">
                                                <label for="created_at"
                                                    class="leading-7 text-sm text-gray-600">作成日</label>
                                                <div id="created_at"
                                                    class="w-full  text-base outline-none text-gray-700 py-1 leading-8">
                                                    {{ dayjs(test.created_at).format('YYYY/MM/DD') }}
                                                </div>
                                            </div>
                                        </div>

                                        <div class="p-2 w-full">
                                            <div class="relative">
                                                <label for="updated_at"
                                                    class="leading-7 text-sm text-gray-600">更新日</label>
                                                <div id="updated_at"
                                                    class="w-full  text-base outline-none text-gray-700 py-1 leading-8">
                                                    {{ dayjs(test.updated_at).format('YYYY/MM/DD') }}
                                                </div>
                                            </div>
                                        </div>

                                        <div class="flex w-full justify-center gap-12 mt-8">
                                            <Link as="button" :href="route('tests.edit', { test: test.id })"
                                                class="flex justify-center w-36 text-white bg-indigo-500 border-0 py-2 px-8 focus:outline-none hover:bg-indigo-600 rounded text-lg">
                                            編集する
                                            </Link>
                                            <button @click="deleteTest(test.id)"
                                                class="flex justify-center w-36 text-white bg-red-500 border-0 py-2 px-8 focus:outline-none hover:bg-red-600 rounded text-lg">
                                                削除する
                                            </button>
                                        </div>

                                    </div>
                                </div>
                            </div>
                        </section>
                    </div>
                </div>
            </div>
        </div>
    </AuthenticatedLayout>

</template>

後ほど削除の処理も作るので、削除ボタンも先に作っておきました。

新規作成処理を作る(CRUDのC)

テストデータを新規追加する処理を作成します。

コントローラーに新規作成画面の表示処理を追加

TestsController
public function create()
{
    return Inertia::render('Tests/Create');
}

Create.vueの作成

Pages/Tests/Create.vue
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, router } from '@inertiajs/vue3';
import { reactive } from 'vue';

const form = reactive({
    classroom: '',
    person: '',
    score: '',
});

const storeTest = () => {
    router.post('/tests', form)
}

</script>

<template>

    <Head title="テスト新規追加" />

    <AuthenticatedLayout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">テスト新規追加</h2>
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
                <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                    <div class="p-6 text-gray-900">
                        <section class="text-gray-600 body-font relative">
                            <form @submit.prevent="storeTest">
                                <div class="container px-5 py-8 mx-auto">
                                    <div class="lg:w-1/2 md:w-2/3 mx-auto">
                                        <div class="flex flex-wrap -m-2">
                                            <div class="p-2 w-full">
                                                <div class="relative">
                                                    <label for="classroom"
                                                        class="leading-7 text-sm text-gray-600">クラス</label>
                                                    <input type="text" id="classroom" name="classroom" v-model="form.classroom"
                                                        class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                                                </div>
                                            </div>
                                            <div class="p-2 w-full">
                                                <div class="relative">
                                                    <label for="name" class="leading-7 text-sm text-gray-600">人物</label>
                                                    <input type="text" id="person" name="person" v-model="form.person"
                                                        class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                                                </div>
                                            </div>
                                            <div class="p-2 w-full">
                                                <div class="relative">
                                                    <label for="score"
                                                        class="leading-7 text-sm text-gray-600">点数</label>
                                                    <input type="number" id="score" name="score" v-model="form.score"
                                                        min="1"
                                                        class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                                                </div>
                                            </div>
                                            <div class="p-2 w-full">
                                                <button
                                                    class="flex mx-auto text-white bg-indigo-500 border-0 py-2 px-8 focus:outline-none hover:bg-indigo-600 rounded text-lg">登録</button>
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            </form>
                        </section>
                    </div>
                </div>
            </div>
        </div>
    </AuthenticatedLayout>

</template>

これで、tests/createにアクセスすると新規作成画面が表示されます。
一覧画面(Index.vue)の好きな場所に新規作成画面へのボタンを貼り付けておくと便利です。

<Link as="button" :href="route('tests.create')"
    class="text-white bg-indigo-500 border-0 py-2 px-6 focus:outline-none hover:bg-indigo-600 rounded">
新規作成
</Link>

続いて、新規作成画面で入力された値を保存する処理を作成します。

コントローラーに保存処理を追加

TestsController
public function store(StoreTestRequest $request)
{
    Test::create([
        'classroom' => $request->classroom,
        'person' => $request->person,
        'score' => $request->score,
    ]);

    return to_route('tests.index');
}

モデルの編集

createメソッドでデータ作成するためにモデルを編集します。

Models/Test.php
class Test extends Model
{
    use HasFactory;

    protected $fillable = [
        'classroom',
        'person',
        'score',
    ];
}

requestの編集

権利関係の設定とバリデーションの作成を行います。

StoreTestRequest.php
class StoreTestRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'classroom' => 'required|string|max:255',
            'person' => 'required|string|max:255',
            'score' => 'required|integer|min:0',
        ];
    }
}

バリデーションの表示(Create.vue)

requestで設定したバリデーションルールに反していた場合、errorsが返却されます。
definePropsでエラーを取得し、v-ifで各項目のエラーがあればエラーメッセージを表示できるようにします。
デフォルトのエラーメッセージは英語なので、日本語で表示したい場合は 言語ファイルの追加が必要です。

Create.vue
<script setup>
defineProps({
    errors: Object,
});
</script>
<template>
<p v-if="errors.classroom">{{ errors.classroom }}</p>
<p v-if="errors.person">{{ errors.person }}</p>
<p v-if="errors.score">{{ errors.score }}</p>
</template>

編集処理を作る(CRUDのU)

新規登録とほとんど同じなので、説明は省略します。

コントローラーに編集画面の表示処理を追加

TestsController
public function edit(Test $test)
{
    return Inertia::render('Tests/Edit', [
        'test' => $test
    ]);
}

Edit.Vueの作成

Tests/Pages/Edit.vue
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, Link, router } from '@inertiajs/vue3';
import { reactive } from 'vue';
const props = defineProps({
    test: Object,
});

const form = reactive({
    id: props.test.id,
    classroom: props.test.classroom,
    person: props.test.person,
    score: props.test.score,
})

const updateTest = id => {
    router.put(route('tests.update', { test: id }), form)
}
</script>

<template>

    <Head title="テスト編集" />

    <AuthenticatedLayout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">テスト編集</h2>
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
                <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                    <div class="p-6 text-gray-900">
                        <div class="mb-4 text-gray-600">
                            <Link :href="route('tests.show', { test: test.id })">詳細に戻る</Link>
                        </div>
                        <section class="text-gray-600 body-font relative">
                            <form @submit.prevent="updateTest(form.id)">
                                <div class="container px-5 py-8 mx-auto">
                                    <div class="lg:w-1/2 md:w-2/3 mx-auto">
                                        <div class="flex flex-wrap -m-2">

                                            <div class="p-2 w-full">
                                                <div class="relative">
                                                    <label for="classroom"
                                                        class="leading-7 text-sm text-gray-600">クラス</label>
                                                    <input type="text" id="classroom" name="classroom" v-model="form.classroom"
                                                        class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                                                </div>
                                            </div>

                                            <div class="p-2 w-full">
                                                <div class="relative">
                                                    <label for="person" class="leading-7 text-sm text-gray-600">名前</label>
                                                    <input type="text" id="person" name="person" v-model="form.person"
                                                        class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                                                </div>
                                            </div>

                                            <div class="p-2 w-full">
                                                <div class="relative">
                                                    <label for="score"
                                                        class="leading-7 text-sm text-gray-600">点数</label>
                                                    <input type="number" id="score" name="score" v-model="form.score"
                                                        class="w-full bg-gray-100 bg-opacity-50 rounded border border-gray-300 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out">
                                                </div>
                                            </div>

                                            <div class="mt-8 p-2 w-full">
                                                <button
                                                    class="flex justify-center w-36 mx-auto text-white bg-indigo-500 border-0 py-2 px-8 focus:outline-none hover:bg-indigo-600 rounded text-lg">更新する</button>
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            </form>
                        </section>
                    </div>
                </div>
            </div>
        </div>
    </AuthenticatedLayout>

</template>

コントローラーに更新処理を追加

TestController
public function update(UpdateTestRequest $request, Test $test)
{
    $test->classroom = $request->classroom;
    $test->person = $request->person;
    $test->score = $request->score;
    $test->save();

    return to_route('tests.index');
}

requestの編集

UpdateTestRequest.php
class UpdateTestRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'classroom' => 'required|string|max:255',
            'person' => 'required|string|max:255',
            'score' => 'required|integer|min:0',
        ];
    }
}

削除処理を作る(CRUDのD)

最後に詳細表示画面でデータを削除できるようにします。
削除ボタンはすでに表示されているはずなので、処理を追加するだけです。

コントローラーに削除処理を追加

TestController.php
public function destroy(Test $test)
{
    $test->delete();
    return to_route('tests.index');
}

詳細表示画面(Show.vue)に削除処理を追加

onBeforeを入れることで削除の前にダイアログを表示することができます。

Pages/Tests/Show.vue
const deleteTest = id => {
  router.delete(route('tests.destroy', { test: id }), {
    onBefore: () => confirm('本当に削除しますか?')
  })
}

Discussion