グローバルナビゲーションへ

本文へ

フッターへ

お役立ち情報Blog



Laravel×Inertia×Vue3でCRUD機能を持つSPAを作ってみた

春雨に寒さを忘れる今日この頃、みなさまはいかがお過ごしでしょうか?

私は、相変わらずバックエンドとフロントエンドを行き来するせわしない日々を送っています。

今回は、LaravelとVue3の繋ぎ役としてSPAを作る際に良きに働いてくれるInertiaをご紹介します。

Inertiaとは

公式によると、

Inertia allows you to create fully client-side rendered, single-page apps, without the complexity that comes with modern SPAs. It does this by leveraging existing server-side patterns that you already love.

Inertia has no client-side routing, nor does it require an API. Simply build controllers and page views like you’ve always done! Inertia works great with any backend framework, but it’s fine-tuned for Laravel.

クライアントサイドでのルーティングがなく、APIも必要とせず、従来通りのコントローラーやページビューを作成するサーバーサイドのパターンを利用し、その上で、シングルページアプリケーション(SPA)によく見られる複雑さを避けながら、完全にクライアントサイドでレンダリングされるSPAを作成できる、

という感じでしょうか。

前提

  • ubuntu v22.04
  • PHP v8.3
  • Laravel v10.48
  • Vite v5.1
  • Vue v3.4
  • Inertia v1.0

今回はNews(お知らせ)を題材にCRUDを作って登録や更新等の処理をみていきます。

Laravelのstarter kitsで簡単に使い始めることができるが、今回は敢えて個別でインストールする方法を取ってみます。

準備

sailでの開発環境を準備

2024年3月時点では、Laravel11がリリースされているため、今までの記事で使用していたcurl -s https://laravel.build/プロジェクト名 | bashでプロジェクトを作るとLaravel11のプロジェクトができてしまいます。
Laravel10とLaravel11ではディレクトリ構成の刷新など変更された箇所が数多くあるため、以下の手順でLaravel10のプロジェクトを作って進めていきます。

Upgrade Guide – Laravel 11.x – The PHP Framework For Web Artisans

composer create-project laravel/laravel myFirstInertia --prefer-dist "10.*"
cd myFirstInertia
composer require laravel/sail --dev
php artisan sail:install
sail up -d

モデル、マイグレーションの準備

sail php artisan make:model News -m
# database/migrations/YYYY_MM_DD_150935_create_news_table.php

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('fugas', function (Blueprint $table) {
            $table->id();
            $table->string('title'); //←追加
            $table->text('body'); //←追加
            $table->timestamps();
        });
    }
# app/Models/News.php

class News extends Model
{
    use HasFactory;

    // ↓以下4行追加
    protected $fillable = [
        'title',
        'body',
    ];

