Laravel で画面を作るとき、先に決めたいのは「UI をどこまで PHP 側で持つか」です。Blade のまま進めるのか、Livewire を差し込むのか、Inertia で Vue / React 側へ寄せるのかで、必要な依存、確認ポイント、保守の重さが変わります。
この記事では Laravel 13 の fresh app を 1 つだけ作り、同じ Book 一覧を Blade / Livewire / Inertia の別ページとして並べます。共通の土台は 1 回だけ作り、各方式で何が増えるかを見る構成です。既存記事は補助導線として参照しますが、前提にはしません。
対象読者: Laravel の Route / Controller / View の基本は追えるが、Blade / Livewire / Inertia の選び分けで止まりやすい人。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop
composer:2コンテナnode:24コンテナ- Laravel 13
- Livewire 4
- Inertia.js + Vue 3
- SQLite
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
1. ゴールと非対象
この記事で到達する状態は次のとおりです。
Blade/Livewire/Inertiaの違いを「描画責務」と「チーム前提」で説明できる- Laravel 13 の共通ベース app に 3 つの最小ページを足す流れを追える
compose.yml、.env、artisan、tinker、route:list、画面確認で何を見るべきか分かる- 自分の画面要件なら何を選ぶか判断できる
今回は比較と最小確認に絞ります。次の内容は扱いません。
Breeze/Starter Kit全体の詳細比較Livewire Volt、Flux UI、Alpine.js の個別説明- React 版 Inertia の実装差分
- テスト、CI、SSR、デプロイ
Sanctumと組み合わせた API 設計
2. 先に結論を置く
一文で先に切ると、Blade はサーバー描画の第一候補、Livewire は Blade の中に動的 UI を差し込む選択肢、Inertia は Laravel の backend を保ったままページ単位の SPA 体験を作る選択肢です。
| 画面要件 | 選びやすい候補 | 理由 |
|---|---|---|
| 一覧・詳細・作成画面を素直に返したい | Blade | route / controller / view の流れが最も単純だから |
| Blade を保ったまま検索やモーダルを足したい | Livewire | PHP 側の component で状態を持てるから |
| Vue / React でページ全体を組みたい | Inertia | routing / auth は Laravel のまま、描画はフロントへ寄せられるから |
判断フローを図にすると次の形です。
flowchart TD
A[作りたい画面] --> B{full page reload で十分か}
B -->|yes| C[Blade]
B -->|no| D{動的なのは画面の一部か}
D -->|yes| E[Livewire]
D -->|no| F{Vue / React のページとして育てたいか}
F -->|yes| G[Inertia]
F -->|no| E
Blade から始めるのが悪いわけではありません。むしろ最初に選びやすい起点です。画面の一部だけが動的なら Livewire、ページ全体をフロントコンポーネントとして持ちたいなら Inertia と分けると迷いが減ります。
3. 共通の土台を一度だけ作る
Blade / Livewire / Inertia の違いを見やすくするため、今回は Laravel app を 1 つだけ作り、その上に 3 つのページを足して比べます。作業ディレクトリは ~/projects/laravel-choice-demo で統一し、確認 URL ごとに分ける構成です。
| 方式 | 追加するページ | 確認 URL |
|---|---|---|
Blade | /blade/books | http://localhost:8000/blade/books |
Livewire | /livewire/books | http://localhost:8000/livewire/books |
Inertia | /inertia/books | http://localhost:8000/inertia/books |
まずはこの共通土台を 1 回だけ作ります。
3-1. fresh app を作成する
mkdir -p ~/projects/laravel-choice-demo
cd ~/projects/laravel-choice-demo
code .
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .
3-2. compose.yml を作成する
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
volumes:
- ./:/app
ports:
- "8000:8000"
command: php artisan serve --host=0.0.0.0 --port=8000
この app サービスは 4 章から 6 章で共通です。
3-3. .env の該当箇所を更新する
.env の該当部分は次のとおりです。
APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
DB_DATABASE=/app/database/database.sqlite
DB_DATABASE を /app/database/database.sqlite にしておくと、コンテナ内からのパスが固定されます。
次を実行して起動してください。
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose up -d
3-4. 共通の Book model を用意する
3 方式とも同じ題材にそろえるため、Book model と migration を作ります。
docker compose exec app php artisan make:model Book -m
生成された database/migrations/*_create_books_table.php は次の内容に置き換えてください。timestamp 部分は実行時刻で変わります。
<?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',
];
}
3-5. migration と sample data を入れる
docker compose exec app php artisan migrate
docker compose exec app php artisan tinker --execute="App\\Models\\Book::query()->create(['title' => 'Laravel Intro', 'author' => 'Aki Tanaka', 'price' => 2800]); App\\Models\\Book::query()->create(['title' => 'Livewire Search UI', 'author' => 'Mika Sato', 'price' => 3400]); App\\Models\\Book::query()->create(['title' => 'Inertia Vue Guide', 'author' => 'Ken Suzuki', 'price' => 3900]);"
ここまでが共通の土台です。4章では /blade/books、5章では /livewire/books、6章では /inertia/books を同じ app に足します。
4. Blade で始める最小形
Blade は、route / controller / view で素直に画面を返したいときの第一候補です。3章で用意した ~/projects/laravel-choice-demo の中で、まずは controller を作ります。
docker compose exec app php artisan make:controller BookPageController
app/Http/Controllers/BookPageController.php は次の内容です。
<?php
namespace App\Http\Controllers;
use App\Models\Book;
use Illuminate\Contracts\View\View;
class BookPageController extends Controller
{
public function __invoke(): View
{
return view('books.index', [
'books' => Book::query()->orderBy('title')->get(),
]);
}
}
resources/views/books/index.blade.php は次の内容で作成します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Blade Books</title>
</head>
<body>
<h1>Blade で本一覧を描画する</h1>
<ul>
@foreach ($books as $book)
<li>{{ $book->title }} / {{ $book->author }} / {{ number_format($book->price) }}円</li>
@endforeach
</ul>
</body>
</html>
routes/web.php は次のとおりです。
<?php
use App\Http\Controllers\BookPageController;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/blade/books');
Route::get('/blade/books', BookPageController::class)->name('blade.books');
ルートを確認します。
docker compose exec app php artisan route:list --name=blade.books
HTTP 応答も手元で確かめておいてください。
curl http://localhost:8000/blade/books
curl すると、サーバー HTML の中にそのまま <h1>Blade で本一覧を描画する</h1> が出ます。route / controller / view を追えば、何が返るかをそのまま読めます。
ここまでできたら、ブラウザで http://localhost:8000/blade/books を開いて、3 件の本一覧が見えていることも確認してください。
Blade が向くのは、一覧・詳細・作成画面をサーバー描画で十分まわせる場面です。ページ全体の責務が PHP 側に閉じるので、最初の実装も保守の入り口も最も単純になります。
5. Livewire が向く場面
Livewire は、Blade を捨てずに画面の一部だけ動的にしたいときの選択肢です。ここでは 3章の土台に package と page を足します。
docker compose exec app composer require livewire/livewire
app/Livewire/BooksIndex.php を次の内容で作成します。
<?php
namespace App\Livewire;
use App\Models\Book;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class BooksIndex extends Component
{
public string $search = '';
public function clearSearch(): void
{
$this->reset('search');
}
public function render(): View
{
return view('livewire.books-index', [
'books' => Book::query()
->when($this->search !== '', fn ($query) => $query->where('title', 'like', '%' . $this->search . '%'))
->orderBy('title')
->get(),
]);
}
}
resources/views/livewire/books-index.blade.php は次の内容です。
<div>
<div>
<input wire:model.live="search" type="text" placeholder="タイトルで検索">
<button wire:click="clearSearch" type="button">クリア</button>
</div>
<p>検索語: {{ $search === '' ? 'なし' : $search }}</p>
<ul>
@foreach ($books as $book)
<li>{{ $book->title }} / {{ $book->author }} / {{ number_format($book->price) }}円</li>
@endforeach
</ul>
</div>
Livewire component を埋め込む wrapper page として、resources/views/livewire/books-page.blade.php は次の内容で作成します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Livewire Books</title>
@livewireStyles
</head>
<body>
<h1>Livewire で小さな動的UIを足す</h1>
<livewire:books-index />
@livewireScripts
</body>
</html>
routes/web.php には、Blade page に加えて Livewire page を足します。
<?php
use App\Http\Controllers\BookPageController;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/blade/books');
Route::get('/blade/books', BookPageController::class)->name('blade.books');
Route::view('/livewire/books', 'livewire.books-page')->name('livewire.books');
確認してください。
docker compose exec app php artisan route:list --name=livewire.books
curl http://localhost:8000/livewire/books
curl すると、サーバー HTML の中に <h1>Livewire で小さな動的UIを足す</h1> が出ます。Blade と同じように HTML を返しつつ、検索欄の wire:model.live と clearSearch() の呼び出しだけが component 経由で動く構成です。
ここまでできたら、ブラウザで http://localhost:8000/livewire/books を開き、検索欄へ入力したときに一覧が絞り込まれることも確認してください。
Livewire は、ページ全体を JS ページへ寄せるのではなく、Blade に component を差し込んで動的な部分だけ増やす構成です。検索、モーダル、簡単なインライン更新のように「画面全体を SPA にするほどではない」場面向きです。
6. Inertia へ分岐する条件
Inertia を選ぶと、Laravel 側の route / controller / middleware はそのまま使いながら、描画の主役は Vue / React に移ります。ここでは同じ app に server-side adapter と frontend build を足します。
docker compose exec app composer require inertiajs/inertia-laravel
docker compose exec app php artisan inertia: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();
root template と client entry を用意します。resources/views/app.blade.php は次の内容で作成します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@inertiaHead
</head>
<body>
@inertia
</body>
</html>
既存の resources/js/app.js は次の内容に更新してください。
import '../css/app.css'
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'
createInertiaApp({
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)
},
})
既存の resources/css/app.css は次の内容に更新します。.vue の走査対象が抜けると、Index.vue に書いた Tailwind utility class が build 後の CSS へ出ません。
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@source '../**/*.vue';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
resources/js/Pages/Books/Index.vue は次の内容で作成してください。
<script setup>
defineProps({
books: {
type: Array,
required: true,
},
})
const priceFormatter = new Intl.NumberFormat('ja-JP')
</script>
<template>
<main class="min-h-screen bg-slate-100 px-6 py-10 text-slate-900">
<div class="mx-auto max-w-3xl space-y-6">
<header class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">Inertia + Vue</p>
<h1 class="text-3xl font-semibold tracking-tight">Inertia で画面単位のSPA体験を作る</h1>
<p class="text-sm leading-6 text-slate-600">Laravel は props を返し、見た目の組み立ては Vue 側が担当します。</p>
</header>
<ul class="grid gap-3">
<li
v-for="book in books"
:key="book.id"
class="rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm shadow-slate-200/60"
>
<p class="text-lg font-medium">{{ book.title }}</p>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-slate-600">
<span>{{ book.author }}</span>
<span class="text-slate-300">/</span>
<span>{{ priceFormatter.format(book.price) }}円</span>
</div>
</li>
</ul>
</div>
</main>
</template>
Laravel fresh app に最初からある vite.config.js は、tailwindcss() を残したまま Vue plugin も足して次の内容に更新します。
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import laravel from 'laravel-vite-plugin'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
tailwindcss(),
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue(),
],
})
routes/web.php には、Blade と Livewire に加えて Inertia page を足します。
<?php
use App\Http\Controllers\BookPageController;
use App\Models\Book;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::redirect('/', '/blade/books');
Route::get('/blade/books', BookPageController::class)->name('blade.books');
Route::view('/livewire/books', 'livewire.books-page')->name('livewire.books');
Route::get('/inertia/books', function () {
return Inertia::render('Books/Index', [
'books' => Book::query()->orderBy('title')->get(['id', 'title', 'author', 'price']),
]);
})->name('inertia.books');
frontend 依存と build を追加してください。
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app node:24 npm install
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app node:24 npm install vue @vitejs/plugin-vue @inertiajs/vue3
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app node:24 npm run build
docker compose exec app php artisan route:list --name=inertia.books
curl http://localhost:8000/inertia/books
ここで Blade / Livewire と違う点が 2 つあります。
1 つ目は、Node.js と build が主線に入ることです。vite.config.js には tailwindcss() と vue() の両方が必要です。どちらかが抜けると、Index.vue に class を書いても CSS へ出なかったり、Vue page が解決できなかったりします。Index.vue に class を書いたのに背景やカードが出ないときは、resources/css/app.css に @source '../**/*.vue'; が入っているか、vite.config.js に tailwindcss() が残っているかを最初に見てください。
2 つ目は、curl した初期 HTML の見え方です。<h1>Inertia で画面単位のSPA体験を作る</h1> は HTML に直接出ず、代わりに <script data-page> の JSON に Books/Index と books props が入り、本文描画は Vue 側が担当します。Inertia を選ぶと、ここで描画責務が Laravel 側からフロント側へ切り替わります。
ここまでできたら、ブラウザで http://localhost:8000/inertia/books を開き、Vue 側で本一覧が描画されていることも確認してください。
今回は描画責務の違いを見たいので、見た目は Tailwind utility class を少し足す程度に留めています。ブラウザで開いたときに、薄い背景の上へ本一覧がカード状に並んでいれば十分です。
Inertia が向くのは、画面全体を Vue / React のページとして持ちたいときです。フォーム状態、ページ遷移、コンポーネント分割をフロント側で設計したいなら、Livewire を無理に伸ばすより整理しやすくなります。
7. チーム構成と保守性で整理する
ここまでを、画面要件とチーム前提で表にすると次のようになります。
| 観点 | Blade | Livewire | Inertia |
|---|---|---|---|
| 描画の主役 | server-side HTML | server-side HTML + component 更新 | client-side page component |
| 追加依存 | ほぼ増えない | Composer package が増える | Composer + npm package + build が増える |
| 向く画面 | シンプルな一覧、詳細、作成 | 検索、モーダル、簡単な動的 UI | ダッシュボード、複雑なフォーム、ページ単位の SPA |
| チーム前提 | PHP 中心 | PHP 中心で少し動的さが欲しい | Vue / React を触れる人がいる |
| 保守の勘所 | route / controller / view を追えばよい | wire: 属性と対応する component class の property / method を見る | route が返す props と Pages/*.vue の defineProps() を対応づけて追う |
判断をさらに短くすると、次の 3 行です。
- full page reload で問題がないなら、まず
Blade - 画面の一部だけが動的で、PHP 側の思考を保ちたいなら
Livewire - 画面全体を Vue / React コンポーネントとして育てたいなら
Inertia
保守性の観点では、「誰がどこを触るか」も重要です。PHP 中心のチームで管理画面を作るなら Blade や Livewire のほうが踏み外しにくい構成になります。逆に、画面ごとの state 管理や component 分割をフロント側で進める前提があるなら、最初から Inertia に寄せたほうが責務がはっきりする構成です。
8. まとめ
Laravel で画面を作るときは、最初に Blade を基準に考えると整理しやすくなります。full page reload で十分ならそのまま進み、部分的な動的 UI が必要になったら Livewire、ページ全体を Vue / React で持ちたいなら Inertia へ進む流れです。
判断をシナリオで言い切るなら、次の 3 つです。
- 管理画面の MVP を早く出したいなら、まず
Blade - Blade のまま検索や小さな対話を増やしたいなら、
Livewire - 画面が今後も増え、Vue / React の component として育てたいなら、最初から
Inertia
次に読む候補としては、基礎から入り直すなら Laravel入門(Route / Controller / View / Model 最小構成)、Livewire を実際に深めるなら LaravelでLivewireを始める がつながります。認証付きの fresh app を official flow で確認したい場合は Laravel 13のStarter Kitsで認証付きアプリを始める も補助導線になります。