T-CREATOR

htmx × Laravel/PHP 導入手順:Blade パーシャルとルート設計の落とし穴回避

htmx × Laravel/PHP 導入手順:Blade パーシャルとルート設計の落とし穴回避

htmx は JavaScript を最小限に抑えながら、リッチなユーザー体験を実現できる革新的なライブラリです。Laravel との組み合わせは、サーバーサイドレンダリングの強みを活かしつつ、モダンな SPA 的な UX を提供できる魅力的な選択肢となっています。しかし、実際に導入してみると、Blade パーシャルの扱い方やルート設計で思わぬ落とし穴に遭遇することがあります。

本記事では、htmx を Laravel プロジェクトに導入する際の具体的な手順と、多くの開発者が陥りがちな落とし穴を回避する方法を解説します。特に、Blade パーシャルの適切な分割方法、ルート設計のベストプラクティス、そして実際のエラーケースとその対処法について詳しく見ていきましょう。

背景

Laravel と htmx の相性

Laravel は PHP の代表的な MVC フレームワークとして、Blade テンプレートエンジンを標準で搭載しています。一方、htmx はサーバーサイドから返される HTML 断片を動的に DOM に反映させることで、JavaScript を書かずにインタラクティブな UI を実現します。

この 2 つの技術を組み合わせることで、以下のメリットが得られます。

まず、フロントエンドとバックエンドの技術スタックを統一できるため、PHP 開発者が慣れ親しんだ Blade テンプレートだけで開発を完結できます。次に、SPA フレームワーク特有の複雑な状態管理やルーティングから解放され、シンプルなアーキテクチャを保てます。さらに、SEO 対策やアクセシビリティにも優れた、サーバーサイドレンダリングの利点を活かせるのです。

以下の図は、htmx と Laravel の基本的なデータフローを示しています。

mermaidflowchart LR
  user["ユーザー"] -->|クリック/入力| htmx["htmx 属性付き<br/>HTML 要素"]
  htmx -->|AJAX リクエスト| route["Laravel<br/>ルート"]
  route -->|処理| controller["コントローラー"]
  controller -->|データ取得| model["Model/DB"]
  model -->|データ| controller
  controller -->|レンダリング| blade["Blade<br/>パーシャル"]
  blade -->|HTML 断片| htmx
  htmx -->|DOM 更新| user

この図からわかるように、htmx は従来の全画面リロードを部分的な HTML 更新に置き換え、Laravel はその HTML 断片を Blade で生成して返すというシンプルな仕組みです。

htmx の基本原理

htmx は HTML 属性を使って AJAX リクエストを宣言的に記述できます。代表的な属性には以下のものがあります。

#属性説明用途
1hx-getGET リクエストを送信データ取得、一覧表示の更新
2hx-postPOST リクエストを送信フォーム送信、データ作成
3hx-targetレスポンスを挿入する要素を指定更新対象の DOM 要素の選択
4hx-swap挿入方法を指定innerHTML、outerHTML など
5hx-triggerイベントトリガーを指定クリック、入力変更など

これらの属性を組み合わせることで、JavaScript コードを書かずにリッチな UI を実現できるわけですね。

課題

Blade パーシャルの分割に関する課題

htmx を Laravel で使う際、最初につまずくのが Blade パーシャルの適切な分割方法です。従来の全画面レンダリングとは異なり、htmx では部分的な HTML 断片だけを返す必要があります。

この時、以下のような課題が発生しがちです。

課題 1:レイアウトとパーシャルの境界が曖昧になり、どこまでを分離すべきか判断が難しくなります。全画面用のレイアウト(app.blade.php など)を含めて返してしまうと、htmx で部分更新した際にレイアウト全体が重複してしまいます。

課題 2:パーシャルの粒度が不適切だと、再利用性が低下します。細かく分割しすぎると管理が煩雑になり、逆に大きすぎると必要以上の HTML を返してしまい、パフォーマンスが悪化します。

課題 3:htmx 専用パーシャルと通常パーシャルの混在により、ファイル構成が複雑化します。どのパーシャルが htmx 用で、どれが通常の全画面レンダリング用なのか、一目で判別できなくなってしまうのです。