    ...省略...
sail php artisan migrate

tikerで適当なデータを5つ程入れておきます。

sail tinker

> News::create(['title'=>'a','body'=>'a'])
= App\Models\News {#6058
    title: "a",
    body: "a",
    updated_at: "2024-03-28 11:20:30",
    created_at: "2024-03-28 11:20:30",
    id: 1,
  }
> News::create(['title'=>'a','body'=>'a'])
= App\Models\News {#6037
    title: "b",
    body: "b",
    updated_at: "2024-03-28 11:36:59",
    created_at: "2024-03-28 11:36:59",
    id: 2,
  }

...省略...

コントローラーの準備

sail php artisan make:controller NewsController --resource --model=News

サーバーサイドの設定

公式に従ってサーバーサイドのInertiaの設定をしていきます。

参照:Server-side setup – Inertia.js

依存関係をインストール

sail composer require inertiajs/inertia-laravel

ルートテンプレートの作成

InertiaとLaravelでのプロジェクトは、デフォルトのルートテンプレートをapp.blade.phpとしています。

# resources/views/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" />
    @vite('resources/js/app.js')
    @inertiaHead
  </head>
  <body>
    @inertia
  </body>
</html>
  • @inertiaHead
    Inertiaを使用する上で必要なメタデータやリンク等を動的に挿入するタグ。
  • @inertia
    フロントエンドのエントリポイント作成のためのタグ。ここに アプリケーションのメインJavascriptファイル(app.js等)が読み込まれ実行される。

ミドルウェアの設定

artisanコマンドでHandleInertiaRequestsミドルウェアをアプリケーションに公開します。

sail php artisan inertia:middleware

ミドルウェアが公開されたら、Webミドルウェアグループの最後に、 HandleInertiaRequestsミドルウェアを登録します。

# app/Http/Kernel.php

'web' => [
    // ...
    \App\Http\Middleware\HandleInertiaRequests::class,
],

レスポンスの作成

# app/Http/Controllers/NewsController.php

use Inertia\Inertia;

class NewsController extends Controller
{
    public function show(News $news)
    {
        return Inertia::render('News/Show', [
            'news' => $news,
        ]);
    }
}

クライアントサイドの設定

公式に従ってクライアントサイドのInertiaの設定をしていきます。

参照:Client-side setup – Inertia.js

ViteとVue3で実装。

Laravel10では、Viteが標準のフロントエンドビルドツールとして採用されておりプロジェクトを作成すると、Viteに関連する設定ファイルが初めから含まれています。

ViteのVueプラグインをインストール

@vitejs/plugin-vueをインストールすると、それ自体でVueを依存関係として持っているため、Vueも自動的にインストールされます。

参照:Asset Bundling (Vite) – Laravel 10.x – The PHP Framework For Web Artisans

sail npm install @vitejs/plugin-vue --save-dev
// vite.config.js

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue'; //←追加

export default defineConfig({
    plugins: [
        vue(), //←追加
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

InertiaとVue3の依存関係のためのライブラリをインストール

sail npm install @inertiajs/vue3

Inertiaアプリの初期化

// resources/js/app.js

import './bootstrap';

import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'

createInertiaApp({
    resolve: (name) => {
        const pages = import.meta.glob("./Pages/**/*.vue", { eager: true });
        return pages[`./Pages/${name}.vue`];
    },
    setup({ el, App, props, plugin }) {
        createApp({ render: () => h(App, props) })
            .use(plugin)
            .mount(el);
    },
});
  • resolve
    ページコンポーネントの名前を受け取り、対応するVueコンポーネントを返す。ここではimport.meta.globを使用して、./Pages/ 内の全Vueファイルを動的にインポートして返している。
  • setup
    アプリケーションが起動する際に呼び出される。resolveで返されるコンポーネントを現在のページとしてレンダリングする。従来のVueアプリケーションで App.vue が固定のルートコンポーネントとして機能するのとは異なり、現在のルートに応じてルートコンポーネントが動的に変わる。

コンポーネントの作成

<!-- resources/js/Pages/News/Show.vue -->

<template>
    <div>
        Hello World!
    </div>
</template>

フロントエンドの開発サーバーを起動してアクセス

sail npm run dev
  • http://localhost/news/1

CRUDの実装

共通の設定

CRUD用のルートに修正します。

# routes/web.php

use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use App\Http\Controllers\NewsController; //←追加

Route::get('/', function () {
    return view('welcome');
});

// ↓削除
// Route::get('/news/{news}', function () {
//     return Inertia::render('News/Show');
// });

// ↓以下6行追加
Route::resource('news', NewsController::class)
    ->except(['store', 'update']);
Route::post('news/create', [NewsController::class, 'store'])
    ->name('news.store');
Route::put('news/{news}/edit', [NewsController::class, 'update'])
    ->name('news.update');
  • resource
    以下のルートを作成してくれる。
    参照:Laravel 10.x コントローラ
    動詞 URI アクション ルート名
    GET /news index news.index
    GET /news/create create news.create
    POST /news store news.store
    GET /news/{news} show news.show
    GET /news/{news}/edit edit news.edit
    PUT/PATCH /news/{news} update news.update
    DELETE /news/{news} destroy news.destroy
  • Route::post('news/create',Route::put('news/{news}/edit',
    store と update を除外して別途ルートを設定している。sailでの開発環境でvalidationエラー時に、元いた画面ではなく news.index にリダイレクトされてしまい、どうしても期待通りに動かなかった。なぜこうなるか、有識者の方教えてくださると幸いです。
    表の store と update のURI部分が以下のように変更される。

    動詞 URI アクション ルート名
    POST /news/create store news.store
    PUT/PATCH /news/{news}/edit update news.update

Ziggyをインストールする。公式によると、

Ziggy provides a JavaScript route() function that works like Laravel’s, making it a breeze to use your named Laravel routes in JavaScript.

tighten/ziggy: Use your Laravel routes in JavaScript.

Laravelのroute()と同じように動作するJavascriptのRoute()関数を提供しており、Javascriptで名前付きのLaravelルートを簡単に使用できるようになる、とのこと。

ZiggyもLaravelのstarter kitsでプロジェクトを作ると元からインストールされているが、今回は個別でインストールします。

sail composer require tightenco/ziggy
# resources/views/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" />
    @vite('resources/js/app.js')
    @routes //←追加
    @inertiaHead
  </head>
  <body>
    @inertia
  </body>
</html>
// vite.config.js

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import path from 'path'; //←追加

export default defineConfig({
    // ↓5行追加
    resolve: {
        alias: {
            'ziggy-js': path.resolve('vendor/tightenco/ziggy'),
        }
    },
    plugins: [
        vue(),
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});
// resources/js/app.js

import './bootstrap';

import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { ZiggyVue } from 'ziggy-js'; //←追加

createInertiaApp({
    resolve: (name) => {
        const pages = import.meta.glob("./Pages/**/*.vue", { eager: true });
        return pages[`./Pages/${name}.vue`];
    },
    setup({ el, App, props, plugin }) {
        createApp({ render: () => h(App, props) })
            .use(plugin)
            .use(ZiggyVue) //←追加
            .mount(el);
    },
});

Read

一覧画面と詳細画面とそのそれぞれの処理を追加していきます。

# app/Http/Controllers/NewsController.php

class NewsController extends Controller
{

    public function index()
    {
        $news = News::all();
        return Inertia::render('News/Index', [
            'news' => $news,
        ]);
    }
    
    ...省略...

    public function show(News $news)
    {
        return Inertia::render('News/Show', [
            'news' => $news,
        ]);
    }
    
    ...省略...

defineProps()でサーバーサイドから渡されたプロパティをコンポーネントで定義し、アクセスできるようにします。

<!-- resources/js/Pages/News/Index.vue -->

<script setup>
import { Link } from '@inertiajs/vue3'

defineProps({
    news: Object,
})
</script>

<template>
    <div v-if="news">
        <div v-for="n in news">
            <h1>{{ n.title }}</h1>
            <p>{{ n.body }}</p>
            <Link :href="route('news.show', n)">詳細</Link>
        </div>
    </div>
</template>
  • http://localhost/news

<!-- resources/js/Pages/News/Show.vue -->

<script setup>
import { Link } from '@inertiajs/vue3';

defineProps({
    news: Object,
})
</script>

<template>

    <div>
        <h1>{{ news.title }}</h1>
        <p>{{ news.body }}</p>
    </div>
    <Link :href="route('news.index')">一覧に戻る</Link>

</template>
  • http://localhost/news/1

Create

新規作成画面とその処理を追加します。

一覧画面に新規作成画面へのリンクを追加します。

<!-- resources/js/Pages/News/Index.vue -->
<script>
...省略...
</script>

<template>
    <Link :href="route('news.create')">新規作成</Link> <!-- ←追加 -->
    <div v-if="news">
        <div v-for="n in news">
            <h1>{{ n.title }}</h1>
            <p>{{ n.body }}</p>
            <Link :href="route('news.show', n)">詳細</Link>
        </div>
    </div>
</template>
# app/Http/Controllers/NewsController.php

class NewsController extends Controller
{
    ...省略...

    public function create()
    {
        return Inertia::render('News/Create', []);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => ['required'],
            'body' => ['required'],
        ]);

        News::create($validated);

        return redirect()->route('news.index');
    }
    
    ...省略...

useForm()はフォームヘルパーで一般的なフォーム送信の処理に必要な定型コードの量を減らした上でformリクエストを作成できます。

参照:Forms – Inertia.js

<!-- resources/js/Pages/News/Create.vue -->

<script setup>
import { useForm } from '@inertiajs/vue3'

const form = useForm({
    title: null,
    body: null,
})

const submit = () => {
    form.post(route('news.store'));
}
</script>

<template>
    <form @submit.prevent="submit">
        <table>
            <tr>
                <th><label for="title">title:</label></th>
                <td>
                    <div v-if="form.errors.title">{{ form.errors.title }}</div>
                    <input id="title" type="text" v-model="form.title">
                </td>
            </tr>
            <tr>
                <th><label for="body">body:</label></th>
                <td>
                    <div v-if="form.errors.body">{{ form.errors.body }}</div>
                    <textarea id="body" cols="22" rows="20" v-model="form.body"></textarea>
                </td>
            </tr>
            <tr>
                <th></th>
                <td>
                    <button type="submit" :disabled="form.processing">登録</button>
                </td>
            </tr>
        </table>
    </form>
</template>
  • http://localhost/news

  • http://localhost/news/create

バリデーションも確認。

Update

編集画面とその処理を追加します。

一覧画面に編集画面へのリンクを追加します。

<!-- resources/js/Pages/News/Index.vue -->
<script>
...省略...
</script>

<template>
    <Link :href="route('news.create')">新規作成</Link>
    <div v-if="news">
        <div v-for="n in news">
            <h1>{{ n.title }}</h1>
            <p>{{ n.body }}</p>
            <div>
                <Link :href="route('news.show', n)">詳細</Link>
            </div>
            <div>
                <Link :href="route('news.edit', n)">編集</Link> <!-- ←追加 -->
            </div>
        </div>
    </div>
</template>
# app/Http/Controllers/NewsController.php

class NewsController extends Controller
{
    ...省略...

    public function edit(News $news)
    {
        return Inertia::render('News/Edit', [
            'news' => $news,
        ]);
    }

    public function update(Request $request, News $news)
    {
        $validated = $request->validate([
            'title' =>'required',
            'body' => 'required',
        ]);

        $news->update($validated);

        return redirect()->route('news.index');
    }
    
    ...省略...
<!-- resources/js/Pages/News/Edit.vue -->

<script setup>
import { useForm } from '@inertiajs/vue3'

const props = defineProps({
    news: Object,
})

const form = useForm({
    title: props.news.title,
    body: props.news.body,
})

const submit = () => {
    form.put(route('news.update', props.news.id ))
}
</script>

<template>
    <form @submit.prevent="submit">
        <table>
            <tr>
                <th><label for="title">title:</label></th>
                <td>
                    <div v-if="form.errors.title">{{ form.errors.title }}</div>
                    <input id="title" type="text" v-model="form.title">
                </td>
            </tr>
            <tr>
                <th><label for="body">body:</label></th>
                <td>
                    <div v-if="form.errors.body">{{ form.errors.body }}</div>
                    <textarea id="body" cols="22" rows="20" v-model="form.body"></textarea>
                </td>
            </tr>
            <tr>
                <th></th>
                <td>
                    <button type="submit" :disabled="form.processing">更新</button>
                </td>
            </tr>
        </table>
    </form>
</template>
  • http://localhost/news

  • http://localhost/news/1/edit

バリデーションも確認。

更新確認

Delete

# app/Http/Controllers/NewsController.php

class NewsController extends Controller
{
    public function destroy(News $news)
    {
        $news->delete();

        return redirect()->route('news.index')->with('message', 'News Deleted Successfully');
    }
<!-- resources/js/Pages/News/Index.vue -->

<script setup>
import { Link, useForm } from '@inertiajs/vue3' //←修正

defineProps({
    news: Object,
})
// ↓以下6行追加
const form = useForm({});
const deleteNews = (news) => {
    form.delete(route('news.destroy', news), {
        preserveScroll: true,
    })
}
</script>

<template>
    <Link :href="route('news.create')">新規作成</Link>
    <div v-if="news">
        <div v-for="n in news">
            <h1>{{ n.title }}</h1>
            <p>{{ n.body }}</p>
            <div>
                <Link :href="route('news.show', n)">詳細</Link>
            </div>
            <div>
                <Link :href="route('news.edit', n)">編集</Link>
            </div>
            <div>
                <Link href="#" @click="deleteNews(n)">削除</Link> <!-- ←追加 -->
            </div>
        </div>
    </div>
</template>
  • preserveScroll
    ページ間のナビゲーション時にデフォルトのブラウザ動作を模倣してスクロール位置をページの上部に自動的にリセットするのを無効にすることができる。
    参照:Manual visits – Inertia.js
  • http://localhost/news

削除を押して確認。

おわりに

いかがでしたでしょうか?

普段慣れ親しんだLaravelでのCRUDの実装とほぼ一緒な感じでSPAを実装することができました。これならフロントエンドに精通していないバックエンドエンジニアも非常に扱いやすいのではないでしょうか。個人的にも使いやすいと感じたので実際のプロジェクトに導入するのは全然ありだなと感じました。

では、また。

この記事を書いた人

KJG
KJGソリューション事業部 システムエンジニア
大学4年時春に文系就職を辞め、エンジニアになることを決意し、独学でRuby、Ruby on Railsを学習。
約1年間の独学期間を経てアーティスへWebエンジニアとして入社。現在はWebエンジニアとして、主にシステムの開発・運用に従事している。
抽象的なもの、複雑なものを言語化して文章にするのが好きで得意。
この記事のカテゴリ

FOLLOW US

最新の情報をお届けします