Laravelを使って爆速でToDoアプリを立ち上げる(Laravel+Inertia+React)

ウェブアプリの作成にはいまやフレームワークは必要不可欠でしょう。Rails、Django、Laravelと使ってきましたが、Laravelが一番後発というのもあってか、一番使いやすい気がします。色んな機能がデフォルトでコミコミだし、拡張性もあるし、簡単に実装できるし、PHPなのでレンタルサーバーにアップすればOKというのも大きいと思います(Nginxいじったり、WSGIサーバー立ち上げたりとか不要)。

というわけで、Laravel使って爆速でCRUDできるアプリを作ります。

Laravel10になって(8、9あたりから?)、InertiaのおかげでReactやVueの連携が簡単だし、BreezeいれればInertia、React(Vue)とかの環境整備があっという間に終わるので、爆速でアプリの開発ができます。

備忘録も兼ねて手順を書いておきます。

ちなみに、Laravel Bootcampを参考にしています。

完成形

最初のアプリといえばToDo。SPAといえばToDo。みんな大好きToDoというわけで、ToDoアプリを作ります。仕様は以下のようなもの。

  • 新規追加すると即座に一覧に追加される。(Create)
  • 進捗「未着手」、「進行中」、「完了」が選択できる。(Read、Update)
  • 完了すると文字が取り消し線で消される。
  • いらないものを削除できる。(Delete)
  • 上記のデータをユーザーごとにデータベースで管理する(Breeze)。
  • SPA(Inertia+React)

開発環境(PHP、Composer、Node)

