fetchとFormDataでファイルをアップロードする方法|JavaScriptからPHPへ画像・PDFを送信

fetchとFormDataで画像・PDFをPHPへアップロードするイメージ

JavaScriptから画像やPDFを送信したいときは、FormDataへファイルを入れ、fetch()bodyへ渡します。ただ、送信コードだけでは「サーバーに届いたのか」「どこを見れば成功と判断できるのか」が分かりにくいことがあります。

この記事では、ファイルを選んでPHPへ送信し、サーバーへ保存できる最小構成を作ります。ブラウザ側だけで終わらせず、Chrome DevToolsの「Network」パネルでリクエストとレスポンスを確認するところまで進めます。

先にポイントを挙げると、FormDataを送るときはContent-Typeを自分で指定しません。ブラウザに任せることで、ファイルの区切りに必要なboundaryも含めて設定されます。

fetchとFormDataでファイルを送信し、PHPで保存してJSONを返す流れ
この記事で作るもの
  • 画像またはPDFを選択できるHTMLフォーム
  • fetch()FormDataを使った送信処理
  • ファイル形式と容量を確認して保存するPHP処理
  • DevToolsで送信内容と結果を確認する手順
目次

完成イメージとファイルアップロードの流れ

完成すると、タイトルとファイルを選んで「アップロード」ボタンを押すだけで、PHP側のuploadsフォルダーへ保存できます。今回はJPEG・PNG・PDF、2MB以下のファイルを受け付けます。

段階処理確認する場所
1HTMLフォームでファイルを選ぶブラウザ画面
2FormDataへ入力内容をまとめるDevToolsのPayload
3fetch()でPHPへPOSTするDevToolsのHeaders
4PHPで形式・容量を確認して保存するuploadsフォルダー
5JSONで結果を返すDevToolsのResponse
ファイル選択フォームとアップロード成功メッセージ
この記事の前提
  • HTML・JavaScript・PHPを同じローカルサーバーで動かす
  • PHP 8系が実行できる環境を用意する
  • 動作確認には個人情報を含まない画像またはPDFを使う

1.作業用フォルダーを作成する

最初に、任意の場所へfetch-uploadフォルダーを作成します。最終的な構成は次のとおりです。

fetch-upload/
├─ index.html
├─ upload.php
└─ uploads/  ← 初回アップロード時に自動作成

手作業で作るのはindex.htmlupload.phpの2ファイルです。保存先のuploadsフォルダーは、PHP側で存在を確認して作成します。

2.HTMLフォームとJavaScriptの送信処理を作る

fetch-uploadフォルダーにindex.htmlを作成し、次のコードを保存します。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ファイルアップロード</title>
</head>
<body>
  <main>
    <h1>ファイルアップロード</h1>

    <!-- タイトルとファイルをまとめて送信するフォーム -->
    <form id="upload-form">
      <div>
        <label for="title">タイトル</label>
        <input type="text" id="title" name="title" required>
      </div>

      <div>
        <label for="file">画像またはPDF</label>
        <input
          type="file"
          id="file"
          name="file"
          accept="image/jpeg,image/png,application/pdf"
          required
        >
      </div>

      <button type="submit">アップロード</button>
    </form>

    <!-- JavaScriptから成功・失敗のメッセージを表示する場所 -->
    <p id="result" role="status"></p>
  </main>

  <script>
    // 操作するフォームと、結果表示用の要素を取得
    const form = document.querySelector("#upload-form");
    const result = document.querySelector("#result");

    // フォームが送信されたときに処理を実行
    form.addEventListener("submit", async (event) => {
      // 通常のフォーム送信(画面の再読み込み)を止める
      event.preventDefault();

      // 二重送信を防ぎ、処理中であることを表示
      const button = form.querySelector("button");
      button.disabled = true;
      result.textContent = "送信中...";

      try {
        // name属性が付いたタイトルとファイルをまとめる
        const formData = new FormData(form);

        // FormDataをupload.phpへPOST送信する
        const response = await fetch("upload.php", {
          method: "POST",
          body: formData,
        });

        // PHPから返ったJSONをJavaScriptのデータへ変換
        const data = await response.json();

        // 200~299以外のレスポンスはエラーとして扱う
        if (!response.ok) {
          throw new Error(data.message ?? `HTTP ${response.status}`);
        }

        // 成功メッセージを表示し、入力内容をリセット
        result.textContent = `${data.message} 保存名: ${data.filename}`;
        form.reset();
      } catch (error) {
        // 通信失敗やPHP側のエラー内容を画面へ表示
        result.textContent = `失敗しました: ${error.message}`;
      } finally {
        // 成功・失敗にかかわらず、送信ボタンを再び有効にする
        button.disabled = false;
      }
    });
  </script>
