2025.09.26
Laravel
Javascript
PHP
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>
にitems
/reorderUrl
/layoutVersion
を渡すだけです。
- カード UI は既定ビュー or 差し替えコンポーネントで再利用できます。
- 保存は JSON+
X-CSRF-TOKEN
、credentials: '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) 二段階ランク更新(ユニーク制約を避ける)
items
は (board_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 の印象を大きく左右する一方で、一貫性の崩壊が起こりやすい領域です。
この組み合わせなら、スムーズな並び替え体験とデータの正しさを両立できます。