2025.09.26

Laravel

Javascript

PHP

SortableJSをLaravel で“安全な並べ替え”を作る

SortableJS × Laravel で“安全な並べ替え”を作る

はじめに:なぜ“並べ替え”は壊れがちか?

UI は軽快でも、同時編集ユニーク制約順序の定義場所が曖昧だとすぐ壊れます(見た目は動くのに順序データが正しく登録されない等)。

この記事では、Blade + SortableJS の最小構成で 実運用に耐える並べ替えを組み上げる方法をまとめます。


何を作ったか(ユースケース)

  • 画像付きカードをドラッグ&ドロップで再配置する Reorder Grid構成です。
  • Laravel(Blade)+ SortableJS(CDN) の軽量構成です。
  • Board(盤)配下の Item(カード)を order_rank で並べ替え、保存時に layout_version をインクリメントします。

フロントエンド:SortableJS を最小限で

選定理由:軽量・依存少・安定している。React / Alpine / Vue を縛らず Blade に直挿し可能です。

使うのはほぼこれだけです。:

  • handle:掴む領域を限定します。
  • animation:ドラッグの滑らかさを決定します。
  • ghostClass:ドラッグ中の見た目を決定します。

<ul class="rg-grid">
  <!-- <li class="rg-card" data-id="..."> … -->
</ul>
<script>
  new Sortable(document.querySelector('.rg-grid'), {
    animation: 150,
    handle: '.rg-handle',
    ghostClass: 'rg-ghost',
    onEnd: save, // 並べ替え終了で保存
  })
</script>

コンポーネント指向(Blade)

  • <x-reorder.grid-sortable>itemsreorderUrllayoutVersion を渡すだけです。
  • カード UI は既定ビュー or 差し替えコンポーネントで再利用できます。
  • 保存は JSONX-CSRF-TOKENcredentials: 'same-origin' を必ず付与してください。

// 要点のみ(連投抑止・簡易トースト)
const save = async () => {
  const ids = getIds(); // <li data-id="..."> から配列化
  const res = await fetch(reorderUrl, {
    method: 'PATCH',
    credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-TOKEN': csrf,
      'Accept': 'application/json'
    },
    body: JSON.stringify({ ordered_ids: ids, coords: {}, layout_version: layoutVersion })
  })
  if (res.status === 409) return location.reload(); // 他ユーザーが先に更新にします。
  // …422/419 などの分岐はトースト表示で
};

実務ポイント

  • 未変更の連投ids.join(',') で冪等化します。
  • ドラッグ連発は 送信合成(queue) で間引きます。
  • coords は将来の“座標保存”を見据えた拡張フィールドとしておくとスムーズです。

サーバ&DB:整合性を守る 4 つの柱

1) 楽観ロック(layout_version

  • クライアントは現行の layout_version を送ります。
  • サーバは Board を lockForUpdate() で行ロックし、版数不一致なら 409エラーを投げます。

2) 厳格な入力検証

  • 並び替え更新時だけ

    Item::withoutGlobalScope(OrderByRankScope::class) で既定ソートを外します。

  • ordered_idsボード内アイテム集合と完全一致するかを検証します。(欠落・混入は 422エラーを投げます)。

3) 二段階ランク更新(ユニーク制約を避ける)

itemsboard_id, order_rank)ユニークにします。

同じ rank へ同時書き換え → 衝突するため、一時退避 → 最終確定の二段階に分けます。

DB::transaction(function () use (...) {
    $locked = Board::whereKey($board->id)->lockForUpdate()->firstOrFail();

    if ((int)$locked->layout_version !== $clientLayoutVersion) {
        abort(409, 'Layout is stale.');
    }

    $items = Item::withoutGlobalScope(OrderByRankScope::class)
        ->where('board_id', $board->id)
        ->lockForUpdate()
        ->get()
        ->keyBy('id');

    // …ordered_ids の厳格検証…

    $now = now();
    $maxRank = (int) Item::where('board_id', $board->id)->max('order_rank');
    $tempBase = $maxRank + 100000000;

    // 1) 一時退避(被りを回避)
    foreach ($orderedIds as $i => $id) {
        Item::whereKey($id)->update(['order_rank' => $tempBase + $i + 1, 'updated_at' => $now]);
    }
    // 2) 最終確定(1000 刻みのスパース配列)
    foreach ($orderedIds as $i => $id) {
        Item::whereKey($id)->update(['order_rank' => ($i + 1) * 1000, 'updated_at' => $now]);
    }

    $locked->increment('layout_version');
});

スパース割り当て(1,2,3…と詰めた連番にせず、1000, 2000, 3000…のように間を空けておきます)

メリット:後から“間に挿入”しやすくなります。


まとめ

“並べ替え”は UI の印象を大きく左右する一方で、一貫性の崩壊が起こりやすい領域です。

この組み合わせなら、スムーズな並び替え体験データの正しさを両立できます。