以下の図は、不適切なパーシャル分割で発生する問題を示しています。

mermaidflowchart TD
  request["htmx リクエスト"] --> controller["コントローラー"]
  controller --> layout["app.blade.php<br/>(レイアウト全体)"]
  layout --> duplicate["重複した<br/>ヘッダー/フッター"]
  duplicate --> error["✗ レイアウト崩れ"]

  controller2["コントローラー"] --> partial["適切な<br/>パーシャル"]
  partial --> content["必要な HTML<br/>断片のみ"]
  content --> success["✓ 正常な<br/>部分更新"]

  request --> controller2

  style error fill:#ffcccc
  style success fill:#ccffcc

ルート設計の課題

次に直面するのが、ルート設計の問題です。htmx を使うと、同じリソースに対して「全画面表示用」と「部分更新用」の 2 種類のエンドポイントが必要になることがあります。

課題 1:ルートの重複と命名規則の混乱が発生します。​/​users​/​users​/​partial のように、似たようなルートが増えていき、どのルートが何の用途なのか把握しづらくなります。

課題 2:リクエストヘッダーによる分岐の複雑化も問題です。htmx は HX-Request ヘッダーを自動的に送信するため、これを利用して同じルートで処理を分岐させることができますが、コントローラーのロジックが複雑になりがちです。

課題 3:REST 原則との整合性も悩ましい点です。htmx の部分更新用エンドポイントを、REST の原則に則ってどう設計すべきか、明確な指針がないため開発者ごとに実装がバラバラになってしまいます。

mermaidstateDiagram-v2
  [*] --> RouteDecision: ユーザーがアクセス

  RouteDecision --> FullPage: 初回アクセス<br/>HX-Request なし
  RouteDecision --> PartialUpdate: htmx からの<br/>リクエスト

  FullPage --> RenderLayout: app.blade.php<br/>を使用
  PartialUpdate --> RenderPartial: パーシャル<br/>のみ返す

  RenderLayout --> [*]: 完全な HTML
  RenderPartial --> [*]: HTML 断片

  note right of RouteDecision
    同じルートで分岐?
    別ルートを作る?
    判断が難しい
  end note

この図が示すように、リクエストの種類によって返すべきレスポンスが変わるため、ルート設計に工夫が必要です。

解決策

Blade パーシャル設計のベストプラクティス

htmx を使う際の Blade パーシャルは、明確な命名規則と階層構造で管理することが重要です。以下の設計方針を採用することで、保守性の高いコードを実現できます。

方針 1:ディレクトリ構造による明確な分離

resources/views ディレクトリ内に、htmx 専用のパーシャルを配置する専用フォルダーを作成しましょう。以下のような構造が推奨されます。

bashresources/
  views/
    layouts/
      app.blade.php          # 全画面用レイアウト
    users/
      index.blade.php        # 全画面表示用
      _list.blade.php        # htmx 用リスト部分
      _item.blade.php        # htmx 用アイテム1件
      _form.blade.php        # htmx 用フォーム
    partials/
      _header.blade.php      # 共通ヘッダー
      _footer.blade.php      # 共通フッター

このように、アンダースコア(_)で始まるファイル名を htmx 用パーシャルとすることで、一目で判別できます。

方針 2:パーシャルの粒度の基準

パーシャルの分割は、以下の基準で判断します。

まず、独立して更新される可能性のある UI 単位で分割します。例えば、ユーザーリストの 1 件、コメント 1 件、いいねボタンなどです。次に、複数の画面で再利用される部品は、必ず独立したパーシャルにします。最後に、1 つのパーシャルは 50 行以内を目安とし、それ以上になる場合はさらに分割を検討しましょう。

方針 3:データの受け渡し方法の統一

パーシャルへのデータ受け渡しは、常に明示的に行います。暗黙的なグローバル変数の利用は避け、必要なデータはすべてパラメータとして渡すことで、依存関係が明確になります。

mermaidflowchart LR
  controller[コントローラー] --|compact()|--> view[メインビュー]
  view --|@include with data|--> partial1[_list.blade.php]
  view --|@include with data|--> partial2[_form.blade.php]

  partial1 --|foreach|--> item[_item.blade.php]

  style controller fill:#e1f5ff
  style view fill:#fff4e1
  style partial1 fill:#e8f5e9
  style partial2 fill:#e8f5e9
  style item fill:#e8f5e9

