LaravelとhtmxでDELETEメソッドを実装したときの罠
背景
Laravelを使用してCRUDサービスの開発を進める中で、とある課題に直面しました。このプロジェクトでは、バックエンドにLaravelを採用し、フロントエンドのテンプレートエンジンとしてBladeを利用しています。ただし、Bladeだけでは動的なページ表現に限界があるため、Alpine.jsやhtmxを導入して、より柔軟でインタラクティブなUIの実現を図っています。
問題
問題が発生したのは、テーブルのレコードを削除する処理においてです。
レコード削除時には、<form>要素に hx-delete
を指定し、DELETEメソッドを使用するように実装しました。
<form hx-delete="/sample/item" hx-target="#element" hx-swap="innerHTML">
...
</form>
このフォームを送信すると、ルーティングによって SampleController の destroyItem()
メソッドが呼び出され、該当レコードが削除される想定でした。
しかし実際には、destroyItem()
に加えて、別のメソッドである destroy()
も同時に実行されてしまい、意図しない挙動が発生しました。
特定
当該のdestroy()
の実装は概ね以下の通りでした。
public function destroyItem(): RedirectResponse {
try {
// Serviceを呼び出し、DELETE操作が実装されている
return redirect()->route('sample.show');
} catch (Exception $e) {
throw $e;
}
}
web.phpでは以下のようなルーティングが実装されています。
Route::get('/sample', [SampleController::class, 'show'])->name('sample.show');
Route::delete('/sample', [SampleController::class, 'destroy'])->name('sample.destroy');
Route::delete('/sample/item', [SampleController::class, 'destroyItem'])->name('sample.destroy_item');
この問題が発生した原因は、destroyItem()
メソッド内でリダイレクトを行っていた点にありました。
hx-delete
を使用した場合、コントローラ内でリダイレクトしても、そのリダイレクトは必ずしも GET メソッドで行われるとは限りません。
今回のケースでは、リダイレクトが DELETE メソッドのままで実行されており、結果として DELETE route('sample.show')
に遷移してしまいました。
そのため、意図せず DELETE /sample
が呼び出され、SampleController の destroy()
メソッドが実行されるという予期しない動作が発生していました。
解決
そこでリダイレクト時にGETメソッドで動作するように変更しました。
1. 303を指定する
この問題は、リダイレクト時のステータスコードを 303 See Other
に設定することで解決できます。
HTTPステータスコード 303 を使用すると、リダイレクト先へのリクエストは常に GET メソッド で実行されるため、hx-delete
による DELETE リクエストのまま遷移することを防げます。
public function destroyItem(): RedirectResponse {
try {
// Serviceを呼び出し、DELETE操作が実装されている
return redirect()->route('sample.show')->setStatusCode(303);
} catch (Exception $e) {
throw $e;
}
}
2. HX-LOCATIONを指定する
もう一つの解決策として、destroyItem()
メソッド内で HX-Location
ヘッダー を指定したレスポンスを返す方法があります。
これにより、クライアント側では GET メソッドによる遷移が行われ、DELETE メソッドのままリダイレクトされる問題を回避できます。
ただしこの方法では、コントローラー側で次に遷移すべきURLやビューの構造を指定する必要があり、Viewに関する情報をControllerが持つことになります。
今後の学び
そもそもCRUD処理のあとはリダイレクトさせるべきではないのかも?
参照
Discussion