Laravel

Laravel Blade Fragmentsの使い方|@fragmentとhtmx・Turbo連携の落とし穴【Laravel 13実測】

Laravel Blade Fragmentsの使い方|@fragmentとhtmx・Turbo連携の落とし穴【Laravel 13実測】

Blade Fragmentsは、Bladeテンプレートの一部に名前を付けておき、コントローラーからその部分のHTMLだけをレスポンスとして返すLaravelの機能です。htmxやTurboのように「サーバーがHTMLを返し、ブラウザがページの一部を差し替える」方式と組み合わせると、部分更新用のテンプレートを別ファイルに切り出さずに済みます。この記事では、@fragmentの構文と4つのメソッドの違い、htmxとTurbo Framesでの書き方、断片が全画面に出てしまう条件を、Laravel 13.34.0の新規プロジェクトで実際に動かした結果をもとに整理します。

まとめ:Blade Fragmentsの要点と使う前の確認事項

  • 追加された版:@fragment と fragment() はLaravel 9.39.0(2022年11月)で追加。Laravel 11の新機能ではない。fragmentIf()・fragments()・fragmentsIf() は9.48.0から
  • 仕組み:ビュー全体をいつもどおり評価し、その結果から名前の付いた部分を切り出して返す。減るのは転送量と差し替え範囲で、サーバー側の処理量は減らない
  • htmxでの判定:公式ドキュメントの hasHeader('HX-Request') だけで分岐すると、hx-boostのページ遷移やブラウザの戻る操作でも断片だけが返り、レイアウトの無い画面になる。HX-Boosted と HX-History-Restore-Request を除外し、Vary: HX-Request, HX-Boosted, HX-History-Restore-Request を付ける
  • Turboでの判定:Turbo Framesは全ページを返しても同じidの <turbo-frame> を抜き出して差し替える。Fragmentsは転送量を減らす用途で、断片の中に <turbo-frame> 自体を含める
  • 最大の落とし穴:存在しないフラグメント名を指定してもエラーにならず、全ページが200で返る。ループ内に同じ名前のフラグメントを置くと最後の1回分しか取れない

Blade Fragmentsの仕組みと追加された版

Blade Fragmentsは、2022年10月にPR #44774として提案され、Laravel 9.39.0(2022年11月8日リリース)のCHANGELOGに「Added template fragments to Blade」として載りました。その後のマイナーリリースでメソッドが増えています。Laravel 9.39.0以上であれば使えるので、Laravel 10・11・12・13のどの版でも利用できます。

追加された機能 Laravelの版 リリース日
@fragment・@endfragment・fragment() 9.39.0 2022-11-08
fragmentIf()・fragments()・fragmentsIf() 9.48.0 2023-01-17
fragments() の引数省略(全フラグメント) 10.31.0 2023-11-07

内部の動きは単純です。@fragment('名前') は出力バッファリングを開始し、@endfragment でバッファの中身を名前付きで保存しつつ、そのまま通常の出力にも書き出します。fragment('名前') を呼ぶと、ビューをレイアウトまで含めて最後まで描画し、保存しておいた部分だけを返します。

このため、旧来の解説にある「必要な部分だけを描画するのでレンダリングコストが下がる」という説明は正しくありません。コントローラーでの検索やビュー内の処理はフルページと同じだけ走ります。下がるのは、ネットワークに流れるHTMLの量と、ブラウザが差し替えるDOMの範囲です。描画そのものが重いページを速くしたいなら、Fragmentsではなくクエリやキャッシュを見直す必要があります。

@fragmentとfragment()の基本構文

テンプレートの中で、部分更新したい範囲を @fragment と @endfragment で囲み、名前を付けます。ここでは商品一覧のリスト部分に product-list という名前を付けています。

{{-- resources/views/products/index.blade.php --}}
@extends('layouts.app')