ルート設計のベストプラクティス

htmx を使う際のルート設計には、以下の 3 つのアプローチがあります。プロジェクトの規模や要件に応じて最適なものを選択しましょう。

アプローチ 1:リクエストヘッダーによる単一ルート分岐

同じルートで、htmx からのリクエストかどうかを判定して処理を分岐させる方法です。シンプルなケースに適しています。

アプローチ 2:専用ルートの作成

htmx 用の専用ルートを明示的に作成する方法です。ルート名に partialhtmx といった接尾辞をつけることで、用途を明確にします。

アプローチ 3:API ルートの活用

htmx のリクエストを API ルートとして扱い、​/​api​/​htmx​/​ のようなプレフィックスを付ける方法です。大規模なプロジェクトに適しています。

それぞれのアプローチについて、具体的な実装方法を次のセクションで見ていきましょう。

ミドルウェアの活用

htmx リクエストを統一的に処理するために、専用のミドルウェアを作成することをお勧めします。これにより、コントローラーごとに重複したコードを書く必要がなくなります。

ミドルウェアでは、HX-Request ヘッダーの検証、レスポンスヘッダーの追加、エラーハンドリングの統一などを行えます。

具体例

基本的な htmx 導入手順

まず、Laravel プロジェクトに htmx をインストールします。CDN を使う方法と、Yarn でインストールする方法がありますが、本記事では Yarn を使った方法を紹介します。

ステップ 1:htmx のインストール

Yarn を使って htmx をプロジェクトに追加します。

bashyarn add htmx.org

ステップ 2:Vite 設定の更新

Laravel 9 以降では Vite がデフォルトのビルドツールです。vite.config.js で htmx をインポートできるようにします。

javascriptimport { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
  plugins: [
    laravel({
      input: [
        'resources/css/app.css',
        'resources/js/app.js',
      ],
      refresh: true,
    }),
  ],
});

ステップ 3:app.js での htmx 読み込み

resources​/​js​/​app.js に htmx をインポートします。

javascript// htmx をグローバルに利用可能にする
import htmx from 'htmx.org';

// window オブジェクトに htmx を割り当て(デバッグ用)
window.htmx = htmx;

この設定により、すべてのページで htmx が利用可能になります。

ステップ 4:レイアウトファイルでの読み込み

resources​/​views​/​layouts​/​app.blade.php で Vite を使って JavaScript を読み込みます。

blade<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@yield('title', 'Laravel + htmx')</title>

    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
    <div id="app">
        @yield('content')
    </div>
</body>
</html>

このレイアウトファイルは、全画面表示の際に使用します。htmx による部分更新では、このレイアウトは含めません。

ステップ 5:ビルドの実行

開発環境で Vite を起動します。

bashyarn dev

これで htmx の基本的な導入が完了しました。

ユーザー一覧の実装例

実際に、ユーザー一覧を htmx で動的に更新する機能を実装してみましょう。この例では、検索フォームに入力すると、リアルタイムでユーザーリストが絞り込まれます。

ルートの定義

routes​/​web.php で、全画面表示用と htmx 部分更新用のルートを定義します。

php<?php

use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;

// 全画面表示用ルート
Route::get('/users', [UserController::class, 'index'])
    ->name('users.index');

// htmx 部分更新用ルート
Route::get('/users/search', [UserController::class, 'search'])
    ->name('users.search');

ルートを分離することで、それぞれの責務が明確になります。全画面表示用は index、htmx 用は search と命名することで、用途が一目瞭然です。

コントローラーの実装

app​/​Http​/​Controllers​/​UserController.php を作成します。まず、全画面表示用のメソッドから実装しましょう。

php<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;

class UserController extends Controller
{
    /**
     * 全画面表示用:ユーザー一覧ページを表示
     */
    public function index()
    {
        // 初期表示では全ユーザーを取得
        $users = User::orderBy('created_at', 'desc')
            ->paginate(20);

        return view('users.index', compact('users'));
    }
}

このメソッドは、ページに初めてアクセスした際に呼ばれます。20 件ずつのページネーション付きで全ユーザーを取得します。

