はじめに
先日、Laravel SailでLaravel 9 (PHP8.1 + node.js 16.x) 環境をサクッと立ち上げるという投稿をしました。まとめで「この環境を使って、Laravel Inertiaについて次は書いてみたいなと考えています。」と書いたので、Inertiaについてまとめたいと思います。
環境
- ホスト
- MacBook Pro (16インチ, 2019)
- macOS Monterey 12.2.1
- Docker Desktop 4.5.0
- PHP 8.1.3
- Composer 2.2.7
- ゲスト
- Laravel Sail
- PHP 8.1.3
- node.js 16.4.0
- MySQL 8.0.x
構築
1. Laravelプロジェクトの作成
composerでcreate-projectします。Laravel-CLIが好きな方はそちらで。
composer create-project laravel/laravel laravel-inertia-sample
cd laravel-inertia-sample
2. Laravel Sail
詳しくは以前の投稿(Laravel SailでLaravel 9 (PHP8.1 + node.js 16.x) 環境をサクッと立ち上げる)をご覧ください。
実行コマンド
Laravel Sailに則した実行コマンドの記述をしています。Sailを使わない方は適宜読み替えて下さい。
Laravel Sailで開発環境を起動します。
sail up
3. Vue3環境の準備
必要なnode.jsのコンポーネントをインストールします。
sail npm install
sail npm install vue@next @vue/compiler-sfc
webpack.mix.jsを変更して、Vue3をコンパイル出来るように、Laravel Mixの設定を変更します。
const mix = require('laravel-mix');
/*
|--------------------------------------------------------------------------
| Mix Asset Management
|--------------------------------------------------------------------------
|
| Mix provides a clean, fluent API for defining some Webpack build steps
| for your Laravel applications. By default, we are compiling the CSS
| file for the application as well as bundling up all the JS files.
|
*/
mix.js('resources/js/app.js', 'public/js')
.vue() // ← 追加
.postCss('resources/css/app.css', 'public/css', [
//
]);
welcome.blade.php
を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>Laravel</title>
<script src="{{ asset('js/app.js') }}" defer="defer"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
Webルートを書き換えます。
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('app');
});
次にapp.js
を書き換えます。
import { createApp } from 'vue'
import TestVue from './Components/Test.vue';
const app = createApp(TestVue)
app.mount('#app')
最後にVueファイルを作成します。
<template>
<div>Laravel + Vue3</div>
</template>
<script>
export default {
name: "Test"
}
</script>
app.js
をコンパイルします。
初めてnpm run devをすると、vue-loaderがインストールされるので、再度実行します。そうするとapp.js
とapp.css
が正しくコンパイルされた旨のメッセージがでます。
sail npm run dev
sail npm run dev
http://localhost/ にアクセスして"Laravel + Vue3"と表示されていればOKです。
4. Inertiaのセットアップ
Inertiaの公式ページを参考に進めます。まずはサーバサイドです。LaravelとRailsでの表記がありますので、もちろんLaravelのインストール方法を参考にします。
sail composer require inertiajs/inertia-laravel
app.blade.php
を以下のように書き換えます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<link href="{{ mix('/css/app.css') }}" rel="stylesheet" />
<script src="{{ mix('/js/app.js') }}" defer></script>
@inertiaHead
</head>
<body>
@inertia
</body>
</html>
ミドルウェアの設定を行います。
sail artisan inertia:middleware
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HandleInertiaRequests::class, // ← 追加
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
モデルを作成します。
sail artisan make:model --controller --factory --migration --seed Book
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
use HasFactory;
protected $fillable = [
'title',
'author',
];
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('books', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('author');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('books');
}
};
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Book>
*/
class BookFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'title' => $this->faker->words(asText: true),
'author' => $this->faker->name(),
];
}
}
<?php
namespace Database\Seeders;
use App\Models\Book;
use Illuminate\Database\Seeder;
class BookSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Book::factory(10)->create();
}
}
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
$this->call([
BookSeeder::class,
]);
}
}
マイグレーションとシーディングを行います。
sail artisan migrate --seed
レスポンスを作成します。
<?php
namespace App\Http\Controllers;
use App\Models\Book;
use Inertia\Inertia;
use Inertia\Response;
class BookController extends Controller
{
/**
* @return Response
*/
public function index(): Response
{
return Inertia::render('Index', [
'books' => Book::all([
'id',
'title',
'author',
]),
]);
}
}
続いて、クライアントサイド(Vue3側)の準備をしていきます。
sail npm install @inertiajs/inertia @inertiajs/inertia-vue3
app.js
を以下のように書き換えます。
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/inertia-vue3'
createInertiaApp({
resolve: name => require(`./Pages/${name}`),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})
ついでにプログレスインジケーターも設定してしまいましょう。
sail npm install @inertiajs/progress
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/inertia-vue3'
import { InertiaProgress } from '@inertiajs/progress'
createInertiaApp({
resolve: name => require(`./Pages/${name}`),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})
InertiaProgress.init()
Vueファイルを作成します。
<template>
<ul>
<li v-for="book in books" v-text="book.title"></li>
</ul>
</template>
<script>
export default {
props: {
books: Array,
},
}
</script>
Webルートの設定を変更します。
<?php
use App\Http\Controllers\BookController;
use Illuminate\Support\Facades\Route;
Route::get('/', [BookController::class, 'index']);
リコンパイルします。変更をwatchしておきたいので、ここではrun dev
ではなくrun watch
を使います。終了はCtrl+C
です。
sail npm run watch
http://localhost/ にアクセスします。10個の本のタイトル(ランダムなword列)が見えたらOKです。
5. BookのCRUDを実装する
現状では一覧のみしか出来ないので、最低限のCRUDを実装します。「一覧画面」「詳細画面+削除」「新規作成画面」「修正画面」の4画面の構成とします。
まずはルートと画面を準備します。
Webルートを以下のように修正します。
<?php
use App\Http\Controllers\BookController;
use Illuminate\Support\Facades\Route;
Route::inertia('/', 'Index');
Route::resource('/books', BookController::class);
Vueファイルを準備します。
<template>
<ul>
<li v-for="book in books" v-text="book.title"></li>
</ul>
</template>
<script>
export default {
props: {
books: Array,
},
}
</script>
<template>
<div>Show</div>
</template>
<script>
export default {
}
</script>
<template>
<div>Create</div>
</template>
<script>
export default {
}
</script>
<template>
<div>Edit</div>
</template>
<script>
export default {
}
</script>
コントローラーを修正します。
<?php
namespace App\Http\Controllers;
use App\Models\Book;
use Inertia\Inertia;
use Inertia\Response;
class BookController extends Controller
{
/**
* @return Response
*/
public function index(): Response
{
return Inertia::render('Books/Index', [
'books' => Book::all([
'id',
'title',
'author',
]),
]);
}
/**
* @param Book $book
* @return Response
*/
public function show(Book $book): Response
{
return Inertia::render('Books/Show');
}
/**
* @return Response
*/
public function create(): Response
{
return Inertia::render('Books/Create');
}
/**
* @param Book $book
* @return Response
*/
public function edit(Book $book): Response
{
return Inertia::render('Books/Edit');
}
}
この状態で、以下のURLにアクセスし正しく表示されるか確認します。
-
http://localhost/books
- 先ほどの http://localhost/ と同じようにタイトルが10個並んでいる
-
http://localhost/books/1
- "Show"と表示されている
-
http://localhost/books/create
- "Create"と表示されている
-
http://localhost/books/1/edit
- "Edit"と表示されている
トップページにリンクを貼ります。
<template>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/books">Books</a></li>
</ul>
</nav>
</template>
<script>
export default {
}
</script>
この状態でトップページ http://localhost/ からリンクをクリックすると、ページが遷移しますがページのリフレッシュがかかっていて、SPAになっていない事が分かります。以下のようにLinkコンポーネントを使って修正します。
<template>
<nav>
<ul>
<li><Link href="/">Home</Link></li>
<li><Link href="/books">Books</Link></li>
</ul>
</nav>
</template>
<script>
import { Link } from '@inertiajs/inertia-vue3';
export default {
components: { Link },
}
</script>
この状態でリンクをクリックすると、ページのリフレッシュはかからずにページ遷移する事を確認出来ます。
このナビゲーションを別のコンポーネント(Components/Nav.vue)にまとめて、全てのページで利用できるようにします。
<template>
<nav>
<ul>
<li><Link href="/">Home</Link></li>
<li><Link href="/books">Books</Link></li>
</ul>
</nav>
</template>
<script>
import { Link } from '@inertiajs/inertia-vue3';
export default {
components: { Link },
}
</script>
<template>
<Nav />
</template>
<script>
import Nav from '../Components/Nav';
export default {
components: { Nav },
}
</script>
Navコンポーネントは全体で利用するので、レイアウトを利用します。まずはComponents/Layout
を作成します。
<template>
<Nav />
<slot />
</template>
<script>
import Nav from './Nav';
export default {
components: { Nav },
}
</script>
app.js
を修正します。
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/inertia-vue3'
import { InertiaProgress } from '@inertiajs/progress'
import Layout from "./Components/Layout";
createInertiaApp({
resolve: name => {
let page = require(`./Pages/${name}`).default;
page.layout ??= Layout;
return page;
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})
InertiaProgress.init()
Vueファイルを修正します。
<template>
</template>
この状態で先ほどのURLにそれぞれアクセスして問題無い事を確認しておきます。
では、CRUDを実装していきます。booksのトップページに詳細ページへのリンクと、新規作成ページへのリンク、それからページネーションを追加します。
<template>
<ul>
<li v-for="book in books.data" key="book.id">
<Link :href="`/books/${book.id}`" v-text="book.title"/>
</li>
</ul>
<div>
<ul>
<li v-for="link in books.links">
<Link
v-if="link.url"
:href="link.url"
v-html="link.label"
/>
<span
v-if="!link.url"
v-html="link.label"
/>
</li>
</ul>
</div>
<div>
<Link href="/books/create">Create</Link>
</div>
</template>
<script>
import Layout from '../../Components/Layout';
import { Link } from '@inertiajs/inertia-vue3';
export default {
components: { Layout, Link },
props: {
books: Object,
},
}
</script>
このままでは動作しないのでコントローラーを以下のように修正します。
/**
* @return Response
*/
public function index(): Response
{
return Inertia::render('Books/Index', [
'books' => Book::query()->paginate(5, [
'id',
'title',
'author',
]),
]);
}
この少ない修正でページネーションが実装できました。
折角なので、Vue3.2から利用出来る<script setup>
を利用して、Index.vueを書き換えます。記述量がぐっと減り、見通しが良くなりました。
<template>
<ul>
<li v-for="book in books.data" key="book.id">
<Link :href="`/books/${book.id}`" v-text="book.title"/>
</li>
</ul>
<div>
<ul>
<li v-for="link in books.links">
<Link
v-if="link.url"
:href="link.url"
v-html="link.label"
/>
<span
v-if="!link.url"
v-html="link.label"
/>
</li>
</ul>
</div>
<div>
<Link href="/books/create">Create</Link>
</div>
</template>
<script setup>
import { Link } from '@inertiajs/inertia-vue3';
const props = defineProps({
books: Object,
});
</script>
では、新規作成ページを作ります。
<template>
<form @submit.prevent="submit">
<div>
Title:
<input v-model="form.title" type="text" name="title" id="title">
</div>
<div>
Author:
<input v-model="form.author" type="text" name="author" id="author">
</div>
<div>
<button type="submit" :disabled="form.processing">Submit</button>
</div>
</form>
</template>
<script setup>
import { useForm } from '@inertiajs/inertia-vue3';
let form = useForm({
title: '',
author: '',
});
let submit = () => {
form.post('/books');
};
</script>
コントローラーも修正します。store()
を追加します。
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Request;
// (中略)
/**
* @return RedirectResponse
*/
public function store(): RedirectResponse
{
$attributes = Request::validate([
'title' => ['required', 'max:255'],
'author' => ['required', 'max:255'],
]);
Book::create($attributes);
return redirect()->route('books.index');
}
この状態でBook一覧からCreate
リンクをクリックし、titleとauthorを入力してSubmitボタンをクリックするとBook一覧の最後に追加されるはずです。
詳細ページを作成します。
<template>
<div>
title: {{ book.title }}
</div>
<div>
author: {{ book.author }}
</div>
<div>
<Link :href="`/books/${book.id}/edit`">Edit</Link>
</div>
<div>
<Link :href="`/books/${book.id}`" method="delete">Delete</Link>
</div>
</template>
<script setup>
import {Link} from '@inertiajs/inertia-vue3';
const props = defineProps({
book: Object,
});
</script>
コントローラーを修正します。
/**
* @param Book $book
* @return Response
*/
public function show(Book $book): Response
{
return Inertia::render('Books/Show', [
'book' => $book->only([
'id',
'title',
'author',
]),
]);
}
この状態でBook一覧からタイトルをクリックすると、詳細(タイトルと作者)が表示されるはずです。
修正ページを作成します。
<template>
<form @submit.prevent="submit">
<div>
Title:
<input v-model="form.title" type="text" name="title" id="title">
</div>
<div>
Author:
<input v-model="form.author" type="text" name="author" id="author">
</div>
<div>
<button type="submit" :disabled="form.processing">Submit</button>
</div>
</form>
</template>
<script setup>
import {useForm} from '@inertiajs/inertia-vue3';
const props = defineProps({
book: Object,
});
let form = useForm({
title: props.book.title,
author: props.book.author,
});
let submit = () => {
form.patch(`/books/${props.book.id}`);
};
</script>
コントローラーを修正します。edit()
を修正、update()
を追加します。
/**
* @param Book $book
* @return Response
*/
public function edit(Book $book): Response
{
return Inertia::render('Books/Edit', [
'book' => $book->only([
'id',
'title',
'author',
]),
]);
}
/**
* @param Book $book
* @return RedirectResponse
*/
public function update(Book $book): RedirectResponse
{
$attributes = Request::validate([
'title' => ['required', 'max:255'],
'author' => ['required', 'max:255'],
]);
$book->update($attributes);
return redirect()->route('books.show', $book->id);
}
この状態で、詳細からEdit
リンクをクリックし、titleとauthorを修正してSubmitボタンをクリックすると、詳細ページに戻り修正が確認出来ます。
最後に削除を実装します。削除の画面は詳細ですので、コントローラの修正のみです。destroy()
を追加します。
/**
* @param Book $book
* @return RedirectResponse
*/
public function destroy(Book $book): RedirectResponse
{
$book->delete();
return redirect()->route('books.index');
}
この状態で、詳細画面のDelete
リンクをクリックするとデータが削除されます。今回は簡単にするため削除時の確認モーダルなどは表示せず、いきなり削除しています。
これでCRUDが実装できました。
6. TypeScript化
ついでにTypeScript化も行います。
app.js
をapp.ts
とリネームし、以下のように修正します。
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/inertia-vue3'
import { InertiaProgress } from '@inertiajs/progress'
import Layout from "./Components/Layout.vue";
createInertiaApp({
resolve: name => {
let page = require(`./Pages/${name}`).default;
page.layout ??= Layout;
return page;
},
setup({ el, app, props, plugin }) {
createApp({ render: () => h(app, props) })
.use(plugin)
.mount(el)
},
})
InertiaProgress.init()
webpack.mix.js
を以下のように修正します。
const mix = require('laravel-mix');
/*
|--------------------------------------------------------------------------
| Mix Asset Management
|--------------------------------------------------------------------------
|
| Mix provides a clean, fluent API for defining some Webpack build steps
| for your Laravel applications. By default, we are compiling the CSS
| file for the application as well as bundling up all the JS files.
|
*/
mix.ts('resources/js/app.ts', 'public/js')
.vue()
.postCss('resources/css/app.css', 'public/css', [
//
]);
tsconfig.json
をプロジェクトルート直下に配置します。
{
"compilerOptions": {
"target": "es5",
"module": "es2020",
"moduleResolution": "node",
"baseUrl": "./",
"strict": true, // Enable strict type-checking options
"skipLibCheck": true, // Skip type checking of declaration files
"noImplicitAny": false // Bypass raising errors on `any` type
},
"include": ["resources/js/**/*"] // Frontend paths pattern
}
shims-vue.d.ts
をresources/js/@types/shims-vue.d.ts
に作成します。
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
typescript関係のnpmモジュールをインストールします。
sail npm install typescript ts-loader -D
Nav.vueを修正します(script setup化が漏れてましたね)。
<template>
<nav>
<ul>
<li><Link href="/">Home</Link></li>
<li><Link href="/books">Books</Link></li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { Link } from '@inertiajs/inertia-vue3';
</script>
続いてLayout.vueも。
<template>
<Nav />
<slot />
</template>
<script setup lang="ts">
import Nav from './Nav.vue';
</script>
Index.vue, Show.vue, Edit.vue, Create.vueも修正します。
<template>
<ul>
<li v-for="book in books.data" key="book.id">
<Link :href="`/books/${book.id}`" v-text="book.title"/>
</li>
</ul>
<div>
<ul>
<li v-for="link in books.links">
<Link
v-if="link.url"
:href="link.url"
v-html="link.label"
/>
<span
v-if="!link.url"
v-html="link.label"
/>
</li>
</ul>
</div>
<div>
<Link href="/books/create">Create</Link>
</div>
</template>
<script setup lang="ts">
import { Link } from '@inertiajs/inertia-vue3';
interface BookModel {
id: number;
title: string;
author: string;
}
interface LinkModel {
url: string;
label: string;
}
interface Props {
books: {
data: Array<BookModel>,
links: Array<LinkModel>,
},
}
const props = defineProps<Props>();
</script>
<template>
<div>
title: {{ book.title }}
</div>
<div>
author: {{ book.author }}
</div>
<div>
<Link :href="`/books/${book.id}/edit`">Edit</Link>
</div>
<div>
<Link :href="`/books/${book.id}`" method="delete">Delete</Link>
</div>
</template>
<script setup lang="ts">
import {Link} from '@inertiajs/inertia-vue3';
interface BookModel {
id: number;
title: string;
author: string;
}
interface Props {
book: BookModel;
}
const props = defineProps<Props>();
</script>
<template>
<form @submit.prevent="submit">
<div>
Title:
<input v-model="form.title" type="text" name="title" id="title">
</div>
<div>
Author:
<input v-model="form.author" type="text" name="author" id="author">
</div>
<div>
<button type="submit" :disabled="form.processing">Submit</button>
</div>
</form>
</template>
<script setup lang="ts">
import {useForm} from '@inertiajs/inertia-vue3';
interface BookModel {
id: number;
title: string;
author: string;
}
interface Props {
book: BookModel;
}
const props = defineProps<Props>();
let form = useForm({
title: props.book.title,
author: props.book.author,
});
let submit = () => {
form.patch(`/books/${props.book.id}`);
};
</script>
<template>
<form @submit.prevent="submit">
<div>
Title:
<input v-model="form.title" type="text" name="title" id="title">
</div>
<div>
Author:
<input v-model="form.author" type="text" name="author" id="author">
</div>
<div>
<button type="submit" :disabled="form.processing">Submit</button>
</div>
</form>
</template>
<script setup lang="ts">
import { useForm } from '@inertiajs/inertia-vue3';
let form = useForm({
title: '',
author: '',
});
let submit = () => {
form.post('/books');
};
</script>
この状態でnpm run watch
を一度Ctrl+C
で停止してから、改めて実行しエラーが出ないこと、動作が変わりないことを確認します。
コードはこなれておらず、特にTypeScriptの型周りにリファクタリングの余地がありますが今回はここまでとします。
おわりに
特に大がかりなサイトを作ったわけでは無いのでInertia.jsの利点があまり出ていませんが、それでも通常の「APIを作って、そのI/Fをたたいてレスポンスを、、、」といった事を考えずに、さらっと作れてしまうのは今後のサイト構築の進め方を大きく変える可能性があると感じています。Inertia.jsを使ってサイト構築をしてみたい、そう思って頂ければ幸いです。