ウェブアプリの作成にはいまやフレームワークは必要不可欠でしょう。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_CONNECTION
とDB_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
で作れるようです。
- リソースコントローラ:https://readouble.com/laravel/10.x/ja/controllers.html#resource-controllers
- モデル作成:https://readouble.com/laravel/10.x/ja/eloquent.html#generating-model-classes
マイグレーションファイルを編集
今回は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’)宛に、data
がpost
されます。post
が成功したら、data
を消去し、それに合わせてフォームの値value={data.title}
が更新されてinput
内の表示が削除されます。
確認
これで、Todoを登録するためのフォームが完成しました。
動作を確認してみましょう。SubmitされたToDoを表示する部分はまだ作っていないので、画面上からは本当にSubmitされたかどうか分からないですが、Databaseを確認してみるとちゃんとDatabaseにデータが保存されていることがわかります。
R(Read)作成
本ToDoアプリでは、ToDo一個一個のデータはタイトルと進捗だけなので、個別に読み出すようなことはせず自分のToDoを一括で読み出すことをするだけです。Controllerのindex
に処理を書いていきます。
コントローラー
app/Http/Controllers/TodoController.php
のindex
を編集します。
<?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.php
のdestroy
を編集します。
<?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>
);
}
useForm
でdelete
とprocessing
を呼びだすように書き換えます。delete
はjavascript自体の予約語なので、destroy
に変更します。- 削除用のボタンを作ってクリック時の処理
destroySubmit
を作成しています。
確認
Deleteボタンを押すと、ToDoが削除されるか確認します。
まとめ
以上、記事としては長くなってしまいましたが、CRUDを備えたウェブアプリ(ToDoアプリ)をLaravel + Inertia + Reactで爆速で作ってきました。Inertiaのお陰でLaraveとReactの連携が非常に簡単になったことと、そのような環境が簡単に構築できるようになったためだと思います。これからも爆速でアプリ開発しましょう。