次に、htmx からの検索リクエストを処理するメソッドを追加します。

php    /**
     * htmx 用:検索結果のパーシャルを返す
     */
    public function search(Request $request)
    {
        // 検索キーワードを取得
        $keyword = $request->input('q', '');

        // キーワードでユーザーを検索
        $users = User::when($keyword, function ($query, $keyword) {
                return $query->where('name', 'like', "%{$keyword}%")
                    ->orWhere('email', 'like', "%{$keyword}%");
            })
            ->orderBy('created_at', 'desc')
            ->paginate(20);

        // パーシャルのみを返す(レイアウトなし)
        return view('users._list', compact('users'));
    }

search メソッドでは、クエリパラメータ q で受け取った検索キーワードを使ってユーザーを絞り込みます。重要なのは、レイアウトを含まないパーシャル users._list だけを返している点です。

メインビューの作成

resources​/​views​/​users​/​index.blade.php を作成します。このファイルは全画面表示用のビューです。

blade@extends('layouts.app')

@section('title', 'ユーザー一覧')

@section('content')
<div class="container mx-auto px-4 py-8">
    <h1 class="text-3xl font-bold mb-6">ユーザー一覧</h1>

    {{-- 検索フォーム --}}
    <div class="mb-6">
        <input
            type="text"
            name="q"
            placeholder="名前またはメールアドレスで検索..."
            class="w-full px-4 py-2 border rounded-lg"
            hx-get="{{ route('users.search') }}"
            hx-trigger="keyup changed delay:300ms"
            hx-target="#user-list"
            hx-indicator="#search-indicator"
        />
        <div id="search-indicator" class="htmx-indicator mt-2">
            検索中...
        </div>
    </div>

検索フォームの input 要素に、htmx の属性を設定しています。各属性の意味を解説しましょう。

hx-get 属性には検索用ルートを指定し、hx-trigger では「キー入力後、300ms の遅延をもってリクエスト」というトリガー条件を設定しています。これにより、ユーザーが入力を止めてから検索が実行されるため、無駄なリクエストを防げます。

hx-target は、レスポンスの HTML を挿入する先の要素を CSS セレクタで指定します。hx-indicator は、ローディング中に表示する要素を指定します。

続いて、ユーザーリストを表示する部分を追加します。

blade    {{-- ユーザーリスト表示エリア --}}
    <div id="user-list">
        @include('users._list', ['users' => $users])
    </div>
</div>
@endsection

@include ディレクティブを使って、パーシャルを読み込んでいます。この部分が htmx によって動的に更新されます。

リスト用パーシャルの作成

resources​/​views​/​users​/​_list.blade.php を作成します。このパーシャルは、htmx のレスポンスとして返される HTML 断片です。

blade{{-- ユーザーリスト --}}
@if($users->isEmpty())
    <div class="text-center py-8 text-gray-500">
        ユーザーが見つかりませんでした。
    </div>
@else
    <div class="space-y-4">
        @foreach($users as $user)
            @include('users._item', ['user' => $user])
        @endforeach
    </div>

リストが空の場合のメッセージと、ユーザーが存在する場合のループ処理を記述しています。各ユーザーは、さらに細かいパーシャル _item として分離します。

ページネーションも追加しましょう。

blade    {{-- ページネーション --}}
    @if($users->hasPages())
        <div class="mt-6">
            {{ $users->links() }}
        </div>
    @endif
@endif

Laravel の標準的なページネーションリンクを表示します。

アイテム用パーシャルの作成

resources​/​views​/​users​/​_item.blade.php を作成します。1 件のユーザー情報を表示する最小単位のパーシャルです。

blade{{-- ユーザー1件の表示 --}}
<div class="bg-white rounded-lg shadow p-4 hover:shadow-md transition">
    <div class="flex items-center justify-between">
        <div class="flex items-center space-x-4">
            {{-- アバター --}}
            <div class="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
                <span class="text-xl font-bold text-gray-600">
                    {{ strtoupper(substr($user->name, 0, 1)) }}
                </span>
            </div>

ユーザー名の頭文字を使った簡易アバターを表示しています。

続いて、ユーザー情報を表示します。

