
九保すこひです(フリーランスのITコンサルタント、エンジニア)
さてさて、この間エックス(旧Twitter
)を見ていたら、とある知らないテクノロジーの話題 が流れてきました。
それが・・・・・・
htmx(えいち・てぃー・えむ・えっくす)
です。
本家のサイト: htmx – high power tools for html
とてもおおざっぱに言うと「JavaScript を書かなくてもいろんなことできるよ」がコンセプトのようで、例えばリンクにhx-get="(ここにURL)"
をセットするだけでAjax
アクセスしてくれたりします。
(コンセプトとしてはlivewire
とかに近いのかもしれませんね…)
そして、個人的には最近こういった技術に触れていなかったので、「ちょっとやってみようかな!」という気分になりました。
そこで
今回はこの「htmx + Laravel」を使ってCRUD
機能を作ってみることにしました。
ぜひ何かの参考になりましたら嬉しいです
※ ちなみにほぼ同じなのでCRUD
のR
(表示)は省略してます。釣りタイトルみたいになっちゃってゴメンナサイ
「今年も猛暑らしいので、
梅雨前ぐらいに色んなとこへ
でかけようかな 」
開発環境: Laravel 10.x、htmx 1.9.10
目次 [非表示]
モデル&マイグレーション&Seederをつくる
では、今回はテストデータとして「お酒」をテーマにつくっていくことにします。使うお酒は以下の10個です。(こういうときChatGPT
便利ですね)
- ビール
- ワイン
- シャンパン
- 日本酒
- ウイスキー
- バーボン
- ウイスキー
- ラム
- テキーラ
- ジン
では、以下のコマンドを実行してください。
php artisan make:model AlcoholicBeverage -ms
すると、ファイル3つ作成されるので中身をそれぞれ以下のようにします。
モデル
app/Models/AlcoholicBeverage.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AlcoholicBeverage extends Model
{
use HasFactory;
protected $fillable = [
'name',
'alcohol_content'
];
}
マイグレーション
database/migrations/****_**_**_******_create_alcoholic_beverages_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('alcoholic_beverages', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('お酒の名前');
$table->string('alcohol_content')->comment('アルコール度数');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('alcoholic_beverages');
}
};
Seeder
database/seeders/AlcoholicBeverageSeeder.php
<?php
namespace Database\Seeders;
use App\Models\AlcoholicBeverage;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class AlcoholicBeverageSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$alcoholic_beverages = [
['name' => 'ビール', 'alcoholContent' => '4%~6%'],
['name' => 'ワイン', 'alcoholContent' => '9%~16%'],
['name' => 'シャンパン', 'alcoholContent' => '8%~12%'],
['name' => '日本酒', 'alcoholContent' => '15%~17%'],
['name' => 'ウイスキー', 'alcoholContent' => '40%~50%'],
['name' => 'バーボン', 'alcoholContent' => '40%~50%'],
['name' => 'ラム', 'alcoholContent' => '37.5%~80%'],
['name' => 'テキーラ', 'alcoholContent' => '35%~55%'],
['name' => 'ジン', 'alcoholContent' => '37.5%~47%']
];
foreach($alcoholic_beverages as $alcoholic_beverage) {
AlcoholicBeverage::create([
'name' => $alcoholic_beverage['name'],
'alcohol_content' => $alcoholic_beverage['alcoholContent']
]);
}
}
}
次のこのAlcoholicBeverageSeeder
をDatabaseSeeder
へ登録します。
database/seeders/DatabaseSeeder.php
<?php
namespace Database\Seeders;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// 省略
$this->call([
AlcoholicBeverageSeeder::class, //
ここ
]);
}
}
では、この状態で一度データベースを初期化してみましょう。
以下のコマンドを実行してください。
php artisan migrate:fresh --seed
すると、実際のテーブルはこうなりました。
コントローラーをつくる
続いて、コントローラーをつくります。
以下のコマンドを実行してください。
php artisan make:controller AlcoholicBeverageController
するとファイルが作成されるので、中身を次のようにします。
app/Http/Controllers/AlcoholicBeverageController.php
<?php
namespace App\Http\Controllers;
use App\Http\Requests\AlcoholicBeverageRequest;
use App\Models\AlcoholicBeverage;
use Illuminate\Http\Request;
class AlcoholicBeverageController extends Controller
{
public function index()
{
return view('alcoholic_beverage.index');
}
public function list()
{
$alcoholic_beverages = AlcoholicBeverage::latest()->paginate();
return view('alcoholic_beverage.list', [
'alcoholic_beverages' => $alcoholic_beverages
]);
}
public function create()
{
return view('alcoholic_beverage.create');
}
public function store(AlcoholicBeverageRequest $request)
{
$alcoholic_beverage = new AlcoholicBeverage();
$alcoholic_beverage->name = $request->name;
$alcoholic_beverage->alcohol_content = $request->alcohol_content;
$alcoholic_beverage->save();
return response()
->view('alcoholic_beverage.create', [
'status' => 'success',
])
->withHeaders([
'X-Response-Status' => 'success',
]);
}
public function show(AlcoholicBeverage $alcoholic_beverage)
{
return view('alcoholic_beverage.show', [
'alcoholic_beverage' => $alcoholic_beverage
]);
}
public function edit(AlcoholicBeverage $alcoholic_beverage)
{
return view('alcoholic_beverage.edit', [
'alcoholic_beverage' => $alcoholic_beverage,
'name' => $alcoholic_beverage->name,
'alcohol_content' => $alcoholic_beverage->alcohol_content,
]);
}
public function update(AlcoholicBeverageRequest $request, AlcoholicBeverage $alcoholic_beverage)
{
$alcoholic_beverage->name = $request->name;
$alcoholic_beverage->alcohol_content = $request->alcohol_content;
$alcoholic_beverage->save();
return response()
->view('alcoholic_beverage.edit', [
'status' => 'success',
'alcoholic_beverage' => $alcoholic_beverage,
'name' => $alcoholic_beverage->name,
'alcohol_content' => $alcoholic_beverage->alcohol_content,
])
->withHeaders([
'X-Response-Status' => 'success',
]);
}
public function destroy(AlcoholicBeverage $alcoholic_beverage)
{
// 削除権限のチェックは省略しています
$alcoholic_beverage->delete();
return response('', 204)
->withHeaders([
'X-Response-Status' => 'success',
]);
}
}
なお、今回使うhtmx
は通常のLaravel
とは違って「HTMLを返す」ようにしなければいけないため、destroy()
以外はビューを返すように設計しています。
また、これもhtmx
への対応として「登録」「更新」「削除」が成功した場合は、ヘッダー「X-Response-Status」にsuccess
をセットしています。
これは、後のバリデーションのところに関わってくるのですが、バリデーションに失敗したときに422を返してしまうと、htmx
がDOM
を更新しないようになっているため、常に200
を返さないといけない(他の200
番台でもOKなのですがなんかロジカルじゃない)ので、この独自ヘッダーで「成功」or「失敗」が判別できるようにしています。
ビューをつくる
次に、コントローラー内でセットした各ビューをつくっていきます。
以下のコマンドを実行してください。
php artisan make:view alcoholic_beverages.index
php artisan make:view alcoholic_beverage.list
php artisan make:view alcoholic_beverages.create
php artisan make:view alcoholic_beverages.edit
すると、4つのビューが作成されるので中身を以下のように変更します。
index のビュー
resources/views/alcoholic_beverage/index.blade.php
<html>
<head>
<title>htmx を使った CRUD サンプル</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdn.tailwindcss.com/3.4.1"></script>
</head>
<body>
<div class="p-5">
<h1 class="text-3xl mb-5">htmx を使った CRUD サンプル</h1>
<div class="mb-4">
<a
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
hx-get="{{ route('alcoholic_beverage.create') }}"
hx-target="#form">
新規作成
</a>
</div>
<div class="flex gap-5">
<div class="flex-1">
<table
id="table"
hx-get="{{ route('alcoholic_beverage.list') }}"
hx-trigger="load, reload"
class="border border-collapse w-full">
</table>
</div>
<div class="flex-1">
<div id="form"></div>
</div>
</div>
</div>
<script>
document.addEventListener('htmx:configRequest', function(event) {
event.detail.headers['X-CSRF-TOKEN'] = '{{ csrf_token() }}'; // CSRF トークンをセット
});
document.addEventListener('htmx:afterRequest', (e) => { // Ajax リクエストが実行されたとき
const shouldUpdateTable = e.target.id === 'form' || e.target.classList.contains('delete-button');
if(shouldUpdateTable === true) { // 登録・更新・削除した場合
const xhr = e.detail.xhr;
const status = xhr.getResponseHeader('X-Response-Status');
if(status === 'success') { // 実行が成功した場合
htmx.trigger('#table', 'reload'); // テーブルを更新
}
}
});
</script>
</body>
</html>
ちなみに、ここで先ほどの「X-Response-Status」ヘッダーを取り出し、実行が成功したのか失敗したのかを判別しています。
list のビュー
resources/views/alcoholic_beverage/list.blade.php
<thead>
<tr>
<th class="border border-gray-500 bg-gray-100 p-1">名前</th>
<th class="border border-gray-500 bg-gray-100 p-1">度数</th>
<th class="border border-gray-500 bg-gray-100 p-1">操作</th>
</tr>
</thead>
<tbody>
@foreach($alcoholic_beverages as $alcoholic_beverage)
<tr>
<td class="border border-gray-500 p-3">{{ $alcoholic_beverage->name }}</td>
<td class="border border-gray-500 p-3">{{ $alcoholic_beverage->alcohol_content }}</td>
<td class="border border-gray-500 p-3 text-center w-48">
<a
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-2"
hx-get="{{ route('alcoholic_beverage.edit', ['alcoholic_beverage' => $alcoholic_beverage]) }}"
hx-target="#form">
編集
</a>
<a
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded delete-button"
hx-delete="{{ route('alcoholic_beverage.destroy', ['alcoholic_beverage' => $alcoholic_beverage]) }}"
hx-confirm="本当に削除しますか?">
削除
</a>
</td>
</tr>
@endforeach
</tbody>
create のビュー
resources/views/alcoholic_beverage/create.blade.php
<form hx-post="{{ route('alcoholic_beverage.store') }}" hx-target="this" hx-swap="outerHTML">
@csrf
<div class="mb-3">
<input
type="text"
name="name"
class="border border-gray-500 p-3"
placeholder="名前"
value="{{ $name ?? '' }}">
@if($errors->has('name'))
<div class="text-red-500 p-1">{{ $errors->first('name') }}</div>
@endif
</div>
<div class="mb-3">
<input
type="text" name="alcohol_content"
class="border border-gray-500 p-3"
placeholder="アルコール度数"
value="{{ $alcohol_content ?? '' }}">
@if($errors->has('alcohol_content'))
<div class="text-red-500 p-1">{{ $errors->first('alcohol_content') }}</div>
@endif
</div>
<div class="mb-3">
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
保存
</button>
@if(isset($status) && $status === 'success')
<div class="text-green-500 pt-2">保存しました</div>
@endif
</div>
</form>
edit のビュー
resources/views/alcoholic_beverage/edit.blade.php
<form hx-post="{{ route('alcoholic_beverage.update', $alcoholic_beverage) }}" hx-target="this" hx-swap="outerHTML">
@csrf
@method('put')
<div class="mb-3">
<input
type="text"
name="name"
class="border border-gray-500 p-3"
placeholder="名前"
value="{{ $name ?? '' }}">
@if($errors->has('name'))
<div class="text-red-500 p-1">{{ $errors->first('name') }}</div>
@endif
</div>
<div class="mb-3">
<input
type="text"
name="alcohol_content"
class="border border-gray-500 p-3"
placeholder="アルコール度数"
value="{{ $alcohol_content ?? '' }}">
@if($errors->has('alcohol_content'))
<div class="text-red-500 p-1">{{ $errors->first('alcohol_content') }}</div>
@endif
</div>
<div class="mb-3">
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
保存
</button>
@if(isset($status) && $status === 'success')
<div class="text-green-500 pt-2">保存しました</div>
@endif
</div>
</form>
バリデーションをつくる
次に、これもコントローラーでセットしたバリデーション(FormRequest
)をつくります。
以下のコマンドを実行してください。
php artisan make:request AlcoholicBeverageRequest
すると、ファイルが作成されるので中身を以下のようにします。
app/Http/Requests/AlcoholicBeverageRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
class AlcoholicBeverageRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
if($this->isMethod('put')) {
return [
'name' => 'required|unique:alcoholic_beverages,name,'.$this->alcoholic_beverage->id.'|max:255',
'alcohol_content' => 'required',
];
}
return [
'name' => 'required|unique:alcoholic_beverages|max:255',
'alcohol_content' => 'required',
];
}
// バリデーションに失敗した場合は、リダイレクトせず view を返す
protected function failedValidation(Validator $validator)
{
$view = '';
$data = [];
if($this->isMethod('post')) {
$view = 'alcoholic_beverage.create';
$data = [
'name' => $this->name,
'alcohol_content' => $this->alcohol_content,
'errors' => $validator->errors(),
];
} else if($this->isMethod('put')) {
$view = 'alcoholic_beverage.edit';
$alcoholic_beverage = $this->route('alcoholic_beverage');
$data = [
'alcoholic_beverage' => $alcoholic_beverage,
'name' => $this->name,
'alcohol_content' => $this->alcohol_content,
'errors' => $validator->errors(),
];
}
$response = response()->view($view, $data);
throw new HttpResponseException($response);
}
public function attributes()
{
return [
'name' => 'お酒の名前',
'alcohol_content' => 'アルコール度数',
];
}
}
この中で重要なのが、バリデーションに通過できなかった場合はリダイレクトは「しない」部分です。
なぜなら、htmx
はリダイレクト先のHTML
をフォームと入れ替えてしまうため、「ページの中に同じページが表示される」ことになってしまうからです。
そのため、failedValidation()
をオーバーライドして、強制的にビューを返すようにしています。(返す内容は登録 or 更新フォームのHTML
です)
ルートをつくる
では、最後にルートです。
routes/web.php
use App\Http\Controllers\AlcoholicBeverageController;
// 省略
Route::prefix('alcoholic_beverage')->controller(AlcoholicBeverageController::class)->group(function(){
Route::get('/', 'index')->name('alcoholic_beverage.index');
Route::get('/list', 'list')->name('alcoholic_beverage.list');
Route::get('create', 'create')->name('alcoholic_beverage.create');
Route::post('/', 'store')->name('alcoholic_beverage.store');
Route::get('{alcoholic_beverage}', 'show')->name('alcoholic_beverage.show');
Route::get('{alcoholic_beverage}/edit', 'edit')->name('alcoholic_beverage.edit');
Route::put('{alcoholic_beverage}', 'update')->name('alcoholic_beverage.update');
Route::delete('{alcoholic_beverage}', 'destroy')->name('alcoholic_beverage.destroy');
});
テストしてみる
では、実際にテストしてみましょう
まずブラウザで「https://******/alcoholic_beverage」へアクセスします。
テストデータとして登録したデータが表示されました。
では、「新規作成」ボタンをクリックしてみましょう。
すると・・・・・・
はい
登録フォームが表示されました。
では、まずは、何も入力しないでバリデーションが効くかチェックしてみます。
はい
うまくエラーが表示されました。
次に、ウォッカに関するデータを入力して「保存」ボタンをクリックしてみましょう。
どうなるでしょうか・・・・・・
はい
登録したウォッカが1番目に表示されました。
まずは成功です
では、次に「ウォッカ」の「編集」ボタンをクリックしてを内容を「ブランデー」
へ変更してみましょう。
うまくいくでしょうか・・・・・・
はい
うまく変更ができました。
では、削除も行ってみましょう。
今変更した「ブランデー」の削除ボタンをクリックします。
すると・・・・・・
はい
ブランデーが削除されてビールが一番目に表示されるようになりました。
すべて成功です
使ってみた感想
コンセプトはとてもいいので、HTML
の延長のようなコードが書けるのはメリットだと感じました。
ただ、やはり少し込み入ったことをしようとすると結局JavaScript
でコードを書かなければならず、実際の開発で使いたいかと言うと「うーん、どうだろう…」といった印象です。
また、Laravel
との連携についても、通常のバリデーション失敗時に実行されるリダイレクトが使えないため、カスタマイズしないといけない点や、(422
ステータスコードを返すと、DOM
が更新されないため)独自ヘッダーを用意してそのリクエストが「成功した」 or 「失敗した」を判別しないといけなかったため、それほど相性がいいとは言えないのかなといった感想です。
企業様へのご提案
今回はhtmx
を使用して開発をしてみましたが、そういった「まだ浸透していないテクノロジー」が実際の開発に使いやすいのか or そうでないのかの調査を承っております。
もしそういった作業をご希望でしたらいつでもお気軽にご相談ください。
お待ちしております。m(_ _)m
おわりに
ということで、今回は「Laravel + htmx」でCRUD
機能をつくってみました。
久しぶりに新しい技術に触れることができたので楽しかった反面、今後開発で利用するかどうかはちょっと未知数なので、もし今後もっとhtmx
が浸透してきたらそのときはもう一度トライしてみようかなってカンジですね。
皆さんもぜひ一度htmx
に触れてみてくださいね。
ではでは〜
「バーに飲みに行ったら
どうすればここの売上が上がるんだろうって
マーケターごっこします(ド素人ですが)」