</body>
</html>

new FormData(form)とすると、フォーム内でname属性が付いた「タイトル」と「ファイル」がまとめて格納されます。個別にappend()する方法もありますが、今回はフォーム全体をそのまま渡しています。

注意

headersContent-Type: multipart/form-dataを追加しないでください。ブラウザが実際の送信内容に合うboundaryを付けられず、PHP側でファイルを取得できない原因になります。

fetch()は404や500のレスポンスを受け取っても、それだけではcatchへ移りません。そのため、response.okを確認し、200~299以外をエラーとして扱っています。

3.PHPでファイルを受信して保存する

同じフォルダーにupload.phpを作成し、次のコードを保存します。

<?php
declare(strict_types=1);

// ブラウザへJSON形式で返すことを伝える
header('Content-Type: application/json; charset=UTF-8');

// HTTPステータスとJSONを返し、PHPの処理を終了する
function respond(int $status, array $data): void
{
    http_response_code($status);
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
    exit;
}

// POST以外のアクセスは受け付けない
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    respond(405, ['message' => 'POSTで送信してください。']);
}

// HTMLのname="file"からファイルが届いたか確認
if (!isset($_FILES['file'])) {
    respond(400, ['message' => 'ファイルが送信されていません。']);
}

$file = $_FILES['file'];

// PHPが検出したアップロードエラーを確認
if ($file['error'] !== UPLOAD_ERR_OK) {
    respond(400, ['message' => 'アップロード中にエラーが発生しました。']);
}

// 上限を2MB(バイト単位)に設定
$maxBytes = 2 * 1024 * 1024;

if ($file['size'] > $maxBytes) {
    respond(413, ['message' => 'ファイルは2MB以下にしてください。']);
}

// 許可するMIMEタイプと、保存時に使う拡張子
$allowedTypes = [
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
    'application/pdf' => 'pdf',
];

// 拡張子ではなく、ファイルの実データからMIMEタイプを判定
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);

if (!isset($allowedTypes[$mimeType])) {
    respond(415, ['message' => 'JPEG・PNG・PDFのみアップロードできます。']);
}

// upload.phpと同じ場所にあるuploadsフォルダーを保存先にする
$uploadDir = __DIR__ . '/uploads';

if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
    respond(500, ['message' => '保存先フォルダーを作成できませんでした。']);
}

// 元の名前は使わず、推測されにくいランダムな保存名を作る
$savedName = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mimeType];
$destination = $uploadDir . '/' . $savedName;

// PHPの一時保存場所からuploadsフォルダーへ移動
if (!move_uploaded_file($file['tmp_name'], $destination)) {
    respond(500, ['message' => 'ファイルを保存できませんでした。']);
}

// HTMLのname="title"からタイトルを取得
$title = trim((string) ($_POST['title'] ?? ''));

// 保存成功を表す201と、画面表示に使うデータを返す
respond(201, [
    'message' => 'アップロードしました。',
    'filename' => $savedName,
    'title' => $title,
]);

PHPでは、ブラウザから届いたファイルを$_FILES['file']で取得します。このfileは、HTML側のname="file"と一致させる必要があります。

