JavaScriptから画像やPDFを送信したいときは、FormDataへファイルを入れ、fetch()のbodyへ渡します。ただ、送信コードだけでは「サーバーに届いたのか」「どこを見れば成功と判断できるのか」が分かりにくいことがあります。
この記事では、ファイルを選んでPHPへ送信し、サーバーへ保存できる最小構成を作ります。ブラウザ側だけで終わらせず、Chrome DevToolsの「Network」パネルでリクエストとレスポンスを確認するところまで進めます。
先にポイントを挙げると、FormDataを送るときはContent-Typeを自分で指定しません。ブラウザに任せることで、ファイルの区切りに必要なboundaryも含めて設定されます。

- 画像またはPDFを選択できるHTMLフォーム
fetch()とFormDataを使った送信処理- ファイル形式と容量を確認して保存するPHP処理
- DevToolsで送信内容と結果を確認する手順
完成イメージとファイルアップロードの流れ
完成すると、タイトルとファイルを選んで「アップロード」ボタンを押すだけで、PHP側のuploadsフォルダーへ保存できます。今回はJPEG・PNG・PDF、2MB以下のファイルを受け付けます。
| 段階 | 処理 | 確認する場所 |
|---|---|---|
| 1 | HTMLフォームでファイルを選ぶ | ブラウザ画面 |
| 2 | FormDataへ入力内容をまとめる | DevToolsのPayload |
| 3 | fetch()でPHPへPOSTする | DevToolsのHeaders |
| 4 | PHPで形式・容量を確認して保存する | uploadsフォルダー |
| 5 | JSONで結果を返す | DevToolsのResponse |

- HTML・JavaScript・PHPを同じローカルサーバーで動かす
- PHP 8系が実行できる環境を用意する
- 動作確認には個人情報を含まない画像またはPDFを使う
1.作業用フォルダーを作成する
最初に、任意の場所へfetch-uploadフォルダーを作成します。最終的な構成は次のとおりです。
fetch-upload/
├─ index.html
├─ upload.php
└─ uploads/ ← 初回アップロード時に自動作成手作業で作るのはindex.htmlとupload.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()する方法もありますが、今回はフォーム全体をそのまま渡しています。
headersへContent-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タイプを確認してから保存します。

4.ローカルサーバーを起動する
ターミナルまたはコマンドプロンプトでfetch-uploadフォルダーへ移動し、PHPの組み込みサーバーを起動します。
php -S localhost:8000PHPをインストールしていない場合、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/を開きます。

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

5.Chrome DevToolsで送信内容を確認する
画面に成功メッセージが出たら終わりにせず、実際の通信も確認します。ChromeでF12キーを押し、「Network」パネルを開いた状態でもう一度アップロードしてください。
HeadersでPOSTとContent-Typeを確認する
一覧からupload.phpを選び、「Headers」を開きます。次の2点を確認します。
- Request Methodが
POSTになっている - Content-Typeが
multipart/form-data; boundary=...になっている

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

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

Networkパネルでは、リクエストのHeaders、Payload、Responseを同じ通信にひも付けて確認できます。画面だけを見るより、ブラウザから何が送られ、サーバーが何を返したかを追いやすくなります。
うまくアップロードできないときの確認ポイント
| 症状 | 確認する場所 | 主な原因 |
|---|---|---|
| 「ファイルが送信されていません」 | Payload | HTMLとPHPでnameが一致していない |
| 415エラー | Response・選択ファイル | 許可していないMIMEタイプ |
| 413エラー | Response・PHP設定 | 2MB超過、またはサーバー側の上限超過 |
| 500エラー | Response・PHPログ | フォルダー作成やファイル保存に失敗 |
Class "finfo" not found | PHP設定・ターミナル | Fileinfo拡張機能が有効になっていない |
Failed to fetch | Console・Headers | URL誤り、サーバー停止、CORSなど |
Unexpected token '<' | Response | JSONではなくHTMLのエラーページが返った |
Class "finfo" not foundが表示される
今回の検証では、最初にファイルをアップロードしたとき、PHPのターミナルへ次のエラーが表示されました。
Fatal error: Uncaught Error: Class "finfo" not foundfinfoは、アップロードされたファイルのMIMEタイプを実データから判定するために使っています。このエラーは、コードや選択したファイルではなく、PHPのFileinfo拡張機能が有効になっていない場合に発生します。
まず、新しいコマンドプロンプトで次のコマンドを実行します。
where php
php --iniwhere 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.iniでextension_dirとextension=fileinfoを設定したら、起動中のPHPサーバーをCtrl+Cで終了し、コマンドプロンプトを開き直してから有効化を確認します。
php --ini
php -m | findstr /I fileinfo「Loaded Configuration File」に作成したphp.iniのパスが表示され、続けてfileinfoと表示されれば有効です。その後、作業フォルダーへ移動してPHPサーバーを起動し直します。
php -S localhost:8000php_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()でファイルを送るときは、FormDataをbodyへ渡します。Content-Typeは手動で設定せず、ブラウザにboundaryを含めて生成させるのがポイントです。
送信後は画面のメッセージだけでなく、Chrome DevToolsの「Headers」「Payload」「Response」を確認すると、ブラウザ側とPHP側のどちらで詰まっているかを切り分けやすくなります。まずはローカルで一連の流れを作り、本番へ組み込むときに認証や保存方法などの安全対策を追加してください。


コメント