@section('content')
  <h1>商品一覧</h1>

  @fragment('product-list')
    <ul id="product-list">
      @foreach ($products as $product)
        <li>{{ $product->name }}</li>
      @endforeach
    </ul>
  @endfragment
@endsection

コントローラーでは、いつもどおり view() を返す代わりに、末尾に fragment() をつなげます。レスポンスは <ul id="product-list"> から </ul> までだけになり、レイアウトの <html> やヘッダーは含まれません。

return view('products.index', ['products' => $products])
    ->fragment('product-list');

フラグメントを置ける場所は、そのビュー本体に限りません。実測では、@extends で継承した子ビューの @section 内、@include で読み込んだ部分テンプレート内、Bladeコンポーネントのテンプレート内のいずれに置いたフラグメントも、親ビューの fragment() で取り出せました。フラグメントの一覧はビュー単位でなくビューファクトリー全体で持っているためです。

fragmentIf・fragments・fragmentsIfの違い

Viewクラスには4つのメソッドがあります。条件付きの2つは、条件が偽ならビュー全体を返します。

メソッド 返すもの ビューの評価回数
fragment('a') フラグメントaだけ 1回
fragmentIf($cond, 'a') 真ならa、偽ならビュー全体 1回
fragments(['a', 'b']) aとbを連結 指定した数だけ
fragments() 全フラグメントを連結 1回
fragmentsIf($cond, [...]) 真なら連結、偽ならビュー全体 真なら指定数だけ

評価回数の列は、テンプレートに評価のたびに増えるカウンターを置いて数えた結果です。fragments(['a', 'b']) は内部で fragment() を名前ごとに呼ぶため、ビュー全体が2回描画されます。ビューの中で重い処理をしている場合、指定するフラグメントの数だけその処理が繰り返されます。

引数を省いた fragments() は、フラグメントが閉じた順に並べて返します。フラグメントを入れ子にすると、内側が先に閉じるので先頭に来て、さらに外側のフラグメントの中にも含まれるため、同じHTMLが2回出力されます。入れ子にしたテンプレートで全件連結は使わず、名前を指定してください。

複数の領域を同時に更新したい場面、たとえば一覧と件数表示を1回の通信で書き換えるときは、fragments(['product-list', 'count']) で連結して返し、htmx側では hx-swap-oob(帯域外スワップ)を付けた要素で件数表示を差し替えるのが定番の組み合わせです。

htmxで一覧を部分更新する実装

htmxと組み合わせると、既存のBladeビューを全ページ表示と部分更新で共用できます。htmxはHTML属性で通信と差し替えを指定するライブラリです。GitHubでは2026年8月28日に4.0.0が正式リリースされましたが、npmの latest タグは2系の2.0.11(2026年9月22日)のままで、npm install htmx.org で入るのは2系です。4系は htmx.org@next で入ります。htmx自体の属性の一覧はhtmxの使い方:hx-属性の実装手順からFastAPI連携まで解説にまとめています。

Blade側のhx-get・hx-target・hx-swapの指定

検索欄に入力するたびに商品一覧だけを取り直す例です。レイアウトでhtmxを読み込み、products.index という名前のGETルートと、name 列を持つ App\Models\Product モデルがある前提で書いています。

<input type="search" name="q"
       hx-get="{{ route('products.index') }}"
       hx-trigger="input changed delay:300ms"
       hx-target="#product-list"
       hx-swap="outerHTML"
       hx-push-url="true">

@fragment('product-list')
  <ul id="product-list">
    @foreach ($products as $product)
      <li>{{ $product->name }}</li>
    @endforeach
  </ul>
@endfragment

hx-swap を省くとhtmxは innerHTML で差し替えるため、フラグメントの <ul id="product-list"> が既存の <ul> の中に入り、リストが二重になります。フラグメントの外枠ごと置き換える outerHTML にするか、フラグメントを <ul> の内側だけで囲むかのどちらかに揃えてください。

コントローラーでのHX-Requestの判定条件

公式ドキュメントの例は fragmentIf($request->hasHeader('HX-Request'), ...) ですが、htmx 2.0.11のソースを読むと、次の2つの場面でも HX-Request: true が送られます。

  • hx-boost によるページ遷移:リンクやフォームをAjax化したページ全体の遷移でも HX-Request が付き、追加で HX-Boosted: true が付く
  • 履歴の復元:戻る操作で履歴キャッシュに無いページをサーバーから取り直すとき、設定 historyRestoreAsHxRequest(既定は有効)により HX-Request と HX-History-Restore-Request: true が付く

どちらもページ全体が必要な場面です。HX-Request だけで判定すると、hx-boostはbodyの中身を応答で置き換えるため、レイアウトのヘッダーやナビゲーションが消えて断片だけが並ぶ画面になります。head内のCSSは残るので見た目が崩れにくく、気付くのが遅れがちです。両方を除外し、同じURLでヘッダーにより中身が変わることをブラウザのキャッシュに伝える Vary ヘッダーも付けます。htmxのドキュメントは Vary: HX-Request を付けること、historyRestoreAsHxRequest を無効にすることを勧めています。下の例は3つのヘッダーで応答を分けているので、Vary にも3つとも並べます。

use Illuminate\Http\Request;

public function index(Request $request)
{
    $products = Product::query()
        ->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->input('q').'%'))
        ->get();

    $partial = $request->hasHeader('HX-Request')
        && ! $request->hasHeader('HX-Boosted')
        && ! $request->hasHeader('HX-History-Restore-Request');

    return response(
        view('products.index', compact('products'))->fragmentIf($partial, 'product-list')
    )->header('Vary', 'HX-Request, HX-Boosted, HX-History-Restore-Request');
}

Laravel 13.34.0でこのルートに4通りのヘッダーを送ったところ、HX-Request だけのときは断片、ヘッダー無し・HX-Boosted 付き・HX-History-Restore-Request 付きのときは全ページが返りました。公式ドキュメントの条件のままだと、履歴復元のリクエストにも断片が返ることを同じ環境で確認しています。

htmx 4.0.0では、差し替え先と hx-select の指定から判断した HX-Request-Type(full または partial)ヘッダーが新たに送られます。4系へ移行した後は、$request->header('HX-Request-Type') === 'partial' を判定に使うと条件が1つで済みます。こうした判定をまとめたヘルパーが欲しい場合は、コミュニティ製の mauricius/laravel-htmx(v0.10.0、Laravel 12・13対応)があります。

POST・DELETEとCSRFトークン

htmxで hx-post や hx-delete を使うと、Laravelのリクエスト偽造対策のミドルウェアを通ります。Laravel 13では、ブラウザが付ける Sec-Fetch-Site ヘッダーが same-origin であればトークンなしで通る仕組み(オリジン検証)が加わりました。Laravel 12以前や、HTTPSではない接続などでブラウザがこのヘッダーを送らない環境ではトークン検証にフォールバックするため、<body> にヘッダーを継承させておくと版を問わず動きます。

<body hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>

トークンの発行元であるセッションの設計はLaravelのセッション管理|ドライバ選定とCSRF・複数台構成の共有設計で扱っています。

Turbo Framesと組み合わせる場合の書き方

Hotwire Turboの <turbo-frame> は、フレーム内のリンクやフォームの応答から同じidのフレームを探し、その中身だけを差し替えます。Turbo 8.0.23のソースでは、フレームからの要求に Turbo-Frame: フレームのid ヘッダーが付き、応答に同じidのフレームが無いと「Content missing」と表示されます。

つまりTurboでは、全ページを返してもフレームの差し替えは成立します。Fragmentsを使う理由は転送量を減らすことだけで、そのときは断片に <turbo-frame> の外枠を含めておく必要があります。

@fragment('product-frame')
  <turbo-frame id="products">
    @include('products._list')
  </turbo-frame>