元のファイル名をそのまま保存せず、random_bytes()で新しい名前を作っています。また、拡張子だけではなく、サーバー側で取得したMIMEタイプを確認してから保存します。

index.htmlとupload.phpを配置したフォルダー

4.ローカルサーバーを起動する

ターミナルまたはコマンドプロンプトでfetch-uploadフォルダーへ移動し、PHPの組み込みサーバーを起動します。

php -S localhost:8000

PHPをインストールしていない場合、WinGetコマンドで簡単にインストールできます(How to install PHP on Windows using WinGet)。以下のコマンドではインストール先をC直下のphpフォルダにしています。

winget install PHP.PHP.8.5 --location "C:\php"

WindowsでXAMPPのPHPを使い、phpコマンドへパスを通していない場合は、次のようにPHPの実行ファイルをフルパスで指定できます。インストール先が異なる場合は読み替えてください。

C:\xampp\php\php.exe -S localhost:8000

起動後、Chromeでhttp://localhost:8000/を開きます。

PHP組み込みサーバーを起動したターミナル

タイトルを入力し、JPEG・PNG・PDFのいずれかを選択して「アップロード」を押します。

アップロード成功メッセージとuploadsフォルダーに保存されたファイル

5.Chrome DevToolsで送信内容を確認する

画面に成功メッセージが出たら終わりにせず、実際の通信も確認します。ChromeでF12キーを押し、「Network」パネルを開いた状態でもう一度アップロードしてください。

HeadersでPOSTとContent-Typeを確認する

一覧からupload.phpを選び、「Headers」を開きます。次の2点を確認します。

  • Request MethodがPOSTになっている
  • Content-Typeがmultipart/form-data; boundary=...になっている
NetworkパネルのHeadersでPOSTとboundaryを確認

Payloadでタイトルとファイルを確認する

「Payload」では、titlefileが送信されていることを確認します。PHP側で$_FILES['file']が空になる場合は、ここでフィールド名と送信内容を先に確認すると切り分けやすくなります。

NetworkパネルのPayloadに表示されたtitleとfile

Responseで保存結果を確認する

「Response」に、PHPが返したJSONが表示されます。成功時はHTTPステータスが201になり、保存後のファイル名も確認できます。

NetworkパネルのResponseに表示されたJSON

Networkパネルでは、リクエストのHeaders、Payload、Responseを同じ通信にひも付けて確認できます。画面だけを見るより、ブラウザから何が送られ、サーバーが何を返したかを追いやすくなります。

うまくアップロードできないときの確認ポイント

症状確認する場所主な原因
「ファイルが送信されていません」PayloadHTMLとPHPでnameが一致していない
415エラーResponse・選択ファイル許可していないMIMEタイプ
413エラーResponse・PHP設定2MB超過、またはサーバー側の上限超過
500エラーResponse・PHPログフォルダー作成やファイル保存に失敗
Class "finfo" not foundPHP設定・ターミナルFileinfo拡張機能が有効になっていない
Failed to fetchConsole・HeadersURL誤り、サーバー停止、CORSなど
Unexpected token '<'ResponseJSONではなくHTMLのエラーページが返った

Class "finfo" not foundが表示される

今回の検証では、最初にファイルをアップロードしたとき、PHPのターミナルへ次のエラーが表示されました。

Fatal error: Uncaught Error: Class "finfo" not found

finfoは、アップロードされたファイルのMIMEタイプを実データから判定するために使っています。このエラーは、コードや選択したファイルではなく、PHPのFileinfo拡張機能が有効になっていない場合に発生します。

まず、新しいコマンドプロンプトで次のコマンドを実行します。

where php
php --ini

where phpでは、現在phpコマンドで呼び出されるphp.exeの場所を確認できます。PHPを複数回インストールしていると、編集したphp.iniとは別のPHPが使われていることがあるため、最初に確認しておくと切り分けやすくなります。