PHP、Composer、Nodeは入っている前提で。PHPはサーバー環境に合わせた方がいいでしょう。ただし、Laravel10はPHP8.1以上が必要です(https://readouble.com/laravel/10.x/ja/deployment.html)。私はphpenvでバージョン管理しています。

$ php -v
PHP 8.2.8 (cli) (built: Jul 31 2023 12:04:54) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.8, Copyright (c) Zend Technologies
    with Zend OPcache v8.2.8, Copyright (c), by Zend Technologies
    with Xdebug v3.2.0, Copyright (c) 2002-2022, by Derick Rethans
$ composer -V
Composer version 2.5.8 2023-06-09 17:13:21
$ node -v
v18.7.0

プロジェクト作成

まずは、CompserでLaravelのプロジェクトを作りましょう。

$ composer create-project laravel/laravel todo-app
$ cd todo-app
$ php artisan -V
Laravel Framework 10.16.1

何もしなければ今現在(2023.8.1)Laravel 10になるはず。

Breeze, Reactインストール

$ composer require laravel/breeze --dev
$ php artisan breeze:install react

これだけでInertiaも入ってます。

Database作成・設定

今回は爆速を目指すのでSQLiteで。

$ touch database/database.sqlite

.envファイルのDB_CONNECTIONDB_DATABASEを書き換えます。

DB_CONNECTION=sqlite
# DB_DATABASE=laravel

DB_DATABASEを削除しておかないと、Eloquentで呼び出すときにエラーが出ます。指定するなら絶対パスで指定しましょう。

一旦マイグレートして実行

とりあえずここまでで、一旦マイグレートしましょう。

$ php artisan migrate
$ php artisan serve

別のターミナル開いて、

$ npm run dev

127.0.0.1:8000にアクセスすると、Breezeのお陰で既に認証機能は完成しています。右上に”Register”があるので、適当にユーザー作っておきます。

作成したらダッシュボードが表示されます。もうこれだけで立派なアプリ。

Todoモデル作成

次はTodoを管理するモデルを作成します。

$ php artisan make:model -mrc Todo

-mrcオプションで、コントローラー(CRUD作成済み)、マイグレーションファイルが生成されます。ファクトリーとかポリシーとかシードとか全部コミコミで作りたかったら--allで作れるようです。

マイグレーションファイルを編集

今回はTodoアプリ用のデータベースを作成します。todoのタイトル(’title’)と進捗(’progress’)を持ったテーブルを作成します。せっかくBreezeが認証機能を作ってくれたので、’user_id’とも紐づけして自分だけのTodoリストを作れるようにします。

database/migrations/***_create_todos_table.phpが作成されているはずなので、その中身を編集します。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('todos', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();    // 👈  add
            $table->string('title');                                           // 👈  add
            $table->integer('progress')->default(0);                           // 👈  add
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('todos');
    }
};

モデルを編集

app/Models/User.php

<?php

...

use Illuminate\Database\Eloquent\Relations\HasMany;    // 👈  add

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    ...

    public function todos(): HasMany {                 // 👈  add
        return $this->hasMany(Todo::class);            // 👈  add
    }                                                  // 👈  add

}

各ユーザーは、複数のTodoを持つので、HasMany指定します。

コード中の...はコードの省略を表します。

app/Models/Todo.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;    // 👈  add

class Todo extends Model
{

    protected $fillable = [                              // 👈  add
        'title',                                         // 👈  add
        'progress',                                      // 👈  add
    ];                                                   // 👈  add

    public function user(): BelongsTo                    // 👈  add
    {                                                    // 👈  add
        return $this->belongsTo(User::class);            // 👈  add
    }                                                    // 👈  add
}

‘title’と’progress’は作成、更新するのでfillableにしておきます。TodoはUserに紐づくのでBelogsToで指定します。

一旦マイグレーション

ここまで作成したら一旦マイグレーションしてデータベースを作りましょう。

$ php artisan migrate

ルーティングを編集

ルーティングはroutes/web.phpで指定できるので、以下を追記します。既にBreezeで認証関係のものが作成されています。

<?php

...

use \App\Http\Controllers\TodoController;              // 👈  add

...

Route::resource('todo', TodoController::class)         // 👈  add
    ->only(['index', 'store', 'update', 'destroy'])    // 👈  add 
    ->middleware(['auth', 'verified']);                // 👈  add

require __DIR__.'/auth.php';

リソースコントローラーで作った(make:modelの-mrc-r。make:controllerの--resourceに同じ。)ので、Route::resourceで指定するだけ。

‘index’, ‘store’, ‘update’, ‘destory’にしかアクセスしないので制限しておきます(爆速でもセキュリティは大事)。

->middleware(['auth', 'verified'])でログイン済みのユーザーのみアクセスできるように制限をかけます。メールアドレスの検証は使っていないので、’verified’を書いても意味ないと思いますが、書いておいても通過できるので、とりあえず残しておきます(Bootcampのコピペ)。

Index作成

CRUDを作る前に、SPAの大元となる表示部分(Indexコンポーネント)を作ります。

コントローラー

app/Http/Contollers/TodoController.phpが既に作成されているので中身を編集・追記します。

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Inertia\Inertia;                                  // 👈 add
use Inertia\Response;                                 // 👈 add

class TodoController extends Controller
{
    
    public function index(): Response                 // 👈 edit
    {
        return Inertia::render('Todos/Index', []);    // 👈 add
    }

    ...
}

index()の返り値は、InertiaのResponseになります。Todos/Indexを呼び出してレンダリングします。

ビュー(Reactコンポーネント)

laravelのビューは通常bladeですが、今回はReactを使いたいので、Reactのコンポーネントを作っていきます。

Index.jsx

resources/js/Pages/に新たにTodosディレクトリを作成し、その中にIndex.jsxを作成します。コントローラーで指定したTodos/Indexにあたります。

import React from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import {Head} from '@inertiajs/react'

export default function Index({ auth }) {
    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title="Todo" />

            <div className="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
                Hello, This is Todos/Index component.
            </div>
        </AuthenticatedLayout>
    );
}

127.0.0.1:8000/todoにアクセスすると、Indexコンポーネントが読み込まれTodoのIndexページができた事がわかります。

  • AuthenticatedLayoutはresources/js/Layouts/AuthenticatedLayout.jsxを呼んでいます。ナビゲーションバーとか、作ってくれているようです。
  • Headはhtmlのheadを変更するコンポーネントかと思います。title以外にもmetaとか変えられるようです。

CSSはTailwind CSSが使われています。classNameのクラス名はTailwindのDocument参照しましょう。

ナビゲーションバー

todoのページに飛べるようにナビゲーションバーにtodoへのリンクを追加しておきましょう。

resources/js/Layouts/AuthenticatedLayout.jsxを編集します。

...

<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('todo.index')} active={route().current('todo.index')}>    // 👈  add
	    Todo                                                                       // 👈  add
        </NavLink>                                                                     // 👈  add
</div>

...

<div className="pt-2 pb-3 space-y-1">
  <ResponsiveNavLink href={route('dashboard')} active={route().current('dashboard')}>
    Dashboard
  </ResponsiveNavLink>
  <ResponsiveNavLink href={route('todo.index')} active={route().current('todo.index')}>   // 👈  add
    Todo                                                                                  // 👈  add
  </ResponsiveNavLink>                                                                    // 👈  add
</div>

...

これで土台ができたので、CRUDを順に実装していきます。

ひたすらコントローラーとビュー(Reactコンポーネント)をいじっていきます。

C(Create)作成

コントローラー

app/Http/Controllers/TodoController.phpを編集します。

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;                            // 👈 add
use Inertia\Inertia;
use Inertia\Response;

class TodoController extends Controller
{
	...

	public function store(Request $request): RedirectResponse    // 👈 edit
    {
        $validated = $request->validate([                        // 👈 add
            'title' => 'required|string|max:255',                // 👈 add
        ]);                                                      // 👈 add

        $request->user()->todos()->create($validated);           // 👈 add

        return redirect(route('todo.index'));                    // 👈 add
    }

    ...
}

Controller内のstoreを上記のように編集します。

  • $requestで受け取った値をデータベースに登録していきます。
  • 入力された値titleにバリデーションあててます。
  • 返り値の型はRedirectResponseで、todo.indexに帰ります。

ビュー(Reactコンポーネント)

Create部分を別コンポーネントで作ります。Index.jsxを編集します。

import React from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import {Head} from '@inertiajs/react'
import SubmitTodo from '@/Components/SubmitTodo';                     // 👈 add

export default function Index({ auth }) {
    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title="Todo" />

            <div className="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
                <SubmitTodo />                                       // 👈 add
            </div>
        </AuthenticatedLayout>
    );
}

仮でHello, This is Todos/Index component.と書いていた部分を<SubmitTodo />に書き換え、import文も追加します。

resources/js/Componets/SubmitTodo.jsxを作成します。

import React from 'react';
import { useForm} from '@inertiajs/react';
import InputError from "@/Components/InputError.jsx";
import PrimaryButton from "@/Components/PrimaryButton.jsx";

export default function SubmitTodo() {
    const {data, setData, post, processing, reset, errors} = useForm({
        title: '',
    });

    const submit = (e) => {
        e.preventDefault();
        post(route('todo.store'), {onSuccess: () => reset()});
    };

    return (
        <form onSubmit={submit}>
            <input
                value={data.title}
                placeholder="new todo"
                className="w-7/12 border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 rounded-md shadow-sm"
                onChange={e => setData('title', e.target.value)}
            ></input>
            <InputError message={errors.message} className="mt-2" />
            <PrimaryButton className="ml-3 mt-4" disabled={processing}>Submit</PrimaryButton>
        </form>
    );
}

Inertiaではフォームに特化したuseFormフックを使うことで、Formのあれやこれやができるようです。

  • data: 状態を保管します。初期値はuseForm({title: ''})のように与えることができます。
  • setData: dataを変更するための関数です。非同期なので注意が必要。
  • post: postする関数。他にget, put, patch, deleteがある模様。
  • processing: フォームの処理が始まると、trueに変わります。フォームの処理中に送信ボタンを無効にして、複数回の送信を防ぐのに使用できます。
  • reset: dataを初期値に戻します。一部を戻すことも可能。
  • errors: Controller側でバリデーションエラーが出た場合、フィールドとエラーメッセージが errors に返ってきます。ここでは、InputErrorコンポーネントに渡してエラーの場合のみ、メッセージを赤字表示しています。
  • https://inertiajs.com/forms参照

Submitボタンを押すとroute(‘todo.store’)宛に、datapostされます。postが成功したら、dataを消去し、それに合わせてフォームの値value={data.title}が更新されてinput内の表示が削除されます。

確認

これで、Todoを登録するためのフォームが完成しました。

動作を確認してみましょう。SubmitされたToDoを表示する部分はまだ作っていないので、画面上からは本当にSubmitされたかどうか分からないですが、Databaseを確認してみるとちゃんとDatabaseにデータが保存されていることがわかります。

R(Read)作成

本ToDoアプリでは、ToDo一個一個のデータはタイトルと進捗だけなので、個別に読み出すようなことはせず自分のToDoを一括で読み出すことをするだけです。Controllerのindexに処理を書いていきます。

コントローラー

app/Http/Controllers/TodoController.phpindexを編集します。

<?php
...

class TodoController extends Controller
{

    public function index(): Response
    {
        return Inertia::render('Todos/Index', ['todos' => \Auth::user()->todos,]);    // 👈 edit
    }

    ...

}

index内の一行を修正するだけです。['todos' => \Auth::user()->todos,]としてToDoの配列をInertia::renderに渡します。

ビュー(Reactコンポーネント)

resources/js/Pages/Todos/Index.jsxに表示部分を書いていきます。

import React from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import {Head} from '@inertiajs/react'
import SubmitTodo from '@/Components/SubmitTodo';

export default function Index({ auth, todos }) {              // 👈 edit
    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title="Todo" />

            <div className="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">

                <div className="">                            // 👈 add
                    {todos.map(todo =>                        // 👈 add
                        <Todo key={todo.id} todo={todo} />    // 👈 add
                    )}                                        // 👈 add
                </div>                                        // 👈 add

                <hr />                                        // 👈 add

                <SubmitTodo />
            </div>
        </AuthenticatedLayout>
    );
}

todosを引数として受け取って、map関数でひとつひとつのtodoをTodoコンポーネントを呼び出して処理します。ということでresources/js/Componets/Todo.jsx(Todoコンポーネント)を作成します。

import React from 'react';

export default function Todo({ todo }) {
    return (
        <div className="flex mb-3 items-center">
            <p className="w-7/12">
                {todo.title}
            </p>
        </div>
    );
}

確認

ここまでの処理を確認します。まず、既に登録済みのToDoが表示されていることがわかります。新たに追加したToDoが即座に一覧に反映されることを確認します。

U(Update)作成

進捗(todo.progress)に対してデータの書き換えを行います。未着手、進行中、完了の三択です。完了となった場合はToDoを取り消し線で消します。

コントローラー

app/Http/Controllers/TodoController.phpのupdateを編集します。

<?php

...

class TodoController extends Controller
{
    ...

    public function update(Request $request, Todo $todo): RedirectResponse    // 👈 edit
    {
        $this->authorize('update', $todo);                                    // 👈 add
        $todo->update($request->all());                                       // 👈 add
        return redirect(route('todo.index'));                                 // 👈 add
    }

    ...
}
  • 返り値の型はRedirectResponseです。
  • $this->authorize('update', $todo);はポリシーと呼ばれるもので認可制御ができます。ここでは、自分以外の人に更新されないようにしています。
  • あとはupdateしてindexに戻るだけ。

ポリシー作成

TodoController中のupdateでポリシーを当てているので、ポリシーを作ります。

$ php artisan make:policy TodoPolicy --model=Todo

app/Policies/TodoPolicy.phpが作成されるので編集します。

<?php

...

class TodoPolicy
{
    ...

    public function update(User $user, Todo $todo): bool
    {
        return $todo->user()->is($user);                   // 👈 add
    }    

    ...
}

これで、保管人が勝手に自分のToDoを変更することができなくなりました。

ビュー(Reactコンポーネント)

resource/js/Components/Todo.jsxを修正しますが、その前に今回はセレクトボックスを使って「未着手」、「進行中」、「完了」を選択できるようにしたいのですが、React-selectというライブラリがあるようなので、これを使います。

$ npm install react-select
import React from 'react';
import {useForm} from "@inertiajs/react";                        // 👈 add
import Select from 'react-select';                               // 👈 add

export default function Todo({ todo }) {

    const {data, setData, patch, processing} = useForm(todo);    // 👈 add

    const update = (e) => {                                      // 👈 add
        data.progress = e.value;                                 // 👈 add
        patch(route('todo.update', todo.id));                    // 👈 add
    }                                                            // 👈 add

    const options = [                                            // 👈 add
        { value: 0, label: '未着手'},                             // 👈 add
        { value: 1, label: '進行中'},                             // 👈 add
        { value: 2, label: '完了 '},                             // 👈 add
    ]                                                            // 👈 add

    return (
        <div className="flex mb-3 items-center">
            <p className={`w-7/12 ${data.progress === 2 && "line-through"}`}>   // 👈 edit
                {todo.title}
            </p>

            <Select                                              // 👈 add
                className="mx-2 w-3/12"                          // 👈 add
                options={options}                                // 👈 add
                defaultValue={options[data.progress]}            // 👈 add
                onChange={update}                                // 👈 add
            />                                                   // 👈 add
        </div>
    );
}
  • useForm()todoを渡して初期値にしています。
  • <Select />に必要な情報を入れていきます。optionsはセレクトボックスの選択肢です。onChangeで変更されたらupdateが呼ばれます。
  • update内でdataの値を書き換えて、バックエンド(todo.update)に処理を投げます。dataの書き換えにsetDataを使っていませんが、setDataは非同期でデータが書き換わるので、patch(route('todo.update', todo.id))が書き換え前の値(data)で処理されてしまします。ですので、data.progress=e.valueで直接書き換えてます。(いいやり方あったら教えて。)
  • todo.title<p>タグの classNameを進捗が完了に変わると取り消し線が表示されるように書き換えています。

確認

セレクトボックスの値を変更するとデータベースの値も変更されることを確認します。画面からは見えませんので、データベースを直接確認してください。もしくは、値を変更したあとページをリロードして値が保持されているか確認します。

また、完了を選択すると、ToDoが取り消し線で消されていることもわかります。

D(Delete)作成

コントローラー

最後のD(Delete)です。あと一息です。

app/Http/Controllers/TodoController.phpdestroyを編集します。

<?php
...

class TodoController extends Controller
{
    ...

    public function destroy(Todo $todo): RedirectResponse    // 👈 edit
    {
        $this->authorize('delete', $todo);                   // 👈 add
        $todo->delete();                                     // 👈 add
        return redirect(route('todo.index'));                // 👈 add
    }
}

やってることはupdateと同じです。

ポリシー編集

自分以外の人に消されないようにポリシーを適用します。app/Policies/TodoPolicy.phpを編集します。

<?php

...

class TodoPolicy
{
    ...

    public function update(User $user, Todo $todo): bool
    {
        return $todo->user()->is($user);
    }    

    public function delete(User $user, Todo $todo): bool
    {
        return $this->update($user, $todo);                  // 👈 add
    }

    ...
}

updateのコピペでもいいのですが、ここではupdateを呼んで同じ処理をするように実装しています。

ビュー(Reactコントローラー)

resource/js/Components/Todo.jsxを修正します。

import React from 'react';
import {useForm} from "@inertiajs/react";
import Select from 'react-select';
import DangerButton from "@/Components/DangerButton.jsx";                         // 👈 add

export default function Todo({ todo }) {

    const {data, setData, patch, delete: destroy, processing} = useForm(todo);    // 👈 edit

    const update = (e) => {
        data.progress = e.value;
        patch(route('todo.update', todo.id));
    }

    const destroySubmit = (e) => {                  // 👈 add
        e.preventDefault();                         // 👈 add
        destroy(route('todo.destroy', todo.id));    // 👈 add
    };                                              // 👈 add

    const options = [
        { value: 0, label: '未着手'},
        { value: 1, label: '進行中'},
        { value: 2, label: '完了 '},
    ]

    return (
        <div className="flex mb-3 items-center">
            <p className={`w-7/12 ${data.progress === 2 && "line-through"}`}>
                {todo.title}
            </p>

            <Select
                className="mx-2 w-3/12"
                options={options}
                defaultValue={options[data.progress]}
                onChange={update}
            />

            <form onSubmit={destroySubmit} className="w-2/12">                            // 👈 add
                <DangerButton className="" disabled={processing}>Delete</DangerButton>    // 👈 add
            </form>                                                                       // 👈 add
        </div>
    );
}
  • useFormdeleteprocessingを呼びだすように書き換えます。deleteはjavascript自体の予約語なので、destroyに変更します。
  • 削除用のボタンを作ってクリック時の処理destroySubmitを作成しています。

確認

Deleteボタンを押すと、ToDoが削除されるか確認します。

まとめ

以上、記事としては長くなってしまいましたが、CRUDを備えたウェブアプリ(ToDoアプリ)をLaravel + Inertia + Reactで爆速で作ってきました。Inertiaのお陰でLaraveとReactの連携が非常に簡単になったことと、そのような環境が簡単に構築できるようになったためだと思います。これからも爆速でアプリ開発しましょう。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください