htmx + Laravel で CRUD を作ってみる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、この間エックス(旧Twitter)を見ていたら、とある知らないテクノロジーの話題 が流れてきました。

それが・・・・・・

htmx(えいち・てぃー・えむ・えっくす)

です。

📝 本家のサイト: htmx – high power tools for html

とてもおおざっぱに言うと「JavaScript を書かなくてもいろんなことできるよ」がコンセプトのようで、例えばリンクにhx-get="(ここにURL)"をセットするだけでAjaxアクセスしてくれたりします。

(コンセプトとしてはlivewireとかに近いのかもしれませんね…🤔)

そして、個人的には最近こういった技術に触れていなかったので、「ちょっとやってみようかな!」という気分になりました。

そこで❗

今回はこの「htmx + Laravel」を使ってCRUD機能を作ってみることにしました。

ぜひ何かの参考になりましたら嬉しいです😄✨

※ ちなみにほぼ同じなのでCRUDR(表示)は省略してます。釣りタイトルみたいになっちゃってゴメンナサイ😅

「今年も猛暑らしいので、
梅雨前ぐらいに色んなとこへ
でかけようかな 🚲💨」

開発環境: 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']
            ]);

        }
    }
}

次のこのAlcoholicBeverageSeederDatabaseSeederへ登録します。

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を返してしまうと、htmxDOMを更新しないようになっているため、常に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

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回は「Laravel + htmx」でCRUD機能をつくってみました。

久しぶりに新しい技術に触れることができたので楽しかった反面、今後開発で利用するかどうかはちょっと未知数なので、もし今後もっとhtmxが浸透してきたらそのときはもう一度トライしてみようかなってカンジですね。

皆さんもぜひ一度htmxに触れてみてくださいね。

ではでは〜❗

「バーに飲みに行ったら
どうすればここの売上が上がるんだろうって
マーケターごっこします(ド素人ですが😂)」

このエントリーをはてなブックマークに追加       follow us in feedly