php --iniの「Loaded Configuration File」に表示されたphp.iniを開き、次の設定を確認します。PHPをC:\phpへ配置した場合の例です。

extension_dir = "C:\php\ext"
extension=fileinfo

;extension=fileinfoとなっている場合は、行頭のセミコロンを削除します。extension_dirは、実際のPHPフォルダー内にあるextフォルダーの場所へ合わせてください。

Loaded Configuration File: (none)と表示された場合

php --iniで次のように表示された場合、PHPはphp.iniを読み込んでいません。

Loaded Configuration File: (none)

PHPを展開したフォルダーにあるphp.ini-developmentをコピーし、同じフォルダーへphp.iniという名前で配置します。C:\phpを使っている場合は、次の構成になります。

C:\php\php.exe
C:\php\php.ini
C:\php\php.ini-development
C:\php\ext\php_fileinfo.dll

作成したphp.iniextension_dirextension=fileinfoを設定したら、起動中のPHPサーバーをCtrlCで終了し、コマンドプロンプトを開き直してから有効化を確認します。

php --ini
php -m | findstr /I fileinfo

「Loaded Configuration File」に作成したphp.iniのパスが表示され、続けてfileinfoと表示されれば有効です。その後、作業フォルダーへ移動してPHPサーバーを起動し直します。

php -S localhost:8000
補足

php_fileinfo.dllを読み込めない警告が出る場合は、extension_dirが実際のextフォルダーを指しているか、そこにphp_fileinfo.dllが存在するかを確認してください。

Content-Typeを手動で付けていないか確認する

FormDataを使っているのにPHP側で受け取れない場合は、fetch()headersを確認します。次のようにContent-Typeを固定すると、ブラウザが生成するリクエストボディのboundaryと一致しなくなるため、削除してください。

// FormData送信では指定しない
headers: {
  "Content-Type": "multipart/form-data",
}

accept属性だけで安全だと思わない

HTMLのaccept属性は、ファイル選択画面で選びやすくするためのヒントです。許可していないファイルの送信を完全に防ぐ仕組みではありません。今回のPHPコードでも、サーバー側でMIMEタイプと容量を確認しています。

HTMLが返っている場合はResponseを確認する

response.json()Unexpected token '<'が発生した場合は、PHPの警告や404ページなど、JSON以外の内容が返っている可能性があります。原因の切り分け方は、次の記事で詳しく整理しています。

実運用する際の安全対策

余談にはなりますが、本番環境では用途とリスクに応じて次のような対策も必要です。

  • ログイン状態や権限を確認し、アップロードできる利用者を制限する
  • 許可する拡張子・MIMEタイプ・ファイルサイズをサーバー側で検証する
  • 元のファイル名をそのまま保存せず、サーバー側で名前を生成する
  • 可能ならWeb公開領域の外へ保存し、必要なときだけ配信する
  • CSRF対策、レート制限、マルウェア検査、保存容量の監視を検討する

ファイルアップロードは「拡張子を確認すれば終わり」ではありません。公開環境へ組み込む場合は、OWASPのFile Upload Cheat Sheetも参照し、保存場所やアクセス制御まで含めて設計してください。

まとめ

fetch()でファイルを送るときは、FormDatabodyへ渡します。Content-Typeは手動で設定せず、ブラウザにboundaryを含めて生成させるのがポイントです。

送信後は画面のメッセージだけでなく、Chrome DevToolsの「Headers」「Payload」「Response」を確認すると、ブラウザ側とPHP側のどちらで詰まっているかを切り分けやすくなります。まずはローカルで一連の流れを作り、本番へ組み込むときに認証や保存方法などの安全対策を追加してください。

参考リンク

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

ちゃあむのアバター ちゃあむ エンジニア

Web開発やSaaS(ServiceNow、Salesforce)、業務システムに携わる、猫と食べることが大好きなインドア系ITエンジニアです。

コメント

コメントする


目次