blade            {{-- ユーザー情報 --}}
            <div>
                <h3 class="font-semibold text-lg">{{ $user->name }}</h3>
                <p class="text-gray-600 text-sm">{{ $user->email }}</p>
            </div>
        </div>

        {{-- 作成日時 --}}
        <div class="text-sm text-gray-500">
            {{ $user->created_at->diffForHumans() }}
        </div>
    </div>
</div>

この粒度でパーシャルを分けることで、個別のユーザーカードだけを更新する機能も簡単に実装できるようになります。

削除機能の実装

次に、htmx を使った削除機能を実装してみましょう。この例では、削除ボタンをクリックすると、確認なしで即座にアイテムが DOM から削除されます。

ルートの追加

routes​/​web.php に削除用のルートを追加します。

php// ユーザー削除用ルート(htmx 用)
Route::delete('/users/{user}', [UserController::class, 'destroy'])
    ->name('users.destroy');

REST 原則に従い、DELETE メソッドを使用します。

コントローラーに削除メソッドを追加

UserController.php に削除処理を追加します。

php    /**
     * htmx 用:ユーザーを削除
     */
    public function destroy(User $user)
    {
        // ユーザーを削除
        $user->delete();

        // 削除成功時は空のレスポンスを返す
        // htmx は hx-swap="outerHTML" により、空のコンテンツで要素を置き換える
        return response('', 200);
    }

削除に成功した場合、空のレスポンスを返しています。htmx の hx-swap="outerHTML" と組み合わせることで、該当する DOM 要素が削除されます。

エラーが発生した場合の処理も追加しましょう。

php    /**
     * htmx 用:ユーザーを削除(エラーハンドリング付き)
     */
    public function destroy(User $user)
    {
        try {
            // 削除前のバリデーション(例:管理者は削除不可)
            if ($user->is_admin) {
                return response()->json([
                    'error' => '管理者ユーザーは削除できません。'
                ], 403);
            }

            $user->delete();

            return response('', 200);

        } catch (\Exception $e) {
            // エラーログに記録
            \Log::error('User deletion failed', [
                'user_id' => $user->id,
                'error' => $e->getMessage()
            ]);

            return response()->json([
                'error' => 'ユーザーの削除に失敗しました。'
            ], 500);
        }
    }

エラーハンドリングを追加することで、より堅牢な実装になります。

アイテムパーシャルに削除ボタンを追加

resources​/​views​/​users​/​_item.blade.php に削除ボタンを追加します。

blade        {{-- 削除ボタン --}}
        <div>
            <button
                class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
                hx-delete="{{ route('users.destroy', $user) }}"
                hx-confirm="本当に削除しますか?"
                hx-target="closest div.bg-white"
                hx-swap="outerHTML swap:300ms"
            >
                削除
            </button>
        </div>

hx-delete 属性で削除用のルートを指定しています。hx-confirm は、削除前に確認ダイアログを表示します。

hx-target="closest div.bg-white" により、最も近い親要素(ユーザーカード全体)を対象にします。hx-swap="outerHTML swap:300ms" は、要素全体を置き換え、300ms のアニメーション付きで削除します。

よくあるエラーと対処法

htmx と Laravel を組み合わせる際、以下のようなエラーに遭遇することがあります。実際のエラーコードと対処法を見ていきましょう。

エラー 1:CSRF トークン検証エラー

エラーコード: HTTP 419: Page Expired

エラーメッセージ:

CSRF token mismatch.

発生条件: POST、PUT、DELETE リクエストを htmx で送信する際、CSRF トークンが含まれていない場合に発生します。

解決方法:

ステップ 1:メタタグを追加

resources​/​views​/​layouts​/​app.blade.php<head> 内に CSRF トークンのメタタグを追加します。

blade<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>@yield('title')</title>
</head>

ステップ 2:htmx 設定でトークンを自動送信

resources​/​js​/​app.js に、htmx のリクエストに自動的に CSRF トークンを含める設定を追加します。

javascriptimport htmx from 'htmx.org';

// CSRF トークンを htmx のすべてのリクエストに自動追加
document.addEventListener('htmx:configRequest', (event) => {
  const token = document.querySelector(
    'meta[name="csrf-token"]'
  );
  if (token) {
    event.detail.headers['X-CSRF-TOKEN'] = token.content;
  }
});

