Blade の画面は追えても、Inertia を入れる段階になると middleware、root template、React 側の入口、Vite の入力ファイルが同時に出てきます。どこを変えれば Inertia::render() が React コンポーネントへ届くのか、最初の 1 回は見通しを持ちにくい箇所です。
この記事では、Laravel 13 の fresh app を空ディレクトリから作り、Book 一覧ページを Inertia + React で返すところまで進めます。既存記事の成果物は前提にせず、compose.yml、.env、依存導入、artisan、tinker、route:list、npm run build、ブラウザ確認までを 1 本で通します。
対象読者: Laravel の Route / Controller / View / artisan は追えるが、Inertia + React を fresh app からどう始めるかで止まりやすい PHP 学習者・実務者。
コマンドは、特記がない限り WSL 側の Ubuntu ターミナルで実行します。Windows の PowerShell ではなく、bash 互換シェルを前提にします。
1. ゴールと非対象
この記事で到達する状態は次のとおりです。
- Laravel 13 の fresh app に
Inertia.jsとReactを導入できる HandleInertiaRequestsをbootstrap/app.phpに追加するresources/views/app.blade.phpとresources/js/app.jsxの役割を説明できる/booksでBook一覧ページを返し、tinker、route:list、ブラウザで確認できる
焦点は Inertia 導入の最小導線にあります。次の内容は対象外です。
- Breeze / Starter Kit の導入
- 認証付き画面
useFormを使った保存処理- SSR、テスト、CI、デプロイ
PostgreSQLやMySQLへの切り替え
2. 先に全体像をつかむ
作業ディレクトリは ~/projects/laravel-inertia-react-intro-demo に固定します。そこで fresh app を作り、compose.yml と .env を整え、Laravel 側と React 側の接続点を順番に埋めていく流れです。
flowchart LR
A[fresh app 作成] --> B[compose.yml と .env を整える]
B --> C[Inertia と React を導入する]
C --> D[HandleInertiaRequests を追加する]
D --> E[root template と app.jsx を用意する]
E --> F[Book 一覧ページを作る]
F --> G[migrate と tinker でデータを入れる]
G --> H[route:list / build / browser で確認する]
Blade、Livewire、Inertia の位置づけを先に整理したい場合は、LaravelでBlade / Livewire / Inertia をどう使い分けるか が補助導線になります。Vue 版の導入手順と比較したい場合は、LaravelでInertia + Vue.js を始める が補助導線です。どちらも前提ではありません。
3. fresh app と起動確認を進める
3-1. 作業ディレクトリを作成して fresh app を展開する
開始位置は ~/projects 配下です。まず作業ディレクトリを作成し、composer:2 コンテナから fresh app を展開します。
mkdir -p ~/projects/laravel-inertia-react-intro-demo
cd ~/projects/laravel-inertia-react-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 を作成する
この記事のホスト側ポートは 8000 です。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 は php artisan serve 用、node は npm install と npm run build 用です。この構成なら、ホスト側へ Node.js を直接入れなくても docker compose exec node ... だけで進められます。
3-3. .env の SQLite 設定を更新する
.env の更新箇所は次の 3 行です。DB_CONNECTION=sqlite は fresh app の既定値ですが、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
4. Inertia + React の入口をつなぐ
4-1. 依存を導入して middleware を生成する
導入コマンドは次の 4 つです。
docker compose exec app composer require inertiajs/inertia-laravel
docker compose exec node npm install react react-dom @inertiajs/react
docker compose exec node npm install -D @vitejs/plugin-react
docker compose exec app php artisan inertia:middleware
このコマンドで app/Http/Middleware/HandleInertiaRequests.php が生成されます。再実行時は Middleware already exists. と表示されるだけなので、初回導入が済んでいれば問題ありません。
4-2. bootstrap/app.php に HandleInertiaRequests を追加する
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::render() で返したページ情報を web middleware に流すための設定です。ここを入れずに root template だけ作っても、共有 props やアセットのバージョン判定が働かず、更新時の整合が崩れやすくなります。
4-3. 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.jsx'])
@inertiaHead
</head>
<body class="bg-slate-50 text-slate-900 antialiased">
@inertia
</body>
</html>
確認するのは次の 3 点です。
@vite(...)の入力ファイルをresources/js/app.jsxに合わせること@inertiaHeadを head 内に置くこと@inertiaを body の描画位置に置くこと
@inertia は React アプリがマウントされる場所です。ここが欠けると、HTML は返っても画面本体が描画されません。
4-4. React 側の入口を作成する
resources/js/app.jsx は次の内容にします。
import './bootstrap';
import '../css/app.css';
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
const appName = 'Laravel Inertia React Demo';
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
resolve: (name) =>
resolvePageComponent(
`./Pages/${name}.jsx`,
import.meta.glob('./Pages/**/*.jsx'),
),
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />);
},
progress: {
color: '#2563eb',
},
});
createInertiaApp() が React 側の入口です。Laravel 側で Inertia::render('Books/Index') を返すと、resolvePageComponent() は import.meta.glob('./Pages/**/*.jsx') の結果から一致するファイルを探し、resources/js/Pages/Books/Index.jsx を読み込みます。
4-5. vite.config.js を React 向けに更新する
vite.config.js は次の内容に更新します。
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react(),
laravel({
input: ['resources/css/app.css', 'resources/js/app.jsx'],
refresh: ['resources/views/**', 'resources/js/**', 'routes/**'],
}),
tailwindcss(),
],
});
@vitejs/plugin-react を追加したうえで、Laravel 側の入力ファイルも app.jsx にそろえる設定です。ここが app.js のままだと、npm run build のあとに Laravel が resources/js/app.jsx を探す一方で manifest には app.js 側の情報しかなくなり、Unable to locate file in Vite manifest: resources/js/app.jsx で止まります。
5. Book 一覧ページを作る
5-1. モデルと controller の雛形を作る
まず Artisan で雛形を生成します。
docker compose exec app php artisan make:model Book -m
docker compose exec app php artisan make:controller BookPageController
5-2. app/Models/Book.php を更新する
app/Models/Book.php は次のとおりです。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
protected $fillable = [
'title',
'author_name',
'price',
];
}
今回は tinker --execute から Book::query()->create(...) で 3 件投入するため、$fillable を先に設定しておきます。
5-3. books テーブルの migration を作る
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): void {
$table->id();
$table->string('title');
$table->string('author_name');
$table->unsignedInteger('price');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('books');
}
};
5-4. controller で Inertia ページを返す
app/Http/Controllers/BookPageController.php は次のとおりです。
<?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(['id', 'title', 'author_name', 'price']),
]);
}
}
Laravel 側で返す 'Books/Index' が、React 側の resources/js/Pages/Books/Index.jsx と対応します。
5-5. React ページコンポーネントを作る
先にディレクトリを作成します。
mkdir -p resources/js/Pages/Books
resources/js/Pages/Books/Index.jsx の内容は次のとおりです。
export default function Index({ books }) {
return (
<main className="min-h-screen bg-slate-50 px-6 py-12">
<div className="mx-auto max-w-4xl space-y-8">
<header className="space-y-3">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-700">
Inertia + React
</p>
<h1 className="text-3xl font-bold text-slate-900">Book 一覧</h1>
<p className="text-sm leading-7 text-slate-600">
Laravel から受け取った props を React コンポーネントで描画しています。
</p>
</header>
<section className="grid gap-4 md:grid-cols-2">
{books.map((book) => (
<article
key={book.id}
className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
>
<h2 className="text-lg font-semibold text-slate-900">{book.title}</h2>
<p className="mt-2 text-sm text-slate-600">著者: {book.author_name}</p>
<p className="mt-1 text-sm text-slate-600">
価格: {Number(book.price).toLocaleString('ja-JP')}円
</p>
</article>
))}
</section>
</div>
</main>
);
}
5-6. ルートを追加する
routes/web.php は次の内容にします。
<?php
use App\Http\Controllers\BookPageController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/books', BookPageController::class)->name('books.index');
6. 確認とつまずきやすい点
6-1. migrate、tinker、route:list、build を通す
ここまでの設定がそろったら、次の順で確認します。
docker compose exec app php artisan migrate
docker compose exec app php artisan tinker --execute="App\\Models\\Book::query()->create(['title' => 'Domain-Driven Design', 'author_name' => 'Eric Evans', 'price' => 6200]);"
docker compose exec app php artisan tinker --execute="App\\Models\\Book::query()->create(['title' => 'Refactoring', 'author_name' => 'Martin Fowler', 'price' => 5800]);"
docker compose exec app php artisan tinker --execute="App\\Models\\Book::query()->create(['title' => 'Clean Architecture', 'author_name' => 'Robert C. Martin', 'price' => 4200]);"
docker compose exec app php artisan route:list --path=books
docker compose exec app php artisan tinker --execute="dump(App\\Models\\Book::query()->orderBy('title')->get(['title', 'author_name', 'price'])->toArray());"
docker compose exec node npm run build
最初の 3 本は、Book::query()->create(...) を 1 件ずつ実行してサンプルデータを入れるコマンドです。tinker の期待結果は、そのあとにタイトル昇順で 3 件の配列が出ることです。検証では次の順になりました。
Clean ArchitectureDomain-Driven DesignRefactoring
route:list --path=books では、次のように books.index が見えれば十分です。
GET|HEAD books ........................ books.index › BookPageController
6-2. ブラウザで /books を確認する
ビルド後に次の URL を開きます。
http://localhost:8000/books
Book 一覧 の見出しと 3 冊のカードが見えれば成功です。
ブラウザを開けない環境では、HTML 側に Inertia の初期データが入っているかを curl で確認できます。
curl -s http://localhost:8000/books | sed -n '1,20p'
この出力で script data-page="app" の JSON に component":"Books\/Index" と books の配列が入っていれば、Laravel 側から React ページへ props が渡っている状態です。
6-3. 詰まりやすい点を先に切り分ける
/books が開くが画面が崩れる、または白い
まず確認する場所:
bootstrap/app.phpにHandleInertiaRequests::classを追加したかresources/views/app.blade.phpに@inertiaHeadと@inertiaがあるかresources/js/app.jsxとvite.config.jsの入力ファイル名が一致しているか
Unable to locate file in Vite manifest: resources/js/app.jsx が出る
主な原因:
npm run buildをまだ実行していないvite.config.jsのinputがresources/js/app.jsのまま残っている@vite(...)側がresources/js/app.jsxへそろっていない
MassAssignmentException が出る
主な原因:
app/Models/Book.phpに$fillableを入れていないtinkerを$fillable更新前に実行している
7. まとめ
この手順で、Laravel 13 の fresh app に Inertia + React を導入し、/books で一覧ページを返すところまで通せました。HandleInertiaRequests を web middleware に追加し、root template の @inertia と React 側の app.jsx を vite.config.js まで同じ入口にそろえると、Inertia::render() の戻り先を迷わず追えます。
次に進むなら、位置づけの整理は LaravelでBlade / Livewire / Inertia をどう使い分けるか、Vue 版との比較は LaravelでInertia + Vue.js を始める がつながりやすい導線です。フォーム送信まで進める段階では、useForm を使った保存処理を別記事で切り出すと追いやすくなります。