はじめに
業務でVue.js + Laravelの構成でアプリケーションを作っていて、上記の内容でどん詰まりした。
問題の処理自体は解決したもののなんだか腑に落ちず、数少ない友人のchatGPTくんと話をしたらいろいろと興味深いことを聞き出せたので、忘れないうちにメモ。
本記事の大半は、chatGPTとの会話を私自身の頭の整理のためにまとめたものであり、個別事項のエビデンスや文献に当たって検証したものではございません。
この点をご承知いただき読み進めていただければと思います。
2025/01/24 追記
ありがたいことにコメントをいただきました。
どうやら、PHP8.4で追加されたrequest_parse_body
関数により、表題の問題を一撃で解決できるみたい。なのでここから下の文章はすべて不要です。
PHP8.4は2024/11/24にリリースされたばかりで既存プロジェクトに組み込んでることはまだ少ないかもしれないですが、もしPHP8.4環境で組んでるシステムなら使用を検討してみてください。末尾に参考リンクも追加しましたので、そちらもぜひご一読を。
コメントくださった@YuneKichi様、ありがとうございました。
私のプロジェクトの環境は以下の通りなので、当然request_parse_body
は使えず、以下のごちゃごちゃになるわけです。
PHP 8.3.2
Laravel 10.48.22
何があったのか
上述の通り、VueとLaravelの構成で作っているwebアプリケーションにおいて、ユーザーの情報をjsonでリクエストして、Laravel側でDB登録・更新をする処理がある。
const params = {
user_name: 'さんぷる太郎',
user_phone_number: '090-xxxx-yyyy',
user_face_icon: dataURL // FileReader.readAsDataURL()したやつ
}
まぁよくあるやつかと思います。注意点としては、ふつうの文字列等の他に画像データも一緒にPOSTする必要があるという点。
なので、multipart/form-data
をリクエストヘッダーに差さないとね。
以下は、新規作成用(POSTメソッド)の処理。
const sendRequest = async (params) => {
const header = {
'Content-Type': 'multipart/form-data'
}
try {
const formData = serialize(params, { indices: true, booleansAsIntegers: true })
// ここでのhttpはaxiosのインスタンスです(以下すべて同じ)
const res = await http.post({API_URL}, formData, { headers: header}
... (以下catch文含め省略)
これで通った。じゃあ更新処理(PUTメソッド)も同じようにすればいけるよね!と思い実装。
const sendRequest = async (params) => {
const header = {
'Content-Type': 'multipart/form-data'
}
try {
const formData = serialize(params, { indices: true, booleansAsIntegers: true })
const res = await http.put({API_URL}, formData, { headers: header }
... (以下catch文含め省略)
APIは200を返してくるし、フロントもcatchに入らず正常系のレスポンスを吐いてくる。が、何かがおかしい…。
何度手元でDBを確認しても、何一つカラムが更新されていないのである。
明らかに挙動は正常系なのに?なぜ?ということで、API側にログを仕込んで、バリデーションをパスした後のリクエストを$request->all()
で拾ってみたところ、 どう見てもリクエストの中身が空っぽなのだ。
???????
まったくもって意味が分からない。 (ふざけんなPHP)
試行錯誤の結果、以下のように実装することで期待する挙動が実現できた。
const sendRequest = async (params) => {
const header = {
'Content-Type': 'multipart/form-data',
'X-HTTP-Method-Override': 'PUT'
}
try {
const formData = serialize(params, { indices: true, booleansAsIntegers: true })
const res = await http.post({API_URL}, formData, { headers: header }
... (以下catch文含め省略)
- リクエストヘッダーに
'X-HTTP-Method-Override': 'PUT'
を追加して - HTTPメソッドをPUT→POSTに変更
これで、表題の事象は解決。$request->all()
でちゃんとparamsの中身は取得できるし、DBもちゃんと更新される。
しかし、めちゃくちゃ気味が悪い。
せめて400とか500を返してくれればいいのに、さも正常系みたいな顔して、実態は空っぽの何かしらを受け取っただけなので処理してませんー!なんて意味が分からない。
そう思ってchatGPTくんとの会話に潜ってみた。
表題の解決方法が知りたいだけの方は、以降はまったくもって読む必要ありません。背景に興味がある方や時間を持て余している方だけ進んでください。
繰り返しになりますが、情報源はすべてchatGPTです。
ひとまずの原因特定(言語仕様)
おおよその原因は単純明快だった。
PHP(Laravel)では、POSTメソッド以外でmultipart/form-dataを含むリクエストはパースしないよ。だってそういう言語だから。
ということらしい。これについてもう少し丁寧に追いかけてみると
- PHPではリクエストの生データは
php://input
というリクエストのbodyから生データを読み込むストリームが存在する -
multipart/form-data
やapplication/x-www-form-urlencoded
の場合、$_POST
や$_FILES
などのスーパーグローバル変数を用いてフォームデータを連想配列としてアクセスする仕組みがある -
$_FILES
を使うことで、バイナリなどで送られてきたフォームデータをパースされた状態でアクセスできる(PHP://inputでも読み込めるが、生データなので読めへん) - 上記スーパーグローバル変数たちは、POSTメソッドのみに対応しておりPUTやPATCHには対応していない
- じゃあLaravelも対応しませんよ。ぷいっ
なのだそう。
確かに、PHPのリファレンスで$_POST
を見ても、 HTTP POST メソッドから現在のスクリプトに渡された変数の連想配列です。 と書いてあるので、PUTやPATCHのことは知らんってことなのだろう。
だから、上記の成功実装のように送信自体はPOSTで行う必要があったみたい。
なお、application/json
やtext/plain
の場合、そもそもPHP自体が$_POST
にパースをしないが、そこはLaravel側がよしなに処理してくれるのだそう。
他言語ではどうか?
そうは言っても、PUTやPATCHも一般的なHTTPメソッドなのに超大手言語が対応してないっておかしくない?と思って、ざっと思いつくweb系言語でのPUT + multipart/form-data
の対応について聞いてみた。
言語 | 可否 |
---|---|
Go | net/httpパッケージで完全にサポート http.Request.ParseMultipartForm |
Ruby | Railsでネイティブにサポート params[:file] |
Python | DjangoやFlaskで問題なく使える request.files |
Node.js | multerなどのミドルウェアを一般的には使う req.file |
メジャーなweb系言語だとPHP以外は、ごく普通にPUTメソッドだろうとmultipart/form-data
が扱えることが分かった。
となると、ますますPHPだけ扱ってもらえない理由が分からない。
言語設計と歴史となんやかんや
こうなると、もはや言語設計の思想や歴史まで話を深掘りせざるを得なくなってしまった。
上記で挙げた言語の歴史や思想についてまで話を広げるともう何が何だか分からなくなるので、PHPに焦点を絞る。これに関しては概ね下記の通り。
- PHP誕生当初(1990年代後半)、webフォームにおけるHTTPメソッドはほぼPOSTだった
- PHP自体がwebサーバーで簡単にHTMLフォームを処理するために設計された言語だった(PHP: Hypertext Preprocessorとか名乗り出すくらいには)
- そのため
$_POST
のようなスーパーグローバル変数をPOSTメソッドのために設計し、手軽にリクエストにアクセスできるようにした - PUTやPATCHが一般化したのはRESTful APIが普及した2000年代後半以降のこと
- PUTやPATCHの一般化に対し、PHPは既存のPOSTメソッド優位の設計を変えることなく進化を続けた(これらメソッドのリクエストは
php://input
で直接bodyから取得すれば良いという方針)
以上より、表題の問いに対する1つの回答として
PHPはその言語設計及び設計思想においてPOSTメソッドに特化した言語のため、(彼らからすると後出しの)PUT/PATCHでの、multipart/form-data
リクエストは標準的にはサポートしない
と言えそう。とはいえ、成功実装のようにPOSTメソッドで送信して、ヘッダーでメソッドをオーバーライドすれば処理はできるのでPHPはクソ!とか他言語は優秀!って話ではないことに注意。
RESTの原則に照らしてみる(PUT + multipart/form-data?)
PHPがPOST特化の進化をした。他言語は柔軟に対応できるよう設計され進化した。どちらが正しいのかはよく分からないし、どちらも正しいかもしれない。
そうなると、疑うべきは言語仕様ではなく そもそもmultipart/form-dataをPUTメソッドでリクエストすること自体が間違いなのでは?? ということ。
以下、GPTくん曰く。
- RESTの原則に基づけば、PUTでmultipart/form-dataは非典型だね
- PUTやPATCHは基本的にapplication/json あたりを想定しているよ
- だってPUTって「リソースの完全置き換え」を意図しているメソッドだからフォームデータとはちょっと違うよね
- さっき、他の言語はPUTでもmutlipart/form-data対応できるとは言ったけど、いずれも別に推奨はされていないし、なんならエンドポイントを分けるべきですらあるよ
だそうだ。厳密にRESTに沿う形でAPI設計を行うのであれば、ファイルはPOSTで、その他はPUT(あるいはPATCH)でエンドポイントを分けて設計しろや、ということらしい。
だから、PHPはPUT/PATCHが一般化しても$_POST
等スーパーグローバル変数に手を加えない方針を取ったのかもしれない。実情は知らない
とはいえ、
const params = {
user_name: 'さんぷる太郎',
user_phone_number: '090-xxxx-yyyy',
user_face_icon: dataURL // FileReader.readAsDataURL()したやつ
}
このようなリクエストを送りたいユースケースなんてそこらじゅうに転がっていそうなもの。UI/UXとかいう難しい言葉を使うまでもなくAPI一本、一括で処理できる方が良いに決まってる。
そのあたりについて、RESTの原則含めGPTくんはどう考えてくれるのか?
RESTの原則に照らして設計することは大事だが、あくまでガイドラインみたいなものなんだから、ユーザビリティとか実装保守の容易性を鑑みて、柔軟に設計・実装してくれよな☆
あ、はい…。
もしRESTに厳密に従って、user_face_iconをPOSTで、その他をPUTやPATCHにしてエンドポイントを分けたとすると、ざっと以下のような不都合さを感じる。
- そもそもAPIを2本に分けて実装するのも、保守するのもめんどくさい
- ユーザー視点、エラーが起きた時どっちでエラーが起きたのかなんとなく分かりづらい
- というか一括送信させてくれよめんどくさいな。UXクソじゃんこのシステム
といった具合に、開発者間では共通認識(でもないと個人的には思っている)RESTの原則に忠実になりすぎるあまり、それ自体が目的化してしまうことが問題になりそう。
GPTくんは、あくまでRESTの遵守は手段であり目的ではない。目的は「要件を満たすこと」にあるのでそこを履き違えてはいけない ということを示してくれた。
最後に
ちょっとした実装での躓きが、まさか言語の設計思想だのRESTの原則だのと話が広がってしまうとは本当に予想外だった。ちょっとGPTくんに愚痴ったのを後悔してる
とはいえ、そんな些細なことからここまで話が広がることに、この世界の面白さというか懐の深さというか感じることができ、夜更かしした甲斐があったなと思った。
次の課題は、なんとなくしか理解していないRESTについてかな。
というか、PHPって1995年生まれらしい。
参考リンク(2024/01/24追加)
@rana_kualu 様
https://qiita.com/rana_kualu/items/9c4d476a4c044bac265f