window.htmx = htmx;

この設定により、すべての htmx リクエストに自動的に CSRF トークンが含まれるようになります。

エラー 2:レイアウトの重複

エラーコード: なし(表示崩れ)

発生条件: htmx のレスポンスに、誤ってレイアウト全体(@extends('layouts.app'))を含めてしまった場合に発生します。

現象: ヘッダーやフッターが重複して表示され、CSS が崩れます。

解決方法:

htmx 用のパーシャルでは、@extends ディレクティブを使わないようにします。

誤った例(users​/​_list.blade.php):

blade@extends('layouts.app')  {{-- これが原因 --}}

@section('content')
<div class="space-y-4">
    @foreach($users as $user)
        ...
    @endforeach
</div>
@endsection

正しい例(users​/​_list.blade.php):

blade{{-- レイアウトは使わず、必要な HTML だけを記述 --}}
<div class="space-y-4">
    @foreach($users as $user)
        @include('users._item', ['user' => $user])
    @endforeach
</div>

パーシャルは純粋な HTML 断片のみを含むようにします。

エラー 3:変数未定義エラー

エラーコード: ErrorException

エラーメッセージ:

rubyUndefined variable $users (View: /resources/views/users/_list.blade.php)

発生条件: パーシャルに必要なデータを渡し忘れた場合に発生します。

解決方法:

ステップ 1:コントローラーで必ずデータを渡す

コントローラーの search メソッドで、compact を使ってデータを渡していることを確認します。

phppublic function search(Request $request)
{
    $users = User::when(...)->paginate(20);

    // 必ず compact で変数を渡す
    return view('users._list', compact('users'));
}

ステップ 2:パーシャルで変数の存在を確認

パーシャル側で、変数が存在するかチェックします。

blade@if(isset($users) && $users->isNotEmpty())
    <div class="space-y-4">
        @foreach($users as $user)
            @include('users._item', ['user' => $user])
        @endforeach
    </div>
@else
    <div class="text-center py-8 text-gray-500">
        ユーザーが見つかりませんでした。
    </div>
@endif

isset()isNotEmpty() を使うことで、より安全なコードになります。

エラー 4:htmx が動作しない

エラーコード: なし(JavaScript エラー)

発生条件: htmx が正しく読み込まれていない、またはビルドされていない場合に発生します。

確認方法:

ブラウザの開発者ツールのコンソールで以下を確認します。

csharpUncaught ReferenceError: htmx is not defined

解決方法:

ステップ 1:Vite が起動しているか確認

開発環境では Vite が起動している必要があります。

bashyarn dev

ステップ 2:本番環境用のビルド

本番環境では、ビルド済みのアセットを使います。

bashyarn build

ステップ 3:app.js の読み込み確認

ブラウザの開発者ツールの Network タブで、app.js が正しく読み込まれているか確認します。404 エラーが出ている場合、Vite の設定や @vite ディレクティブの記述を見直しましょう。

フォーム送信の実装例

最後に、htmx を使ったフォーム送信の実装例を見ていきましょう。バリデーションエラーの表示も含めた実装になります。

作成フォーム用ルートの追加

php// ユーザー作成フォーム表示
Route::get('/users/create', [UserController::class, 'create'])
    ->name('users.create');

// ユーザー作成処理(htmx 用)
Route::post('/users', [UserController::class, 'store'])
    ->name('users.store');

コントローラーにフォーム処理を追加

php    /**
     * ユーザー作成フォームを表示
     */
    public function create()
    {
        return view('users.create');
    }

    /**
     * htmx 用:ユーザーを作成
     */
    public function store(Request $request)
    {
        // バリデーション
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email',
            'password' => 'required|string|min:8',
        ]);

バリデーションルールを定義します。エラーが発生した場合、Laravel は自動的にリダイレクトしますが、htmx では JSON レスポンスが必要です。

これを処理するため、カスタムバリデーションレスポンスを返すようにします。

