Laravel8の認証にBreezeが登場した時にはJavaScriptの処理にはAlpine.jsが利用されていました。Laravel Breezeでも日々更新が行われ、Inertiaが利用できるようになり2021年5月にはInertiaのオプションにReactも加わりBreeze + Inertia + ReactでLaravelアプリケーションを構築することができます。ReactではなくVueを選択することも可能ですが本文書ではReactを利用した場合はBreezeの動作確認とReact + Inertia環境でのCRUD(Create, Read, Update, Delete)の実装方法について解説を行っています。

Laravel環境の構築

Laravelプロジェクトの作成

動作確認の環境を構築するためにLaravelプロジェクトの作成を行います。ここではlaravelコマンドを利用してbreeze_inertia_reactという名前でプロジェクトの作成を行っています。名前は任意なので好きなプロジェクト名を設定してください。


 % laravel new breeze_inertia_react

インストールを行ったLaravelバージョンはphp artisanコマンドで確認します。


 % cd breeze_inertia_react
 % php artisan --version
Laravel Framework 8.55.0

Laravel Breezeのインストールと初期設定

Laravelプロジェクトの作成後、composerコマンドを利用してLaravel Breezeパッケージのインストールを行います。


 % composer require laravel/breeze --dev

php artisanコマンドでbreezeのインストールを行いますがbreeze:installの後ろに何も設定していない場合はalpine.jsがインストールされます。reactを指定するとInertia.jsとReactがインストトールされます。Vueを利用したい場合はreactの代わりにvueを指定します。


 % php artisan breeze:install react

Breezeをインストール後に作成されるpackage.jsonファイルを元にJavaScriptライブラリをインストール/ビルドするために”npm install && npm run dev”コマンドを実行します。


 % npm install && npm run dev

ビルド完了後、Laravelの開発サーバの起動を行い行います。


 % php artisan serve
