Content-Typeとattachmentを指定してファイルダウンロードする方法[PHP]


この記事では、HTTPレスポンスとしてファイルをダウンロードさせたいときのheaderの指定方法について調べたことをまとめています。これまでなんとなく記述していたContent-TypeやContent-Dispositionについて触れています。

結論から書くと、ファイルをダウンロードさせたいときには次のようにheaderを書くと良さそうです。

  • Content-Typeに適切なファイル種別を入れる
  • Content-Dispositionをattachmentにする

私の理解では上記のような結論になりましたが、間違いなどありましたらご指摘いただけると大変嬉しいです。

JSONファイルがブラウザに表示されずにダウンロードされる

この記事を書くきっかけになったのが、下記のコードです。

index.php

<?php

$items = [
    ['name' => 'banana', 'price' => 200],
    ['name' => 'orange', 'price' => 150],
];

// jsonに変換
$data = json_encode($items, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Content-Typeをjsonに指定
header("Content-Type: applicarion/json");

echo $data;

ブラウザから上記のファイルにアクセスすると、なぜかテキストファイルがダウンロードされてしまいました。

原因がしばらくわからなかったので、この機会にheaderのContent-Typeについてちゃんと知ろうと思ったのが、この記事を書いたきっかけです。

テキストファイルがダウンロードされてしまう原因

結論から書くと、Content-Typeのファイル種別をタイプミスしていたことが上記の挙動の原因でした。

- header("Content-Type: applicarion/json"); // 修正前 ファイルがダウンロードされる
+ header("Content-Type: application/json"); // 修正後 ブラウザに表示される

echo $data;

applicationtrになっており、そのせいでファイルがダウンロードされてしまっていました。

のちほど詳しく書きますが、Content-Typeはファイルの種別を表す項目らしく、ここにtext/htmlapplication/pdfなど、ブラウザで表示可能なファイルが指定されている場合は表示するのが正常な挙動のようです。Content-Typeがapplication/jsonの場合も、一般的には次のようにブラウザに表示されます。

※ 画像はChromeのJSON Formatterという拡張機能で整形しています

しかし、このContent-Typeに未知のファイル種別が指定されていると、ブラウザ側の仕様でファイルをダウンロードさせてしまうことが多いようです。つまり、Content-Type: applicarion/jsonとタイプミスした影響で、ブラウザに未知のファイル種別と誤認されてしまっていたようです。

ファイルをダウンロードさせるときのheaderの書き方

ファイルをダウンロードさせたい場合、上記のようにContent-Typeに未知のファイル種別を指定する以外にも、いくつか方法があるようです。参考にさせていただいたこちらの記事では、次のように書かれていました。

ダウンロード形式に関わるヘッダは、 Content-Type Content-Disposition の2つがあり、調べていると以下の3つの指定がよく使われているのが見つかります。

  • Content-Type: application/force-download
  • Content-Type: application/octet-stream
  • Content-Disposition: attachment

https://shkn.hatenablog.com/entry/2019/03/22/235503

Content-Typeをapplication/force-downloadにする方法

ファイルをダウンロードさせたいとき、Content-Type: application/force-downloadという指定の仕方は慣習的によく使われるようですが、実はapplication/force-downloadというMIMEタイプ(ファイル種別)は存在しないようです。

つまり、さきほど私がファイル種別をタイプミスしたときと同様に、未知のMIMEタイプが指定されたときはファイルをダウンロードする挙動になる、というブラウザの仕様を利用したダウンロードに過ぎないようです。application/force-downloadでなく、hogeなど任意の文字列をContent-Typeに指定してもファイルはダウンロードされました。

// ファイルがダウンロードされる
header("Content-Type: application/force-download");

echo $data;

この方法でダウンロード処理を実現することは可能なのですが、本来はMIMEタイプを記載すべき場所をダウンロード処理のために使っているという点に違和感を覚えました。

後述するContent-Dispositionは、ファイルをWEBページとして表示するか、ダウンロードさせるかを指定するためのheaderなので、こちらを使用するほうが自然なように思えました。

Content-Typeをapplication/octet-streamにする方法

application/octet-streamは、未知のファイルを表すMIMEタイプのようです。

application/octet-stream

これは、バイナリファイルでは既定です。これは未知のバイナリ形式のファイルを表すものであり、ブラウザーはふつう実行したり、実行するべきか確認したりしません。これらは Content-Disposition ヘッダーの値に attachment が設定されたかのように扱い、「名前を付けて保存」ダイアログを提案します。

https://developer.mozilla.org/ja/docs/Web/HTTP/Basics_of_HTTP/MIME_types

さきほどのapplication/force-downloadは公式には存在しないMIMEタイプでしたが、こちらのapplication/octet-streamは公式に定義されたMIMEタイプのようです。

// ファイルがダウンロードされる
header("Content-Type: application/octet-stream");

echo $data;

application/octet-streamは公式なMIMEタイプらしいので、上記のような記述は間違いではないと思いますが、しかし、たとえばJSONファイルをダウンロードさせたいとき、MIMEタイプはapplication/jsonとわかっているのに、ダウンロードのためだけにapplication/octet-streamと書くのはやはり不自然な感じがします。

Content-Dispositionをattachmentにする方法

Content-Dispositionは、ファイルをWEBページとして表示するか、ダウンロードさせるかを指定するためのheaderです。

挙動
inline(デフォルト) ウェブページとして表示する
attachment ファイルをダウンロードする(「名前を付けて保存」ダイアログを表示する)

また、filenameというパラメータでダウンロードファイルの名前のデフォルト値を設定することも可能です。

// ファイルがダウンロードされる
header('Content-Disposition: attachment; filename="任意のファイル名.json"');

echo $data;

Content-Dispositionは、もともとファイルの扱いを指定するためのheaderなので、これを利用してダウンロード処理を実現するのが最も自然に思えます。Content-Typeと併用も可能なので、正しいMIMEタイプを通知しつつ、ダウンロードさせることができます。

ブラウザーの互換性

Chrome Edge Firefox Internet Explorer Opera Safari Android webview Android 版 Chrome Android 版 Firefox Android 版 Opera iOSのSafari Samsung Internet
Content-Disposition 完全対応あり 完全対応12 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition

上記を見た限り、互換性も問題なさそうです。

curlコマンドで挙動の違いを確認

これまで紹介したContent-TypeやContent-Dispositionなどのheaderを変更しながら、レスポンスがどのように変化するのかを確かめてみました。確認にはcurlコマンドを使いました。

ちなみに、curlは「シーユーアールエル」とも読まれますが、公式に「カール」という発音が書かれているようです。

普通にechoした場合

index.php

<?php

echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: text/html; charset=UTF-8 // HTMLファイルとして出力

ウェブページとして表示されました。

Content-Type: text/plainを指定した場合

index.php

<?php

header("Content-Type: text/plain");
echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: text/plain;charset=UTF-8 // テキストファイルとして出力

ウェブページとして表示されましたが、CSSスタイルに少し違いがありました。

word-wrapプロパティ

word-wrapプロパティは、W3Cで審議中の仕様をInternet Explorerが独自に採用したもので、 表示範囲内に収まりきらない単語がある場合に、単語の途中で改行するかどうかを指定するに使用します

http://www.htmq.com/style/word-wrap.shtml

white-spaceプロパティ

white-spaceプロパティは、
1.ソース中のホワイトスペース(連続する半角スペース・タブ)の表示方法
2.ソース中の改行の表示方法
の2点を指定するプロパティです。 この2つの表示方法の組み合わせパターンの数だけ値が用意されている、と考えると理解しやすいかもしれません。

~~ 中略 ~~

pre-wrap

ソース中のホワイトスペースをそのまま表示
ソース中の改行をそのまま表示
ボックスサイズが指定されている場合にはそれに合わせて自動改行する

http://www.htmq.com/style/white-space.shtml

Content-Type: application/octet-streamを指定する場合

index.php

<?php

header("Content-Type: application/octet-stream");
echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: application/octet-stream // index.phpというファイル名でダウンロードされる

Chromeブラウザで確かめた結果、index.phpというファイル名でダウンロードされました。

Content-Disposition: attachmentを指定する場合

index.php

<?php

header('Content-Disposition: attachment; filename="hello.txt"');
echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: text/html; charset=UTF-8 // HTMLファイルとして認識される
~~ 中略 ~~
< Content-Disposition: attachment; filename="hello.txt" // 指定されたファイル名でダウンロード

Content-Type: application/octet-streamを利用したときはindex.phpというファイル名でダウンロードされましたが、Content-Disposition: attachmentを利用したときは、ちゃんとhello.txtという指定したファイル名でダウンロードされることが確認できました。

しかし、細かいことかもしれませんが、Content-Type: text/htmlと認識されている点が気になりました。

Content-TypeとContent-Dispositionの両方を指定する場合

index.php

<?php

header('Content-Disposition: attachment; filename="hello.txt"');
header("Content-Type: text/plain");
echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: text/plain;charset=UTF-8 // プレーンテキストとして認識される
~~ 中略 ~~
< Content-Disposition: attachment; filename="hello.txt"

このようにContent-TypeとContent-Dispositionの両方を指定すると、正しいMIMEタイプが認識され、かつダウンロード処理やファイル名のデフォルト値も意図した通りに実現できました。

少し面倒ですが、ファイルをダウンロードさせたいときはこのように記述するのが良さそうです。

まとめ

この記事で書いたことをまとめると次のようになります。

  • Content-Typeをタイプミスすると表示可能なテキストファイルでもダウンロードされる
  • ブラウザは未知のMIMEタイプを認識すると実行せずにファイルをダウンロードする
  • ファイルをダウンロードさせたいときはContent-Typeにapplication/force-downloadapplication/octet-streamがよく使われる
  • Content-Dispositionをattachmentにすると任意のファイル名でダウンロードさせることができる

ファイルをダウンロードさせたいとき、Content-Typeを利用する方法とContent-Dispositionを利用する方法、どちらでも実現できますが、Content-TypeでMIMEタイプを指定し、Content-Dispositionでファイルの処理方法を指定するのが本来の分担のようです。