Blade の画面は追えるが、Inertia を足す段階になると middleware、root template、Vue 側の入口、Vite build が同時に出てきます。この記事では Laravel 13 の fresh app を空ディレクトリから作り、Book 一覧ページを Inertia + Vue 3 で返すところまでの手順をまとめます。
既存記事の成果物を前提にしない構成です。compose.yml、.env、composer require、npm install、artisan、route:list、tinker、画面確認をひと通り通し、Laravel 側と Vue 側がどこでつながるかを確認します。
対象読者: Laravel の Route / Controller / View / artisan は追えるが、Inertia + Vue を fresh app からどう始めるかで止まりやすい PHP 学習者・実務者。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop
composer:2コンテナnode:24コンテナ- Laravel 13
- Inertia.js 3
- Vue 3
- SQLite
コマンドは、特記がない限り WSL 側ターミナルで実行します。
1. ゴールと非対象
この記事で到達する状態は次のとおりです。
- Laravel 13 の fresh app に
Inertia.jsとVue 3を導入できる HandleInertiaRequestsをbootstrap/app.phpへ追加するresources/views/app.blade.phpとresources/js/app.jsの役割を説明できる/booksでBook一覧ページを返し、route:list、tinker、ブラウザで確認できる
焦点は Inertia 導入の最小導線です。次の内容は扱いません。
Breeze/ Starter Kit の導入- 認証付き画面
useFormを使った保存処理- React 版 Inertia
- SSR、テスト、CI、デプロイ
2. 先に全体像をつかむ
作業ディレクトリは ~/projects/laravel-inertia-vue-intro-demo にそろえます。そこで fresh app を作り、compose.yml で app と node を起動し、Laravel 側と Vue 側の接続点を順番に埋めるのがこの記事の流れです。
flowchart LR
A[fresh app 作成] --> B[compose.yml と .env を整える]
B --> C[Inertia と Vue を導入する]
C --> D[HandleInertiaRequests を追加する]
D --> E[root template と app.js を差し替える]
E --> F[Book 一覧ページを作る]
F --> G[migrate と db:seed]
G --> H[route:list / tinker / browser で確認]
「なぜ Blade / Livewire / Inertia を分けるのか」を先に整理したい場合は、LaravelでBlade / Livewire / Inertia をどう使い分けるか を補助導線として読むと位置づけがつかみやすくなります。今回の主題は比較ではなく、Inertia を fresh app へつなぐ最小手順です。
3. 本題を進める
3-1. 作業ディレクトリを作成して fresh app を用意する
開始位置は ~/projects 配下です。まず作業ディレクトリを作成し、composer:2 コンテナから fresh app を展開します。
mkdir -p ~/projects/laravel-inertia-vue-intro-demo
cd ~/projects/laravel-inertia-vue-intro-demo
code .
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .
-u "$(id -u):$(id -g)" を付けているのは、生成ファイルの所有者を WSL 側の自分にそろえるためです。ここを外すと、あとで composer require や npm install をしたときに root 所有が混ざりやすくなります。
3-2. compose.yml を作成し、.env の SQLite パスを固定する
compose.yml は次の内容で作成します。
services:
app:
image: composer:2
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
working_dir: /app
environment:
HOME: /tmp
XDG_CONFIG_HOME: /tmp/.config
COMPOSER_HOME: /tmp/.composer
volumes:
- ./:/app
ports:
- "8000:8000"
command: sh -lc "php artisan serve --host=0.0.0.0 --port=8000"
node:
image: node:24-bookworm
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
working_dir: /app
volumes:
- ./:/app
command: sh -lc "tail -f /dev/null"
app は Laravel の内蔵サーバー用、node は npm install と vite build 用です。node を常駐させ、必要なときだけ docker compose exec node ... で入る形にすると、ホスト側へ Node.js を直接入れずに進められます。
.env の該当箇所は次のように更新してください。Laravel 13 の fresh app では DB_CONNECTION=sqlite が既定値ですが、DB_DATABASE はコメントアウトされていました。コンテナ内から見えるパスへ固定するため、コメントを外しておきます。
APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
DB_DATABASE=/app/database/database.sqlite
起動は次のコマンドです。
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose up -d
curl -I http://localhost:8000
- 期待結果:
HTTP/1.1 200 OK
ここでは Laravel の Welcome 画面が返れば十分です。Bind for 0.0.0.0:8000 failed: port is already allocated が出る場合は、compose.yml の左側ポートと APP_URL を一緒に 8010 などへ変えてください。
3-3. Inertia と Vue の依存を導入し、middleware を生成する
導入コマンドは次の 4 つです。
docker compose exec app composer require inertiajs/inertia-laravel
docker compose exec node npm install vue @inertiajs/vue3
docker compose exec node npm install -D @vitejs/plugin-vue
docker compose exec app php artisan inertia:middleware
この手順で app/Http/Middleware/HandleInertiaRequests.php が生成されます。npm install vue @inertiajs/vue3 の時点で既定の npm 依存も一緒に入るため、別途 npm install を先に打つ手順は不要です。
Laravel 13 の fresh app には tailwindcss と @tailwindcss/vite が最初から含まれています。この記事で追加するのは @inertiajs/vue3、vue、@vitejs/plugin-vue だけです。
3-4. bootstrap/app.php と root template を Inertia 用に更新する
HandleInertiaRequests を Web middleware へ追加します。bootstrap/app.php は次の内容です。
<?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
続いて、Inertia の最初の描画で使う root template を用意します。resources/views/app.blade.php は次の内容で作成してください。
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>{{ config('app.name', 'Laravel') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@inertiaHead
</head>
<body class="bg-slate-50 text-slate-900 antialiased">
@inertia
</body>
</html>
確認するのは 3 点です。
HandleInertiaRequestsを Web middleware へ追加すること- root template 側で
@inertiaHeadを置くこと @inertiaを描画位置に置くこと
どれかが欠けると、/books を開いても白い画面のまま止まるか、Vite 読み込みエラーで崩れます。ブラウザの DevTools を開き、コンソールとネットワークで app.js と CSS が読めているかを確認してください。
3-5. resources/js/app.js と vite.config.js を更新する
Vue 側の入口を差し替えます。resources/js/app.js は次の内容です。
import './bootstrap';
import '../css/app.css';
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
const appName = 'Laravel Inertia Vue Demo';
createInertiaApp({
title: (title) => title ? title + ' - ' + appName : appName,
resolve: (name) =>
resolvePageComponent(
'./Pages/' + name + '.vue',
import.meta.glob('./Pages/**/*.vue'),
),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
progress: {
color: '#2563eb',
},
});
vite.config.js は Vue plugin を追加して次の内容へ更新します。
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue(),
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: [
'resources/views/**',
'resources/js/**',
'routes/**',
],
}),
tailwindcss(),
],
server: {
watch: {
ignored: ['**/storage/framework/views/**'],
},
},
});
createInertiaApp() が Vue 側の入口です。resolvePageComponent() で resources/js/Pages/**/*.vue を解決し、Laravel 側の Inertia::render('Books/Index') とつながります。
この例では BookPageController 側で orderBy('title') を入れているため、画面の左右順は ID 順ではありません。スクリーンショットで Book 2 が左端に出ていても正常です。
3-6. Book 一覧ページとサンプルデータを作る
まず Book model と controller を作ります。
docker compose exec app php artisan make:model Book -m
docker compose exec app php artisan make:controller BookPageController
生成された migration の timestamp 部分は実行時刻で変わるため、database/migrations/*_create_books_table.php を次の内容に置き換えてください。
<?php
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('books', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('author');
$table->unsignedInteger('price');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('books');
}
};
app/Models/Book.php は次の内容です。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
protected $fillable = [
'title',
'author',
'price',
];
}
app/Http/Controllers/BookPageController.php は Laravel 側で Book 一覧を取得し、Inertia へ props として渡します。
<?php
namespace App\Http\Controllers;
use App\Models\Book;
use Inertia\Inertia;
use Inertia\Response;
class BookPageController extends Controller
{
public function __invoke(): Response
{
return Inertia::render('Books/Index', [
'books' => Book::query()
->orderBy('title')
->get()
->map(fn (Book $book): array => [
'id' => $book->id,
'title' => $book->title,
'author' => $book->author,
'price' => $book->price,
])
->values()
->all(),
]);
}
}
routes/web.php は次の内容です。
<?php
use App\Http\Controllers\BookPageController;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/books');
Route::get('/books', BookPageController::class)->name('books.index');
resources/js/Pages/Books/Index.vue は次の内容で作成します。
<script setup>
import { Head } from '@inertiajs/vue3';
defineProps({
books: {
type: Array,
required: true,
},
});
const formatPrice = (price) => new Intl.NumberFormat('ja-JP').format(price);
</script>
<template>
<Head title="Books" />
<main class="min-h-screen bg-slate-50 px-6 py-10 text-slate-900">
<div class="mx-auto max-w-4xl space-y-8">
<section class="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-slate-200">
<p class="text-sm font-semibold uppercase tracking-[0.2em] text-blue-700">Laravel 13 + Inertia.js + Vue 3</p>
<h1 class="mt-3 text-3xl font-bold">Inertia で本一覧ページを返す</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
ルーティングとデータ取得は Laravel 側に寄せたまま、画面描画だけを Vue コンポーネントへ切り出した最小構成です。
</p>
</section>
<section class="grid gap-4 md:grid-cols-3">
<article
v-for="book in books"
:key="book.id"
class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200"
>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Book {{ book.id }}</p>
<h2 class="mt-3 text-lg font-semibold">{{ book.title }}</h2>
<p class="mt-2 text-sm text-slate-600">著者: {{ book.author }}</p>
<p class="mt-4 text-sm font-medium text-blue-700">{{ formatPrice(book.price) }}円</p>
</article>
</section>
</div>
</main>
</template>
サンプルデータは tinker --execute に長い配列を直接書くより、seeder のほうが手順を固定しやすく、シェルのクォート崩れも避けやすくなります。database/seeders/BookSeeder.php を次の内容で作成してください。
<?php
namespace Database\Seeders;
use App\Models\Book;
use Illuminate\Database\Seeder;
class BookSeeder extends Seeder
{
public function run(): void
{
Book::query()->delete();
Book::query()->create([
'title' => 'Laravel Starter',
'author' => 'Aki Tanaka',
'price' => 2800,
]);
Book::query()->create([
'title' => 'Inertia Handbook',
'author' => 'Mika Sato',
'price' => 3600,
]);
Book::query()->create([
'title' => 'Vue Page Patterns',
'author' => 'Ken Suzuki',
'price' => 4200,
]);
}
}
3-7. build、route:list、tinker、ブラウザで確認する
ここまでできたら、次の順で確認します。
docker compose exec app php artisan migrate
docker compose exec app php artisan db:seed --class=BookSeeder
docker compose exec app php artisan route:list --path=books
docker compose exec app php artisan tinker --execute="dump(App\\Models\\Book::query()->count());"
docker compose exec node npm run build
route:list の期待結果は次の 1 行です。
GET|HEAD books ........................ books.index › BookPageController
tinker の期待結果は 3 です。
3 // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:1
最後にブラウザで http://localhost:8000/books を開いてください。3 件のカードが並び、Laravel Starter、Inertia Handbook、Vue Page Patterns が見えれば成功です。
4. 確認とつまずきやすい点
確認では次の 3 点を見てください。
php artisan route:list --path=booksでbooks.indexが出るphp artisan tinker --execute="dump(App\\Models\\Book::query()->count());"が3を返すhttp://localhost:8000/booksで 3 件のカードが見える
詰まりやすい点は次の 3 つです。
DB_DATABASE のコメント解除を忘れる
Laravel 13 の fresh app では DB_CONNECTION=sqlite は有効ですが、DB_DATABASE はコメントアウトされています。コンテナ内パスへ固定していないと、別の SQLite ファイルを参照して books テーブルが見つからない、または tinker の件数確認で 0 が返る、といった症状が出ます。
npm run build を忘れて /books が崩れる
今回は npm run dev を常駐させず、npm run build で public/build を作る流れです。route:list は通るのに画面が崩れる場合は、まず docker compose exec node npm run build を打ち直してください。
tinker --execute に長い配列を直接書く
複数レコードを tinker --execute へそのまま流し込むと、WSL とシェルのクォートが崩れやすくなります。データ投入は seeder に寄せ、tinker は件数確認や単発の確認に使うほうが進めやすくなります。
5. まとめ
Inertia 導入で最初に見るべき接続点は 4 つです。Laravel 側では HandleInertiaRequests と route / controller、描画側では root template と resources/js/app.js です。この 4 点を正しく設定すると、Laravel が渡した props を Vue 側で受け取り、Blade から一気に離れなくてもページ単位のコンポーネントを返せるようになります。
次に判断軸を整理したいなら LaravelでBlade / Livewire / Inertia をどう使い分けるか、認証付きの API とつなげたいなら LaravelでSanctum認証APIを作る を続けて読むと流れがつながります。