Starting Laravel development server: http://127.0.0.1:8000
[Sun Aug 22 09:13:43 2021] PHP 8.0.7 Development Server (http://127.0.0.1:8000) started

起動後にブラウザで127.0.0.1にアクセスするとLaravelの初期画面が表示されます。Breezeのインストールを行ったので、右上にはLog inとRegisterのリンクが表示されています。

Laravelの初期画面
Laravelの初期画面

データベースの設定

Breezeにより認証機能の追加が行われたのでユーザデータを保存すためにデータベースの設定を行います。簡易的に利用できるSQLiteを利用します。databaseフォルダの下にdatabase.sqliteファイルを作成します。


% touch database/database.sqlite

ファイルの作成が完了したら.envファイルを開いてDB_CONNECTIONの値をmysqlからsqliteに変更します。DB_CONNECTION以外の先頭にDB_がついた環境変数はすべて削除します。

設定前はDB_CONNECTIONにmysqlが設定されています。


DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=breeze_inertia_react
DB_USERNAME=root
DB_PASSWORD=

設定後と下記のようになります。


DB_CONNECTION=sqlite

.envファイルの更新が完了したらphp artisan migrateコマンドで作成したデータベースにテーブルを作成します。


 % php artisan migrate
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (3.57ms)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (2.38ms)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (1.49ms)
Migrating: 2019_12_14_000001_create_personal_access_tokens_table
Migrated:  2019_12_14_000001_create_personal_access_tokens_table (2.17ms)

ユーザの作成

データベースにusersテーブルの作成ができたのでユーザの登録を行います。先ほどアクセスしたLaravelの初期画面の右上にある”Register”をクリックしてください。ユーザの登録画面が表示されます。ユーザの登録を行ってください。

ユーザ登録画面
ユーザ登録画面

ユーザの登録が完了すると以下の画面が表示されます。

ダッシュボード画面
ダッシュボード画面

Breezeの設定理解

Breezeのインストール時にreactを指定するとどのような処理が行われるか確認するためにcomposerコマンドでlaravel/breezeパッケージをインストールした際に作成されるinstallCommand.phpファイルを確認します。 InstallCommand.phpファイルは、vendor¥laravel¥breeze¥src¥Consoleフォルダに保存されています。

InstallCommand.phpファイルを確認するとphp artisan breeze:installの引数にvue、reactを指定するか何もしていしないかで実行される処理が異なることがわかります。何も指定しない場合にはalpine.jsがインストールされることがわかります。


public function handle()
{
    if ($this->option('inertia') || $this->argument('stack') === 'vue') {
        return $this->installInertiaVueStack();
    }

    if ($this->argument('stack') === 'react') {
        return $this->installInertiaReactStack();
    }

        // NPM Packages...
        $this->updateNodePackages(function ($packages) {
            return [
                '@tailwindcss/forms' => '^0.2.1',
                'alpinejs' => '^2.7.3',
                'autoprefixer' => '^10.1.0',
                'postcss' => '^8.2.1',
                'postcss-import' => '^12.0.1',
                'tailwindcss' => '^2.0.2',
            ] + $packages;
        });
//略

reactの場合はinstallInertiaReactStack()が実行されるので関数の中身を確認してみましょう。


protected function installInertiaReactStack()
{
    // Install Inertia...
    $this->requireComposerPackages('inertiajs/inertia-laravel:^0.3.5', 'laravel/sanctum:^2.6', 'tightenco/ziggy:^1.0');

    // NPM Packages...
    $this->updateNodePackages(function ($packages) {
        return [
            '@headlessui/react' => '^1.2.0',
            '@inertiajs/inertia' => '^0.9.0',
            '@inertiajs/inertia-react' => '^0.6.0',
            '@inertiajs/progress' => '^0.2.4',
            '@tailwindcss/forms' => '^0.3.2',
            'autoprefixer' => '^10.2.4',
            'postcss' => '^8.2.13',
            'postcss-import' => '^14.0.1',
            'tailwindcss' => '^2.1.2',
            'react' => '^17.0.2',
            'react-dom' => '^17.0.2',
            '@babel/preset-react' => '^7.13.13',
        ] + $packages;
    });

    // Controllers...
    (new Filesystem)->ensureDirectoryExists(app_path('Http/Controllers/Auth'));
    (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia-common/app/Http/Controllers/Auth', app_path('Http/Controllers/Auth'));

    // Requests...
    (new Filesystem)->ensureDirectoryExists(app_path('Http/Requests/Auth'));
    (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/default/App/Http/Requests/Auth', app_path('Http/Requests/Auth'));

    // Middleware...
    $this->installMiddlewareAfter('SubstituteBindings::class', '\App\Http\Middleware\HandleInertiaRequests::class');

    copy(__DIR__.'/../../stubs/inertia-common/app/Http/Middleware/HandleInertiaRequests.php', app_path('Http/Middleware/HandleInertiaRequests.php'));

    // Views...
    copy(__DIR__.'/../../stubs/inertia-common/resources/views/app.blade.php', resource_path('views/app.blade.php'));

    // Components + Pages...
    (new Filesystem)->ensureDirectoryExists(resource_path('js/Components'));
    (new Filesystem)->ensureDirectoryExists(resource_path('js/Layouts'));
    (new Filesystem)->ensureDirectoryExists(resource_path('js/Pages'));

    (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia-react/resources/js/Components', resource_path('js/Components'));
    (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia-react/resources/js/Layouts', resource_path('js/Layouts'));
    (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia-react/resources/js/Pages', resource_path('js/Pages'));

    // Tests...
    (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/default/tests/Feature', base_path('tests/Feature'));

    // Routes...
    copy(__DIR__.'/../../stubs/inertia-common/routes/web.php', base_path('routes/web.php'));
    copy(__DIR__.'/../../stubs/inertia-common/routes/auth.php', base_path('routes/auth.php'));

    // "Dashboard" Route...
    $this->replaceInFile('/home', '/dashboard', resource_path('js/Pages/Welcome.js'));
    $this->replaceInFile('Home', 'Dashboard', resource_path('js/Pages/Welcome.js'));
    $this->replaceInFile('/home', '/dashboard', app_path('Providers/RouteServiceProvider.php'));

    // Tailwind / Webpack...
    copy(__DIR__.'/../../stubs/inertia-common/tailwind.config.js', base_path('tailwind.config.js'));
    copy(__DIR__.'/../../stubs/inertia-common/webpack.mix.js', base_path('webpack.mix.js'));
    copy(__DIR__.'/../../stubs/inertia-common/webpack.config.js', base_path('webpack.config.js'));
    copy(__DIR__.'/../../stubs/inertia-common/jsconfig.json', base_path('jsconfig.json'));
    copy(__DIR__.'/../../stubs/inertia-common/resources/css/app.css', resource_path('css/app.css'));
    copy(__DIR__.'/../../stubs/inertia-react/resources/js/app.js', resource_path('js/app.js'));

    $this->replaceInFile('.vue()', '.react()', base_path('webpack.mix.js'));
    $this->replaceInFile('.vue', '.js', base_path('tailwind.config.js'));

    $this->info('Breeze scaffolding installed successfully.');
    $this->comment('Please execute the "npm install && npm run dev" command to build your assets.');
}

updateNodePackages関数でpackage.jsonファイルの更新が行われていますがreactがインストールされていることが確認できます。それ以下のコードではFileSystemクラスのensureDirectoryExistsメソッドで指定したフォルダが存在するかチェックし、存在しない場合は作成、作成後はcopyDirectoryであらかじめstubsフォルダに保存されている情報をコピーしています。

package.jsonファイルを確認すると設定通りの内容が記述されていることがわかります。npm installを実行した際にここに記述されているJavaScriptのライブラリがインストールされることになります。inertiajs, react, tailwindcss, postcssで構成されていることがわかります。


{
//略
    "devDependencies": {
        "@babel/preset-react": "^7.13.13",
        "@headlessui/react": "^1.2.0",
        "@inertiajs/inertia": "^0.9.0",
        "@inertiajs/inertia-react": "^0.6.0",
        "@inertiajs/progress": "^0.2.4",
        "@tailwindcss/forms": "^0.3.2",
        "autoprefixer": "^10.2.4",
        "axios": "^0.21",
        "laravel-mix": "^6.0.6",
        "lodash": "^4.17.19",
        "postcss": "^8.2.13",
        "postcss-import": "^14.0.1",
        "react": "^17.0.2",
        "react-dom": "^17.0.2",
        "tailwindcss": "^2.1.2"
    }
}

ログイン処理

Laravel、IneriaとReactの組み合わせの環境下でアプリケーションの構築方法の理解を深めるためにログイン処理がどのようなに行っているか確認していきます。ログイン処理の内容を参考に後ほどCRUDのコードを記述します。

先程ユーザ登録した時にログインしているユーザを一度ログアウトしhttps://127.0.0.1/loginにアクセスするとログイン画面が表示されます。

Laravel Breezeログイン画面
Laravel Breezeログイン画面

この画面が表示される流れを見ていきます。routesフォルダにあるルーティングファイルweb.phpファイルを確認すると最終行にauth.phpファイルが読み込まれていることが確認できます。これはBreezeをインストールしたことで追加されるルーティング情報です。


//略
Route::get('/', function () {
    return Inertia::render('Welcome', [
        'canLogin' => Route::has('login'),
        'canRegister' => Route::has('register'),
        'laravelVersion' => Application::VERSION,
        'phpVersion' => PHP_VERSION,
    ]);
});

Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

require __DIR__.'/auth.php';

auth.phpファイルを確認すると/register, /login, /forgot-paswordなど認証に関するルーティングが追加されていることが確認できます。


<?php

use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\ConfirmablePasswordController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\EmailVerificationPromptController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;

Route::get('/register', [RegisteredUserController::class, 'create'])
                >middleware('guest')
                >name('register');

Route::post('/register', [RegisteredUserController::class, 'store'])
                >middleware('guest');

Route::get('/login', [AuthenticatedSessionController::class, 'create'])
                >middleware('guest')
                >name('login');
//略

auth.phpファイルの内容から/loginにアクセスを行うとAuthenticatedSessionControllerのcreateメソッドが実行されることがわかるのでAuthenticatedSessionController.phpファイルの中身を確認します。


public function create()
{
    return Inertia::render('Auth/Login', [
        'canResetPassword' => Route::has('password.request'),
        'status' => session('status'),
    ]);
}

Inertiaを利用しない場合はログイン画面の表示にBladeファイルが指定されることになりますが、Inertiaを利用している場合はInertiaクラスのrender関数にJavaScriptファイルを指定します。render関数の引数に設定されているAuth/Loginはフォルダ名とファイル名を表しており、このファイルはresoucecs¥js¥Pages¥Authフォルダに保存されているLogin.jsファイルに対応します。render関数の第2引数にcanRestPasswod, statusが設定されていますがLogin.jsファイル(Loginコンポーネント)にはpropsとしてこれらの変数を渡すことができます。

Loginコンポーネント

Login.jsファイルはReactで記述されています。Login.jsファイルの中ではログインの入力フォームの表示と入力後のPOSTリクエストまでの処理が記述されています。コード自体に難しい箇所はありませんがIinertia-reactパッケージからimportしているuseFromが気になる箇所かと思います。userFormを利用することでInertia+React環境下でフォーム処理が扱いやすくなっています。Laravelに含まれているヘルパー関数と同じようや役割を持っています。useFormは必須ではありませんが利用すると便利です。

Login.jsファイルの先頭を見るとinput要素などもコンポーネント化されimportされていることがわかります。


import React, { useEffect } from "react";
import Button from "@/Components/Button";
import Checkbox from "@/Components/Checkbox";
import Guest from "@/Layouts/Guest";
import Input from "@/Components/Input";
import Label from "@/Components/Label";
import ValidationErrors from "@/Components/ValidationErrors";
import { Head, Link, useForm } from "@inertiajs/inertia-react";

export default function Login({ status, canResetPassword }) {
    const { data, setData, post, processing, errors, reset } = useForm({
        email: "",
        password: "",
        remember: "",
    });

    useEffect(() => {
        return () => {
            reset("password");
        };
    }, []);

    const onHandleChange = (event) => {
        setData(
            event.target.name,
            event.target.type === "checkbox"
                ? event.target.checked
                : event.target.value
        );
    };

    const submit = (e) => {
        e.preventDefault();

        post(route("login"));
    };

    return (
        <Guest>
            <Head title="Log in" />

            {status && (
                <div className="mb-4 font-medium text-sm text-green-600">
                    {status}
                </div>
            )}

            <ValidationErrors errors={errors} />

            <form onSubmit={submit}>
                <div>
                    <Label forInput="email" value="Email" />

                    <Input
                        type="text"
                        name="email"
                        value={data.email}
                        className="mt-1 block w-full"
                        autoComplete="username"
                        isFocused={true}
                        handleChange={onHandleChange}
                    />
                </div>

                <div className="mt-4">
                    <Label forInput="password" value="Password" />

                    <Input
                        type="password"
                        name="password"
                        value={data.password}
                        className="mt-1 block w-full"
                        autoComplete="current-password"
                        handleChange={onHandleChange}
                    />
                </div>

                <div className="block mt-4">
                    <label className="flex items-center">
                        <Checkbox
                            name="remember"
                            value={data.remember}
                            handleChange={onHandleChange}
                        />

                        <span className="ml-2 text-sm text-gray-600">
                            Remember me
                        </span>
                    </label>
                </div>

                <div className="flex items-center justify-end mt-4">
                    {canResetPassword && (
                        <Link
                            href={route("password.request")}
                            className="underline text-sm text-gray-600 hover:text-gray-900"
                        >
                            Forgot your password?
                        </Link>
                    )}

                    <Button className="ml-4" processing={processing}>
                        Log in
                    </Button>
                </div>
            </form>
        </Guest>
    );
}

useFormについて

Inertia+React環境でどのようにPOSTを送信するのか、エラーはどのように戻されるのか等フォーム処理に関して不明なことがあるかと思いますのがuseFormを利用することで簡単に行えるようになります。useFormを活用するために利用方法を理解する必要があるのでLogin.jsファイルを見ながら説明していきます。

Login.jsファイルの中でuseFormに関する以下のコードを確認することができます。


const { data, setData, post, processing, errors, reset } = useForm({
    email: "",
    password: "",
    remember: "",
});

useFormの引数にはログインフォームのinput要素に入力した値を保存する変数の初期値をオブジェクトで指定することができます。ログインフォームには2つのinput要素と1つのチェックボックスが存在するため3つの変数の初期値を定義しています。

設定した変数はdataを通して取得することができます。dataの中にemail, password, remerberの値が保存されているためemailのInputコンポーネントを見るとvalueの値にdata.emailが指定されています。


<Input
    type="text"
    name="email"
    value={data.email}
    className="mt-1 block w-full"
    autoComplete="username"
    isFocused={true}
    handleChange={onHandleChange}
/>

InputコンポーネントではpropsのhandleChangeが設定されています。Inputコンポーネントの中身を確認するとonChangeイベントが設定されているので文字を入力する度にonHandleChange関数が実行されることになります。

onHandleChange関数ではuseFormのsetDataを利用してinput要素に入力した値の更新を行っています。setDataは第一引数に更新を行う名前、第二引数には値を設定します。名前にはuseFormの引数で指定したオブジェクトのプロパティ名を指定します。input要素のtype属性がcheckboxとtextの場合で取得する場所が異なるためtype属性で分岐を行っています。


const onHandleChange = (event) => {
    setData(
        event.target.name,
        event.target.type === "checkbox"
            ? event.target.checked
            : event.target.value
    );
};

setDataによって更新されたdataはsubmit関数のpost関数によって/loginに送信されます。post関数の処理はuseFormの中で実行されるためPOSTリクエストは送信先を設定するだけで完了です。


const submit = (e) => {
    e.preventDefault();

    post(route("login"));
};

processingはリクエスト処理を処理中かどうか確認するために利用することができます。デフォルトの値はfalseで、POSTリクエスト送信後からPOST処理が完了するまでprocessingの値はtrueとなります。processingの値をbutton要素のdesabled属性で利用します。processingをpropsでButtonコンポーネントに渡すことでButtonコンポーネントにあるbutton要素のdisabled属性の値を設定しています。processingがtrueの場合は処理中なのでbuttonがクリックできないようにdisabled属性の値とtrueにしています。

errorsにはPOSTリクエストのバリデーションに失敗した場合のエラー情報が保存されます。ValidationErrorsコンポーネントにpropsでerrorsを渡しpropsのerrorsが値を持つ場合はmap関数を利用してエラーをブラウザ上に表示できるように設定されています。

resetはuseEffect Hookのクリーンアップ処理でpasswordに対して実行されているのでLoginコンポーネントがアンマウントされる時にinput要素に入力したpasswordの値をuseFormで指定したpasswordの初期値にリセットしています。

POSTリクエストの処理

POSTリクエストは/loginに送信され、AuthenticatedSessionController.phpファイルのstoreメソッドが実行されます。storeメソッドの中では認証の処理が行われ問題がなければRouteServiceProvider::HOMEで指定された場所にリダイレクトされます。


public function store(LoginRequest $request)
{
    $request->authenticate();

    $request->session()->regenerate();

    return redirect()->intended(RouteServiceProvider::HOME);
}

RouteServiceProviderの中でリダイレクト先は/dashboardに設定されています。


class RouteServiceProvider extends ServiceProvider
{

    public const HOME = '/dashboard';
//略

CRUDの処理

ログイン処理を確認することで入力フォームのデータの扱い方やPOSTリクエストの送信方法、コントローラー内での処理方法を理解することができました。ここからはログイン処理を参考にCreate, Read, Update, Delete処理をLaravel+Inertia+React環境でどのように記述していくかを確認していきます。

Blogモデルの作成

動作確認を行うために利用するBlogモデルはtitle, contentの2つの列を持つシンプルなブログを想定しています。php artisan make:modelコマンドにオプション-aをつけて実行することでモデル、ファクトリー、マイグレーション、コントロラーファイルを一括で作成します。ファクトリーファイルはダミーデータを登録する際に利用します。


 % php artisan make:model Blog -a
Model created successfully.
Factory created successfully.
Created Migration: 2021_08_22_042002_create_blogs_table
Seeder created successfully.
Controller created successfully.

テーブルの作成

テーブルにはtitleとcontet列のみ追加します。titleはブログのタイトルなのでデータタイプをstring, contentはブログの内容を保存するのでデータタイプをtextに設定します。


public function up()
{
    Schema::create('blogs', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->text('content');
        $table->timestamps();
    });
}

php artisan migrateコマンドでテーブルの作成を行います。


 % php artisan migrate 
Migrating: 2021_08_22_042002_create_blogs_table
Migrated:  2021_08_22_042002_create_blogs_table (5.39ms)

Blogテーブルへのデータ登録ができるようにBlog.phpファイルに追加した列の情報を追加します。


class Blog extends Model
{
    use HasFactory;
    
    protected $fillable = [
        'title',
        'content'
    ];
}

ルーティングの追加

/blogsにアクセスするとブラウザ上にブログ一覧が表示されるようにルーティングファイルweb.phpにルーティングを追加します。


use App\Http\Controllers\BlogController;
//
Route::get('/blogs', [BlogController::class, 'index'])
    ->name('blog.index')
    ->middleware('auth');

/blogsにアクセスするとBlogControllerのindexメソッドが実行されます。nameメソッドでルーティングに名前を付けています。リンク設定時に付与した名前を利用することで/blogsのURLが変わってもリンクの設定側での設定変更が不必要になります。設定方法については後ほど実際にリンクを設定時に確認します。middlewareでauthを設定することでアクセス制限を行っています。ログインが完了していないユーザには/blogsにアクセスすることはできません。

middlewareの行を削除することでログインが完了していないユーザでもアクセスすることが可能になります。


use App\Http\Controllers\BlogController;
//
Route::get('/blogs', [BlogController::class, 'index'])
    ->name('blog.index');

リンクの設定

ログイン後には/dashboardにリダイレクトすることを確認したのでダッシュボードページから/blogsにアクセスできるようにリンクの設定を行います。

/dashboardにアクセスした場合に表示される内容はどのように記述されているのかはweb.phpファイルのルーティングを使って確認することができます。web.phpに記述されているルーティングからDashboardがrenderされていることがわかります。これはresouces¥js¥Pagesフォルダの下にあるDashboard.jsファイルに対応します。


Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

Dashboard.jsファイルを開くとAuthenticatedコンポーネントによって包まれていることがわかります。Authenticatedファイルの内容については本文書では説明しませんがログインしたユーザに表示されるページのレイアウトファイルとして利用されます。ヘッダーやフッターなどの情報がこのファイルに含まれています。


import React from 'react';
import Authenticated from '@/Layouts/Authenticated';
import { Head } from '@inertiajs/inertia-react';

export default function Dashboard(props) {
    return (
        <Authenticated
            auth={props.auth}
            errors={props.errors}
            header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Dashboard</h2>}
        >
            <Head title="Dashboard" />

            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                        <div className="p-6 bg-white border-b border-gray-200">You're logged in!</div>
                    </div>
                </div>
            </div>
        </Authenticated>
    );
}

Authenticated.jsファイルを開いてNavLinkタブを見つけてください。これがダッシュボードの上部に表示されているDashboadへのリンクです。NavLinkを参考にweb.phpファイルのnameメソッドで指定したblog.indexを使ってNavLinkを追加します。


<div className="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
    <NavLink
        href={route("dashboard")}
        active={route().current("dashboard")}
    >
        Dashboard
    </NavLink>
    <NavLink
        href={route("blog.index")}
        active={route().current("blog.index")}
    >
        Blog
    </NavLink>
</div>

NavLinkを追加すると上部のDashboardの横に設定したBlogが表示されます。更新したにも関わらずブラウザ上の表示が変わらない場合はnpm run watchコマンドを実行してください。jsファイルを更新した時はリビルドが必要となるため忘れずに実行してください。

Blogリンクが表示
Blogリンクが表示

ここでBlogのリンクをクリックすると画面上には白のモーダルウィンドウが表示されます。これはBlogControllerのindexメソッドに何もコードが記述されていないためです。

Indexページの作成

BlogControllerのindexメソッドが実行されるとrender関数を使ってBlog/Indexの内容が表示されるように設定を行います。


//略
use Inertia\Inertia; 

class BlogController extends Controller
{

    public function index()
    {
        return Inertia::render('Blog/Index');
    }
//略

指定したBlog/Indexに対応するIndex.jsファイルは未作成なので¥resources¥js¥Pagesの下にBlogフォルダを作成し、その下にIndex.jsファイルを作成します。Index.jsファイルはDashBoard.jsを参考に下記のように記述します。


import React from "react";
import Authenticated from "@/Layouts/Authenticated";
import { Head } from "@inertiajs/inertia-react";

export default function Index(props) {
    return (
        <Authenticated
            auth={props.auth}
            errors={props.errors}
            header={
                <h2 className="font-semibold text-xl text-gray-800 leading-tight">
                    Blog
                </h2>
            }
        >
            <Head title="Blog Index" />

            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                        <div className="p-6 bg-white border-b border-gray-200">
                            Blog Index
                        </div>
                    </div>
                </div>
            </div>
        </Authenticated>
    );
}

設定後再度blogのリンクをクリックすると設定した内容が表示されます。

BlogのIndexページの表示
BlogのIndexページの表示

ダミーデータの挿入

/blogsにアクセスするとブログ一覧が表示されるようにblogテーブルにダミーデータの登録を行います。ダミーデータを挿入するためのSeedingに必要なFactoryファイルはモデル作成時に一緒に作成しているのでdatabase¥factoriesフォルダの中にBlogFactory.phpファイルが作成されているはずです。BlogFactory.phpファイルを開いてdefinition関数にダミーデータの定義を行います。


public function definition()
{
    return [
        'title' => $this->faker->sentence,
        'content' => $this->faker->text,
    ];
}

定義が完了したらdatabase¥seedersフォルダにあるDatabaseSeeder.phpファイルに下記の設定を行います。Seedingを実行すると10件のダミーデータがBlogfactory.phpファイルで定義した内容によって作成されます。


public function run()
{
    \App\Models\Blog::factory(10)->create();
}

設定が完了したらphp artisan db:seedコマンドでダミーデータの挿入を行います。


 % php artisan db:seed
Database seeding completed successfully.

Blogデータの一覧表示

blogテーブルに保存したデータはBlogController.phpファイルのindexメソッドの中で取得してpropsとしてBlog/Indexに渡します。


public function index()
{
    return Inertia::render('Blog/Index',['blogs' => Blog::all()]);
}

propsで受け取ったblogsはmap関数を利用して展開しテーブル要素として表示させます。


<div className="p-6 bg-white border-b border-gray-200">
    <table>
        <thead>
            <tr>
                <th>タイトル</th>
                <th>コンテンツ</th>
            </tr>
        </thead>
        <tbody>
            {props.blogs.map((blog) => {
                return (
                    <tr key={blog.id}>
                        <td className="border px-4 py-2">
                            {blog.title}
                        </td>
                        <td className="border px-4 py-2">
                            {blog.content}
                        </td>
                    </tr>
                );
            })}
        </tbody>
    </table>
</div>

ブラウザで確認するとBlogデータの一覧が表示されることが確認できます。

Blogデータ一覧表示
Blogデータ一覧表示

IneratiaとReactを利用してブラウザ上にデータを表示させる方法を理解することができました。

データの作成

一覧表示したデータはLaravelのSeeding機能を利用して一括で作成しましたがここから入力フォームを利用してデータの作成ができるようにコードを追加していきます。

ルーティングの追加

入力フォームのページを表示するためにはルーティングを追加する必要があります。ルーティングファイルweb.phpを開いて/blogsで設定していたルーティングをgetメソッドだけではなくpost, delete, put, updateメソッドでも利用できるようにgetからreroucesに変更を行い、createメソッドを追加します。


Route::resource('/blogs', BlogController::class)
    ->names(['index'=>'blog.index',
            'create' => 'blog.create'])
    ->middleware(['auth']);

新規作成ボタンを追加

Blog/Index.jsファイルに新規作成ボタンを追加し、ボタンをクリックすると入力フォームのページに移動するようにリンクを設定します。リンク先はルーティングファイルに追加したBlogControlllerのcreateメソッドです(/blogs/create)。

新規作成ボタンはログイン画面で利用していたButtonコンポーネントを再利用します。リンクについてもinertia-reactのLinkを利用します。ボタンはtable要素の上に追加します。利用するコンポーネントは忘れずにimportしておきます。


//略
import Button from "@/Components/Button";
import { Link } from "@inertiajs/inertia-react";

//略
<div className="p-6 bg-white border-b border-gray-200">
    <div>
        <Link href={route("blog.create")}>
            <Button type="button">新規作成</Button>
        </Link>
    </div>
    <table>
//略

設定が完了するとBlog/Indexのデータ一覧のテーブルの上に新規作成ボタンが表示されます。新規作成ボタンをクリックすることはできますがクリックしても行き先であるBlogController.phpファイルにcreateメソッドの中身が記述されていないため白のモーダルウィンドウが表示されます。

新規作成ボタンの表示
新規作成ボタンの表示

createページの作成

BlogController.phpファイルを開いてcreateメソッドを追加します。createメソッドが実行されるとrender関数で指定しているBlog/Createの中身が表示されます。


public function create()
{
    return Inertia::render('Blog/Create');
}

renderメソッドで指定したBlog/Createに対応するCreate.jsファイルをIndex.jsファイルと同様にresouces¥js¥Pages¥Postの下に作成します。Index.jsファイルを元にCreate.jsファイルを作成します。


import React from "react";
import Authenticated from "@/Layouts/Authenticated";
import { Head } from "@inertiajs/inertia-react";
import Button from "@/Components/Button";

export default function Index(props) {
    return (
        <Authenticated
            auth={props.auth}
            errors={props.errors}
            header={
                <h2 className="font-semibold text-xl text-gray-800 leading-tight">
                    Blog
                </h2>
            }
        >
            <Head title="Blog Create" />
            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                        <div className="p-6 bg-white border-b border-gray-200">
                          新規作成ページ
                        </div>
                    </div>
                </div>
            </div>
        </Authenticated>
    );
}

作成後に新規作成ボタンをクリックすると下記のページが表示されます。

新規作成ページ
新規作成ページ

入力フォームの追加

作成したCreate.jsファイルに入力フォームを追加します。Login.jsファイルで入力フォームの作り方を確認していたのでそこで使われていたコンポーネントを利用しながら入力フォームを作成します。Login.jsファイルを元に作成していきます。


import React from "react";
import Authenticated from "@/Layouts/Authenticated";
import { Head, useForm } from "@inertiajs/inertia-react";
import Input from "@/Components/Input";
import Label from "@/Components/Label";
import Button from "@/Components/Button";
import ValidationErrors from "@/Components/ValidationErrors";

export default function Create(props) {
    const { data, setData, post, processing, errors } = useForm({
        title: "",
        content: "",
    });

    const onHandleChange = (event) => {
        setData(event.target.name, event.target.value);
    };

    const submit = (e) => {
        e.preventDefault();

        post(route("blog.store"));
    };

    return (
        <Authenticated
            auth={props.auth}
            errors={props.errors}
            header={
                <h2 className="font-semibold text-xl text-gray-800 leading-tight">
                    Blog
                </h2>
            }
        >
            <Head title="Blog Create" />
            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                        <div className="p-6 bg-white border-b border-gray-200">
                            <ValidationErrors errors={errors} />
                            <form onSubmit={submit}>
                                <div>
                                    <Label forInput="title" value="Title" />

                                    <Input
                                        type="text"
                                        name="title"
                                        value={data.title}
                                        className="mt-1 block w-full"
                                        isFocused={true}
                                        handleChange={onHandleChange}
                                    />
                                </div>
                                <div>
                                    <Label forInput="content" value="Content" />

                                    <Input
                                        type="text"
                                        name="content"
                                        value={data.content}
                                        className="mt-1 block w-full"
                                        handleChange={onHandleChange}
                                    />
                                </div>
                                <div className="flex items-center justify-end mt-4">
                                    <Button
                                        className="ml-4"
                                        processing={processing}
                                    >
                                        作成
                                    </Button>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </Authenticated>
    );
}

contentはtextarea要素を使う必要がありますがここではinput要素を利用しています。Textareaのコンポーネントを作成したい場合はInputコンポーネントを参考に作成してください。

ブラウザで確認すると入力フォームが表示されます。

入力フォーム作成
入力フォーム作成

作成したCreate.jsファイルの中身を確認していきます。

Login.jsと同様にinertia-reactのuseFormを利用しています。blogデータを作成するために必要となるデータはtitle, contentなのでuseFormの引数にtitleとcontentを持つオブジェクトを設定しています。初期値はどちらもブランクにしています。


const { data, setData, post, processing, errors } = useForm({
    title: "",
    content: "",
});

input要素にはInputコンポーネントを利用しています。useFormのdataにはtitleとcontentの値が入っているのでdata.titleでtitleの値にアクセスを行えるためvalueの値として設定しています。input要素に文字を入力するとonHandleChange関数が実行されます。propsのisFocusedをtureに設定するとページを開いた時にこの要素に自動でフォーカスが当てられます。


<Input
    type="text"
    name="title"
    value={data.title}
    className="mt-1 block w-full"
    isFocused={true}
    handleChange={onHandleChange}
/>

onHandleChange関数ではuseFormのsetDataを利用してtitle, contentの値を更新することができます。setDataは第一引数に更新を行う名前、第二引数には値を設定します。名前はuseFormの引数で指定したオブジェクトのプロパティ名を指定します。


const onHandleChange = (event) => {
    setData(event.target.name, event.target.value);
};

onSubmitイベントが設定されているのでボタンをクリックするとsubmit関数が実行されます。


<form onSubmit={submit}>

submitを実行すると通常はページのリロードが行わます。ページのリロードを避けるためpreventDefaultを設定しています。useFormのpostにPOSTリクエストを送信する場所を指定します。useFormを利用しているためpost関数を使うことでtitle, contentを含むデータがPOSTリクエストと一緒に送信されます。送信先のblog.storeはこの後に作成します。


const submit = (e) => {
    e.preventDefault();

    post(route("blog.store"));
};

作成ボタンにはButtonコンポーネントを利用しています。Buttonコンポーネントのtype属性の値のデフォルト値はsubmitなのでボタンをクリックするとonSubmitイベントが発火されます。useFromのprocessingを利用することでPOSTリクエストの処理中はボタンがクリックできないように無効化しています。無効化により誤って複数回クリックするといったことが起こらないようになっています。


<Button
    className="ml-4"
    processing={processing}
>
    作成
</Button>

useFormのerrorsとValidationErrorsコンポーネントを使いバリデーションエラーが発生した場合はエラーが表示されるようになっています。


<ValidationErrors errors={errors} />

データの作成処理

submit関数で指定したpost.storeのルーティングの追加を行います。ルーティングファイルweb.phpファイルを開いてstoreメソッドを追加します。


Route::resource('/blogs', BlogController::class)
    ->names([
            'index'=>'blog.index',
            'create' => 'blog.create',
            'store' => 'blog.store'
            ])
    ->middleware(['auth']);

BlogController.phpファイルにstoreメソッドを追加します。


public function store(Request $request)
{

    $request->validate([
        'title' => ['required'],
        'content' => ['required']
    ]);

    Blog::create($request->all());

    return redirect()->route('blog.index');
}

validateを使って送信されてきたデータのバリデーションを行います。どちらもrequiredに設定しているのでどちらも入力が必須となります。バリデーションをパスするとblogデータを作成し、データが一覧表示されるページのblog.indexにリダイレクトさせます。

入力フォームのinput要素に入力
入力フォームのinput要素に入力

作成ボタンをクリックするとリダイレクトが行われ最後の行に追加したデータが表示されることが確認できます。

データ一覧に追加されたデータ
データ一覧に追加されたデータ

もしinput要素に何も入力せず作成ボタンをクリックした場合はバリデーションエラーが表示されます。

バリデーションエラー表示
バリデーションエラー表示

データ削除

データ作成の機能を追加することができたので次は削除機能を追加します。Blog/Index.jsファイルのデータ一覧のテーブルに削除列と削除ボタンを追加します。ここではButtonコンポーネントは利用していません。


<table>
    <thead>
        <tr>
            <th>タイトル</th>
            <th>コンテンツ</th>
            <th>削除</th>
        </tr>
    </thead>
    <tbody>
        {props.blogs.map((blog) => {
            return (
                <tr key={blog.id}>
                    <td className="border px-4 py-2">
                        {blog.title}
                    </td>
                    <td className="border px-4 py-2">
                        {blog.content}
                    </td>
                    <td className="border px-4 py-2">
                        <button
                                                               className="px-4 py-2 bg-red-500 text-white rounded-lg text-xs font-semibold"
                        >
                            削除
                        </button>
                    </td>
                </tr>
            );
        })}
    </tbody>
</table>

ブラウザで確認すると削除ボタンが表示されます。

削除ボタンを追加
削除ボタンを追加

削除ボタンにonClickイベントを設定しhandleDelete関数を設定します。引数にはどのデータを削除するのか識別するためにidを設定します。


<button
    className="px-4 py-2 bg-red-500 text-white rounded-lg text-xs font-semibold"
    onClick={() =>
        handleDelete(
            blog.id
        )
    }
>
    削除
</button>

handleDele関数の追加を行います。データを削除を行うためにDELETEリクエストを送信する必要があります。POSTリクエストでuseFormのpost関数を利用したようにDELETEリクエスト場合はuseFormのdestory関数を利用することができます。destory関数の第一引数にはroute関数を使ってリクエストの送信先であるblog.destoryと削除するデータのidを設定しています。第2引数は必須ではありませんがpreserveScrollのtrueを設定することでデータ削除後にリダイレクトされてまた同じページに戻った時にスクロール位置を削除ボタンを押した場所に戻してくれます。もし設定していない場合はページのトップから表示されることになります。


import React from "react";
import Authenticated from "@/Layouts/Authenticated";
import { Head } from "@inertiajs/inertia-react";
import Button from "@/Components/Button";
import { Link, useForm } from "@inertiajs/inertia-react";

export default function Index(props) {
    const { delete: destory } = useForm();
    const handleDelete = (id) => {
        destory(route("blog.destroy", id), {
            preserveScroll: true,
        });
    };
//略

リクエスト先であるblog.destoryをルーティングファイルweb.phpに追加します。


Route::resource('/blogs', BlogController::class)
    ->names([
            'index'=>'blog.index',
            'create' => 'blog.create',
            'store' => 'blog.store',
            'destroy' => 'blog.destroy'
            ])
    ->middleware(['auth']);

追加したdestroyメソッドをBlogController.phpファイルに追加します。destroyメソッドの中ではblogデータを削除してblog.indexにリダイレクトしています。


public function destroy(Blog $blog)
{
    $blog->delete();

    return redirect()->route('blog.index');

}

設定完了後ブラウザ上から削除ボタンをクリックするとクリックした行のデータが削除されます。削除処理の実装は完了です。

データの更新処理

データの更新処理を行うためBlog/Index.jsファイルのテーブル要素に新たに更新ボタンを追加します。


<table>
    <thead>
        <tr>
            <th>タイトル</th>
            <th>コンテンツ</th>
            <th>更新</th>
            <th>削除</th>
        </tr>
    </thead>
    <tbody>
        {props.blogs.map((blog) => {
            return (
                <tr key={blog.id}>
                    <td className="border px-4 py-2">
                        {blog.title}
                    </td>
                    <td className="border px-4 py-2">
                        {blog.content}
                    </td>
                    <td className="border px-4 py-2">
                        <button className="px-4 py-2 bg-green-500 text-white rounded-lg text-xs font-semibold">
                            更新
                        </button>
                    </td>
                    <td className="border px-4 py-2">
                        <button
                            className="px-4 py-2 bg-red-500 text-white rounded-lg text-xs font-semibold"
                            onClick={() =>
                                handleDelete(
                                    blog.id
                                )
                            }
                        >
                            削除
                        </button>
                    </td>
                </tr>
            );
        })}
    </tbody>
</table>

ブラウザで確認すると削除ボタンの横に更新ボタンが表示されます。

更新ボタン表示
更新ボタン表示

更新ボタンをクリックするとblogデータを更新するための入力フォームが表示できるように更新ボタンにLinkコンポーネントを利用してリンクを設定します。


<Link
    href={route(
        "blog.edit",
        blog.id
    )}
>
    <button className="px-4 py-2 bg-green-500 text-white rounded-lg text-xs font-semibold">
        更新
    </button>
</Link>

リンク先のblog.editをルーティングに追加します。


Route::resource('/blogs', BlogController::class)
    ->names([
            'index'=>'blog.index',
            'create' => 'blog.create',
            'store' => 'blog.store',
            'destroy' => 'blog.destroy',
            'edit' => 'blog.edit'
            ])
    ->middleware(['auth']);

BlogController.phpファイルにeditメソッドを追加します。render関数で指定したBlog/Editにpropsでblogデータを渡しています。


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

Blog/Editに対応するファイルEdit.jsをresouces¥js¥Pages¥Blogの下に作成します。Create.jsファイルを元に作成します。 中身はCreate.jsとほとんど変わりませんがtitleとcontentの初期値はpropsで渡されるblogの値を利用しています。Create.jsの場合はsubmit関数の中でuseFormのpost関数を利用していましがが更新なのでpost関数ではなくpatch関数を利用しています。


import React from "react";
import Authenticated from "@/Layouts/Authenticated";
import { Head, useForm } from "@inertiajs/inertia-react";
import Input from "@/Components/Input";
import Label from "@/Components/Label";
import Button from "@/Components/Button";
import ValidationErrors from "@/Components/ValidationErrors";

export default function Edit(props) {
    const { data, setData, patch, processing, errors } = useForm({
        title: props.blog.title,
        content: props.blog.content,
    });

    const onHandleChange = (event) => {
        setData(event.target.name, event.target.value);
    };

    const submit = (e) => {
        e.preventDefault();

        patch(route("blog.update", props.blog.id));
    };

    return (
        <Authenticated
            auth={props.auth}
            errors={props.errors}
            header={
                <h2 className="font-semibold text-xl text-gray-800 leading-tight">
                    Blog
                </h2>
            }
        >
            <Head title="Blog Edit" />
            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                        <div className="p-6 bg-white border-b border-gray-200">
                            <ValidationErrors errors={errors} />
                            <form onSubmit={submit}>
                                <div>
                                    <Label forInput="title" value="Title" />

                                    <Input
                                        type="text"
                                        name="title"
                                        value={data.title}
                                        className="mt-1 block w-full"
                                        isFocused={true}
                                        handleChange={onHandleChange}
                                    />
                                </div>
                                <div>
                                    <Label forInput="content" value="Content" />

                                    <Input
                                        type="text"
                                        name="content"
                                        value={data.content}
                                        className="mt-1 block w-full"
                                        handleChange={onHandleChange}
                                    />
                                </div>
                                <div className="flex items-center justify-end mt-4">
                                    <Button
                                        className="ml-4"
                                        processing={processing}
                                    >
                                        更新
                                    </Button>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </Authenticated>
    );
}

submit関数のpatch関数で指定した送信先のblog.patchを追加する必要があります。ルーティングファイルのweb.phpファイルを開いてupdateメソッドを追加してください。


Route::resource('/blogs', BlogController::class)
    ->names([
            'index'=>'blog.index',
            'create' => 'blog.create',
            'store' => 'blog.store',
            'destroy' => 'blog.destroy',
            'edit' => 'blog.edit',
            'update' => 'blog.update',
            ])
    ->middleware(['auth']);

BlogController.phpファイルにupdateメソッドを追加します。送信されてきたデータのバリデーションを行い、バリデーションにパスしたらデータを更新し、blog.indexにリダイレクトしています。


public function update(Request $request, Blog $blog)
{

    $request->validate([
        'title' => ['required'],
        'content' => ['required']
    ]);

    $blog->update($request->all());

    return redirect()->route('blog.index');
}

BlogController.phpファイルへのupdateメソッドの追加後、データ一覧ページから先程作成したデータの更新ボタンをクリックしてください。

更新画面
更新画面

TitleまたはContetの内容を変更して更新ボタンをクリックしてください。

ここではTitleとContetのどちらも更新したのでリダイレクト後にデータ一覧ページで更新されたデータを確認することができます。

データ更新後の画面
データ更新後の画面

これでアプリケーションを構築する際のCRUDをLaravel+ Inertia + React環境で行えるようになりました。Inertia + Reactでどのようにコードを記述すればいいのか基本的な理解は深まったと思うのでぜひより大規模なアプリケーションの作成にチャレンジしてみてください。