【Laravel】Laravel×Vue/Inertiaで簡単なTODOアプリを作ってみる

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

これまでService・Repository・Form Request・ディレクトリ構成と、Laravelの設計面を中心に見てきました。

ここからは実践編として、フロントエンドにVueを使い、Inertia.jsでLaravelとつなぐ構成で、簡単なTODOアプリを作っていきます。

Inertia.jsとは何か、なぜ選ぶか

SPA構築の選択肢比較

LaravelでSPA(Single Page Application)的なUIを作る方法はいくつかあります。

方法特徴
Blade(従来型)ページ遷移のたびにサーバーから全体を再取得。実装はシンプルだがUXは劣る
API + フロント別プロジェクトLaravelは純粋なAPI、フロントはVue/React単体のSPA。柔軟だが構成が複雑になりやすい
Inertia.jsLaravelのControllerからVue/Reactのコンポーネントを直接呼び出せる。API層を別途作らずにSPAのようなUXを実現できる

初中級者がAPI + SPA構成に挑戦すると、認証・CORS・状態管理など、覚えることが一気に増えてつまずきやすいです。

Inertia.jsは、Laravelのルーティングやセッション認証をそのまま活かしながら、画面遷移だけをSPA的にできる点が大きな魅力です。

Inertiaのメリット

Inertiaを使うと、ControllerからJSON APIを作る代わりに、直接Vueコンポーネントに「渡したいデータ」を渡せます。

return Inertia::render('Todos/Index', [
    'todos' => $todos,
]);

この1行で、Vue側の Todos/Index.vue というコンポーネントに todos というpropsが渡ります。

APIのエンドポイント設計やレスポンス形式を別途考える必要がなく、Laravelの知識だけでSPA風のUIに近づける、というのが最大のメリットです。

環境構築

インストールコマンド

Laravelプロジェクトに、Inertia用のスターターキット(Breeze)をVue構成でインストールします。

composer require laravel/breeze --dev
php artisan breeze:install vue
npm install
npm run dev

このコマンドで、認証機能とInertia+Vueの基本構成が一式セットアップされます。

Vueのセットアップ確認

インストール後、resources/js/Pages フォルダにVueコンポーネントが、routes/web.php にInertia用のルートが自動生成されていることを確認しておきましょう。

// routes/web.php
Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

この時点で php artisan serve を実行し、ログイン画面まで動作することを確認してから次に進みます。

TODOアプリの実装

マイグレーションとモデル

まずは todos テーブルを作ります。

php artisan make:model Todo -mc
// database/migrations/xxxx_xx_xx_create_todos_table.php
public function up(): void
{
    Schema::create('todos', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('title');
        $table->boolean('is_done')->default(false);
        $table->timestamps();
    });
}
// app/Models/Todo.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Todo extends Model
{
    protected $fillable = ['title', 'is_done', 'user_id'];
    protected $casts = ['is_done' => 'boolean'];
}
php artisan migrate

Controller実装

Inertia経由でCRUDを行うControllerを実装します。

// app/Http/Controllers/TodoController.php
namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Inertia\Inertia;

class TodoController extends Controller
{
    public function index()
    {
        return Inertia::render('Todos/Index', [
            'todos' => Todo::where('user_id', auth()->id())
                ->latest()
                ->get(),
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|max:255',
        ]);

        Todo::create([
            ...$validated,
            'user_id' => auth()->id(),
        ]);

        return redirect()->route('todos.index');
    }

    public function update(Request $request, Todo $todo)
    {
        $todo->update([
            'is_done' => $request->boolean('is_done'),
        ]);

        return redirect()->route('todos.index');
    }

    public function destroy(Todo $todo)
    {
        $todo->delete();
        return redirect()->route('todos.index');
    }
}
// routes/web.php
use App\Http\Controllers\TodoController;

Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('/todos', [TodoController::class, 'index'])->name('todos.index');
    Route::post('/todos', [TodoController::class, 'store'])->name('todos.store');
    Route::patch('/todos/{todo}', [TodoController::class, 'update'])->name('todos.update');
    Route::delete('/todos/{todo}', [TodoController::class, 'destroy'])->name('todos.destroy');
});

Vueコンポーネント実装

続いて resources/js/Pages/Todos/Index.vue を作成します。

<script setup>
import { useForm } from '@inertiajs/vue3'
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'

const props = defineProps({
    todos: Array,
})

const form = useForm({
    title: '',
})