@endfragment
return view('products.index', compact('products'))
    ->fragmentIf($request->header('Turbo-Frame') === 'products', 'product-frame');

同じURLで Turbo-Frame ヘッダーに応じて応答を変えるので、HTTPキャッシュを使う構成では、全ページと断片の両方の応答で Vary に Turbo-Frame を含めます。書き方はhtmxの例と同じく response(...)->header('Vary', 'Turbo-Frame') です。

Turbo Framesの Content missing の原因と対処はTurbo Framesとは?turbo_frame_tagの使い方とContent missingの対処、Hotwire全体の構成はHotwireとは?Turbo・Stimulus・Hotwire Nativeの役割とRails 8.1での使い方で解説しています。

実測で分かったBlade Fragmentsの落とし穴

Laravel 13.34.0(PHP 8.5.8)の新規プロジェクトで、境界になりそうな書き方を1つずつ試しました。データは固定の配列を渡し、fragment() などの戻り値をそのまま出力して確かめています。公式ドキュメントに書かれていない挙動は次のとおりです。

書き方 実際の結果
存在しない名前を fragment() に指定 エラーにならず全ページが返る
ループ内に同じ名前の @fragment 最後の1回分だけが返る
@endfragment だけを書く ViewException で停止
@endfragment を書き忘れる 例外にならず出力の順序が崩れる
入れ子にして fragments() 内側のHTMLが重複する

一番気付きにくいのは名前の誤記です。fragment() は保存済みのフラグメントが見つからないと何も返さず、その場合はビュー全体の描画結果がそのまま返る実装になっています。htmxで差し替えた領域にヘッダーとフッターが丸ごと入っても、HTTPステータスは200で、ログにも何も残りません。名前は定数やBladeの変数にまとめるか、後述のテストで断片が返っていることを確かめてください。

ループの中で行ごとに @fragment('row') を置くと、フラグメントは名前をキーに保存されるため、繰り返すたびに上書きされます。1行だけを更新したいときは @fragment('row-'.$product->id) のように行ごとに名前を変えます。

断片が返っているかどうかは、機能テストで確認できます。次のテストは、上のコントローラーと同じ条件のルート(データは固定配列)に対し、Laravel 13.34.0の php artisan test で3件とも成功しました。検索を実装したら、検索語「0」でも絞り込まれることも確かめてください。when() は「0」を偽として扱うため、$request->q をそのまま条件に渡すと全件が返ります。

public function test_htmx_request_returns_only_fragment(): void
{
    $this->withHeaders(['HX-Request' => 'true'])
        ->get('/products')
        ->assertOk()
        ->assertSee('id="product-list"', false)
        ->assertDontSee('<header>', false)
        ->assertHeader('Vary', 'HX-Request, HX-Boosted, HX-History-Restore-Request');
}

public function test_boosted_request_returns_full_page(): void
{
    $this->withHeaders(['HX-Request' => 'true', 'HX-Boosted' => 'true'])
        ->get('/products')
        ->assertSee('<header>', false);
}

public function test_history_restore_request_returns_full_page(): void
{
    $this->withHeaders(['HX-Request' => 'true', 'HX-History-Restore-Request' => 'true'])
        ->get('/products')
        ->assertSee('<header>', false)
        ->assertHeader('Vary', 'HX-Request, HX-Boosted, HX-History-Restore-Request');
}

Livewire・Inertiaとの選び方とFragmentsが向かない場面

Blade Fragmentsは「HTMLを返すサーバー」と「HTMLを差し替える軽いクライアント」の組み合わせで、Fragments自体は状態同期の仕組みを持たず、必要な状態をクエリやフォーム値、セッションなどから受け取ってHTMLを生成します。LaravelでBladeのまま画面を動的にする手段はほかにもあり、役割がはっきり分かれます。