php        try {
            // パスワードをハッシュ化
            $validated['password'] = bcrypt($validated['password']);

            // ユーザーを作成
            $user = User::create($validated);

            // 成功時は新しいユーザーのパーシャルを返す
            return view('users._item', ['user' => $user])
                ->header('HX-Trigger', 'userCreated');

        } catch (\Exception $e) {
            return response()->json([
                'error' => 'ユーザーの作成に失敗しました。'
            ], 500);
        }
    }

成功時は新しく作成されたユーザーの HTML を返し、カスタムヘッダー HX-Trigger でイベントを発火させます。このイベントを使って、フォームのクリアや通知の表示ができます。

フォームビューの作成

resources​/​views​/​users​/​create.blade.php を作成します。

blade@extends('layouts.app')

@section('title', 'ユーザー作成')

@section('content')
<div class="container mx-auto px-4 py-8">
    <h1 class="text-3xl font-bold mb-6">新規ユーザー作成</h1>

    <div class="max-w-md">
        <form
            hx-post="{{ route('users.store') }}"
            hx-target="#user-list"
            hx-swap="afterbegin"
            hx-on::after-request="this.reset()"
        >
            @csrf

フォーム要素に htmx 属性を設定しています。hx-swap="afterbegin" により、新しく作成されたユーザーがリストの先頭に追加されます。

hx-on::after-request はリクエスト完了後にフォームをリセットします。

続いて、フォームフィールドを追加します。

blade            {{-- 名前入力 --}}
            <div class="mb-4">
                <label class="block text-gray-700 font-bold mb-2">
                    名前
                </label>
                <input
                    type="text"
                    name="name"
                    required
                    class="w-full px-4 py-2 border rounded-lg"
                />
            </div>

            {{-- メールアドレス入力 --}}
            <div class="mb-4">
                <label class="block text-gray-700 font-bold mb-2">
                    メールアドレス
                </label>
                <input
                    type="email"
                    name="email"
                    required
                    class="w-full px-4 py-2 border rounded-lg"
                />
            </div>

各フィールドに適切な typerequired 属性を設定します。

最後に、パスワードフィールドと送信ボタンを追加します。

blade            {{-- パスワード入力 --}}
            <div class="mb-4">
                <label class="block text-gray-700 font-bold mb-2">
                    パスワード
                </label>
                <input
                    type="password"
                    name="password"
                    required
                    minlength="8"
                    class="w-full px-4 py-2 border rounded-lg"
                />
            </div>

            {{-- 送信ボタン --}}
            <button
                type="submit"
                class="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
            >
                作成
            </button>
        </form>
    </div>
</div>
@endsection

これで、htmx を使った基本的なフォーム送信機能が完成しました。

まとめ

本記事では、htmx を Laravel プロジェクトに導入する際の具体的な手順と、Blade パーシャルやルート設計における落とし穴の回避方法について解説してきました。

htmx と Laravel の組み合わせは、シンプルなアーキテクチャで SPA のようなユーザー体験を実現できる強力な選択肢です。ただし、従来の全画面レンダリングとは異なるアプローチが必要なため、以下のポイントを押さえることが重要になります。

まず、Blade パーシャルの設計では、アンダースコア(_)で始まる命名規則を採用し、htmx 用のパーシャルを明確に区別しましょう。パーシャルの粒度は、独立して更新される UI 単位で分割し、1 ファイル 50 行以内を目安にすることで、保守性が向上します。

次に、ルート設計では、全画面表示用と htmx 部分更新用のルートを明確に分離することをお勧めします。users.indexusers.search のように、命名規則で用途を区別することで、チーム開発でも混乱を避けられます。

エラー処理については、CSRF トークンの自動送信設定を忘れずに行い、バリデーションエラーを適切にハンドリングすることが大切です。特に、htmx リクエストでは JSON レスポンスを返す必要があるケースもあるため、リクエストの種類に応じた処理を実装しましょう。

htmx の導入により、JavaScript コードを最小限に抑えつつ、リッチなユーザー体験を提供できるようになります。本記事で紹介した実装パターンとベストプラクティスを参考に、ぜひ皆さんのプロジェクトでも htmx を活用してみてください。

Laravel の Blade テンプレートと htmx の組み合わせは、これからの Web 開発において、シンプルさと機能性を両立する魅力的な選択肢となるでしょう。

関連リンク