こんにちは、かつコーチです。
これまで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.js | Laravelの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の構成比較:どちらを選ぶべきか検証してみた