const submit = () => {
    form.post(route('todos.store'), {
        onSuccess: () => form.reset('title'),
    })
}

const toggleDone = (todo) => {
    useForm({ is_done: !todo.is_done }).patch(route('todos.update', todo.id))
}

const remove = (todo) => {
    useForm().delete(route('todos.destroy', todo.id))
}
</script>

<template>
    <AuthenticatedLayout>
        <div class="max-w-xl mx-auto py-8">
            <h1 class="text-xl font-bold mb-4">TODOリスト</h1>

            <form @submit.prevent="submit" class="flex gap-2 mb-6">
                <input
                    v-model="form.title"
                    type="text"
                    placeholder="やることを入力"
                    class="border rounded px-3 py-2 flex-1"
                />
                <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">
                    追加
                </button>
            </form>

            <ul>
                <li
                    v-for="todo in todos"
                    :key="todo.id"
                    class="flex items-center gap-2 border-b py-2"
                >
                    <input
                        type="checkbox"
                        :checked="todo.is_done"
                        @change="toggleDone(todo)"
                    />
                    <span :class="{ 'line-through text-gray-400': todo.is_done }">
                        {{ todo.title }}
                    </span>
                    <button @click="remove(todo)" class="ml-auto text-red-500 text-sm">
                        削除
                    </button>
                </li>
            </ul>
        </div>
    </AuthenticatedLayout>
</template>

useForm を使うことで、フォームの状態管理・送信・エラー処理をまとめて扱えるのがInertia+Vueの強みです。

つまずきポイント:propsが画面に反映されない問題

かつコーチが実際につまずいた話

TODOを追加した直後、一覧に新しいTODOがすぐ反映されず、ページを手動リロードしないと表示されない、という現象にハマったことがあります。

❌ Before:storeの後にJSONを返してしまう

public function store(Request $request)
{
    $validated = $request->validate(['title' => 'required|max:255']);

    $todo = Todo::create([
        ...$validated,
        'user_id' => auth()->id(),
    ]);

    return response()->json($todo); // Inertiaのリクエストなのに通常のJSONを返していた
}

Inertiaはフォーム送信後、サーバーからの「Inertiaレスポンス」を期待しています。

普通の response()->json() を返してしまうと、Inertia側がうまく画面を更新できず、フロントの状態と実際のデータがズレたままになってしまいました。

✅ After:redirect()でInertiaのページ遷移として返す

public function store(Request $request)
{
    $validated = $request->validate(['title' => 'required|max:255']);

    Todo::create([
        ...$validated,
        'user_id' => auth()->id(),
    ]);

    return redirect()->route('todos.index'); // Inertiaが正しく再描画してくれる
}

APIを作る感覚で response()->json() を返してしまいがちですが、Inertiaを使う場合は基本的に redirect()Inertia::render() でレスポンスを返す、というのがルールだと理解してから解決しました。

「InertiaはAPIではなく、ページ遷移の仕組みを提供している」という前提を忘れないことが大切です。

応用:一歩先の使い方

バリデーションエラーの表示

useForm は、サーバー側のバリデーションエラーを自動的に form.errors に格納してくれます。

<input v-model="form.title" type="text" />
<div v-if="form.errors.title" class="text-red-500 text-sm">
    {{ form.errors.title }}
</div>

前回紹介したForm Requestと組み合わせれば、Controller側のバリデーションロジックはそのままに、フロント側でエラー表示だけ追加すればよくなります。

Ziggyでルート生成を楽にする

コンポーネント内で route('todos.store') のようにLaravelの名前付きルートを使えるのは、ziggy というパッケージのおかげです。

Breezeのインストール時に自動で組み込まれるため、追加設定なしでVue側からもLaravelのルート名をそのまま使えます。

URLをフロントとバックエンドの2箇所にハードコーディングせずに済むため、ルート変更時の修正漏れを防げます。

まとめ

この記事のポイント

  • Inertia.jsは、API層を別途作らずにLaravel×Vueで SPA的なUXを実現できる仕組み
  • Inertia::render() でControllerからVueコンポーネントに直接propsを渡せる
  • フォーム送信後は response()->json() ではなく redirect() を返すのがInertiaの作法
  • useForm を使うとフォームの状態管理・バリデーションエラー表示がまとめて扱える

次に読むべき記事

同じTODOアプリを、今度はReactで作ったらどう変わるのか、実際に比較検証してみましょう。

→ 次の記事:Laravel×Reactの構成比較:どちらを選ぶべきか検証してみた

タイトルとURLをコピーしました