手段 クライアント側 向いている画面
Fragments+htmx htmx(属性のみ) 検索・絞り込み・ページ送り・行の削除
Fragments+Turbo Turbo タブ・モーダル・フレーム単位の遷移
Livewire Livewire(Alpine.js同梱) 入力途中の検証・多段フォーム
Inertia React・Vue・Svelte 状態の多いSPA型の管理画面

判断の分かれ目は、画面の状態をどこに置くかです。検索条件やページ番号をURLで表せる画面では、Fragments+htmxを選ぶと、BladeビューとHTML属性を中心に部分更新を実装できます。入力途中の値や選択状態をサーバーと往復させ続ける画面は、コンポーネント単位で状態を持つLivewire(Laravel)とは?v4対応の使い方とコンポーネント作成・Blade連携の手順のほうが素直に書けます。LivewireはBladeを使いますが、差し替えは自前の仕組みで行うため @fragment は不要です。画面をReactやVueで作るならLaravel Inertiaとは|APIを作らずSPAを組む仕組みと3系の変更点の領域になります。

Fragmentsを採用すべきでないのは、次のような場合です。

  • 描画の重さが問題のページ:ビュー全体を評価する仕組みなので、サーバーの応答時間は短くならない
  • 1回の操作で画面のあちこちが変わる:複数領域の更新条件が絡み合う画面では、名前を配列で指定する fragments() の再描画負荷と hx-swap-oob の管理が増え、Livewireのほうが保守しやすい
  • オフラインや即時の入力反応が必要:通信が前提なので、ドラッグ操作や入力補完はAlpine.jsなどクライアント側で処理する。Alpine.jsの書き方はAlpine.jsとは?18ディレクティブの実装とCSP対応・採用判断を解説を参照

よくある質問

Blade FragmentsはLaravel 11の新機能ですか?

いいえ。@fragment と fragment() はLaravel 9.39.0(2022年11月)で追加され、fragmentIf() などの3メソッドは9.48.0で加わりました。Laravel 10以降のどの版でも使えます。現行のLaravelの版とサポート期限はLaravelの最新バージョンは13|対応PHPバージョン・サポート期限一覧とアップグレード判断にまとめています。

fragment()に存在しない名前を渡すとエラーになりますか?

なりません。見つからない場合はビュー全体が返り、HTTPステータスも200のままです。部分更新した場所にページ全体が入る症状が出たら、まず名前の綴りを確認してください。

@fragmentは@includeした部分テンプレートの中にも書けますか?

書けます。@includeした部分テンプレートやBladeコンポーネントの中のフラグメントも、親ビューの fragment() で取り出せることをLaravel 13.34.0で確認しました。部分テンプレートを単独で view() に渡しても同じように取り出せます。

Livewireのコンポーネントで@fragmentは使えますか?

Livewireはコンポーネントの再描画と差分適用を自前で行うため、@fragment を組み合わせる必要はありません。Fragmentsはhtmx・Turboのように、サーバーが返したHTMLをそのまま差し替える方式のための機能です。

Blade Fragmentsを使うと表示は速くなりますか?

転送するHTMLが減るので、通信量とブラウザの差し替え処理は軽くなります。一方でサーバーはビュー全体を描画してから切り出すため、データベースの検索やビュー内の計算にかかる時間は変わりません。

関連記事

お気に入りに入れた記事の一覧

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2026.10.08 テックブログ 大阪公立大学のランサムウェア被害と仮想化基盤の停止|全授業休講に至った経緯とバックアップを守る設定
  3. 2024.11.08 テックブログ OpenAPI GeneratorでJavaコードを自動生成する方法|CLI導入からSpring・ライブラリ選択まで
  4. 2026.10.09 テックブログ ニッスイのサイバー攻撃で日水物流の入出荷停止|委託先クラウド障害に荷主が備える手順
  5. 2026.10.09 テックブログ 京王電鉄のランサムウェア被害とグループ共通基盤:決済・ポイント・予約が止まった範囲と遮断の初動

RELATED POSTS 関連記事

目次