技術資料

Laravel 13+Vue.jsで画像・CSVを非同期アップロードする:FormData・ファイル検証・非公開保存

作成日:2026.08.31

Laravel 13のBladeに組み込んだVue.jsから、FormDataを使って画像またはCSVを非同期で送信する方法を解説します。JPEG・PNGの画像検証、CSVの文字コード変換と簡易的な内容確認、20MBのサイズ制限、非公開ディスクへの保存、CSRF・バリデーションエラー処理を実装し、fetchを基本にaxiosとの違いも整理します。

以前、Vue.jsのフォームからLaravelへJSONを送信して、登録・編集処理を非同期で実行する記事を書きました。今回はその続きとして、FormDataを使って画像やCSVファイルのアップロード処理を実装します。

画像はJPEGとPNGに限定し、CSVは文字コードをUTF-8へそろえてから、ヘッダー、列数、必須項目を簡単に確認します。アップロードしたファイルは、ブラウザから直接アクセスできない非公開ディスクへ保存します。

前提

今回の前提環境は以下の通りです。

  • OS: Windows 11
  • PHP: 8.3.15
  • Laravel Framework: 13.24.0
  • Node.js: 20.20.2
  • npm: 10.8.2
  • Vue.js: 3.5.41
  • Vite: 8.2.0
  • ターミナル: PowerShell

LaravelのプロジェクトへVue.jsを組み込む方法と、非同期の登録・編集フォームの基本は、次の記事を前提にします。

今回は、画像用とCSV用のフォームを分けます。1回の送信で扱うファイルは1件だけです。認証・認可、複数ファイル、画像加工、CSVデータの本格的なインポート処理、保存したファイルの表示やダウンロードは扱いません。

ファイルサイズの上限は20MBとします。実際の環境では、Laravelのバリデーションだけでなく、PHPやWebサーバー側のアップロード上限も確認する必要があります。

ファイルを非同期で送信するときの考え方

JSONを送信する場合は、入力値をJSON.stringify()で文字列化していました。ファイルを送信するときは、文字列とFileオブジェクトをFormDataへ追加します。

const formData = new FormData();
formData.append('file', selectedFile);

const response = await fetch('/uploads/image', {
    method: 'POST',
    body: formData,
});

FormDatafetchで送信するときは、Content-Typeを自分で設定しません。ブラウザが、フォーム項目の区切りに使うboundaryを含めたContent-Typeを設定します。手動で設定すると、サーバーがmultipartの内容を正しく分解できないことがあります。詳しくはMDNのFormDataの利用方法を参照してください。

CSRFトークンは、前回の記事と同じくBladeのmeta要素から取得して、X-CSRF-TOKENヘッダーへ設定します。Acceptにはapplication/jsonを指定し、バリデーションエラーをJSONで受け取れるようにします。

保存先を非公開にする

今回は、アップロードした画像やCSVをブラウザから直接表示・ダウンロードしないため、Laravelのlocalディスクへ保存します。

Laravel 13の標準構成では、localディスクの保存先はstorage/app/privateです。publicディスクのようにpublic/storageへのシンボリックリンクを作成しないため、保存したファイルをURLから直接参照する構成になりません。

公開してよい画像を保存する場合は、公開ディスクやstorage:linkを検討できます。しかし、利用者がアップロードしたファイルを非公開にしたい場合は、公開ディスクへ保存しないことが重要です。Laravelのファイル保存についてはLaravel 13.x File Storageを参照してください。

ルートとControllerを作成する

画像用とCSV用に、それぞれPOSTルートを用意します。

<?php

use App\Http\Controllers\FileUploadController;
use Illuminate\Support\Facades\Route;

Route::view('/uploads', 'uploads.index')
    ->name('uploads.index');
Route::post('/uploads/image', [FileUploadController::class, 'image'])
    ->name('uploads.image');
Route::post('/uploads/csv', [FileUploadController::class, 'csv'])
    ->name('uploads.csv');

Controllerを作成します。

php artisan make:controller FileUploadController

app/Http/Controllers/FileUploadController.phpを、次の内容にします。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Rules\File;
use InvalidArgumentException;

class FileUploadController extends Controller
{
    public function image(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'file' => [
                'required',
                File::image()->max('20mb'),
                'mimes:jpg,jpeg,png',
                'extensions:jpg,jpeg,png',
            ],
        ], [
            'file.required' => '画像ファイルを選択してください。',
            'file.image' => '画像ファイルを選択してください。',
            'file.mimes' => 'JPEGまたはPNGの画像を選択してください。',
            'file.extensions' => '拡張子がjpg、jpeg、pngのファイルを選択してください。',
            'file.max' => '画像のサイズは20MB以下にしてください。',
        ]);

        $path = $validated['file']->store('uploads/images', 'local');

        if ($path === false) {
            return response()->json([
                'message' => '画像を保存できませんでした。',
            ], 500);
        }

        return response()->json([
            'message' => '画像のアップロードが完了しました。',
        ], 201);
    }

    public function csv(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'file' => [
                'required',
                File::types(['csv'])->max('20mb'),
                'extensions:csv',
            ],
        ], [
            'file.required' => 'CSVファイルを選択してください。',
            'file.file' => 'CSVファイルを選択してください。',
            'file.extensions' => '拡張子がcsvのファイルを選択してください。',
            'file.max' => 'CSVのサイズは20MB以下にしてください。',
        ]);

        try {
            $rowCount = $this->validateCsv($validated['file']);
        } catch (InvalidArgumentException $e) {
            return response()->json([
                'errors' => [
                    'file' => [$e->getMessage()],
                ],
            ], 422);
        }

        $path = $validated['file']->store('uploads/csv', 'local');

        if ($path === false) {
            return response()->json([
                'message' => 'CSVを保存できませんでした。',
            ], 500);
        }

        return response()->json([
            'message' => 'CSVのアップロードが完了しました。',
            'rows' => $rowCount,
        ], 201);
    }

    private function validateCsv(UploadedFile $file): int
    {
        $realPath = $file->getRealPath();

        if ($realPath === false) {
            throw new InvalidArgumentException('CSVの一時ファイルを確認できませんでした。');
        }

        $contents = file_get_contents($realPath);

        if ($contents === false) {
            throw new InvalidArgumentException('CSVを読み込めませんでした。');
        }

        $encoding = mb_detect_encoding(
            $contents,
            ['UTF-8', 'SJIS-win', 'CP932'],
            true,
        );

        if ($encoding === false) {
            throw new InvalidArgumentException(
                'CSVの文字コードを判定できませんでした。',
            );
        }

        $contents = mb_convert_encoding($contents, 'UTF-8', $encoding);

        if ($contents === false) {
            throw new InvalidArgumentException('CSVの文字コードを変換できませんでした。');
        }

        $contents = preg_replace('/^\xEF\xBB\xBF/', '', $contents) ?? $contents;

        $handle = fopen('php://temp', 'r+');

        if ($handle === false || fwrite($handle, $contents) === false) {
            throw new InvalidArgumentException('CSVを一時的に読み込めませんでした。');
        }

        rewind($handle);

        try {
            $header = fgetcsv($handle, null, ',', '"', '');

            if ($header === false || $header === [null]) {
                throw new InvalidArgumentException('CSVのヘッダーを読み込めませんでした。');
            }

            $header = array_map(
                static fn ($value): string => trim((string) $value),
                $header,
            );

            if (count($header) !== count(array_unique($header))) {
                throw new InvalidArgumentException('CSVのヘッダーに重複があります。');
            }

            $requiredHeaders = ['name', 'email'];
            $missingHeaders = array_diff($requiredHeaders, $header);

            if ($missingHeaders !== []) {
                throw new InvalidArgumentException(
                    'CSVにはnameとemailの列が必要です。',
                );
            }

            $rowNumber = 1;
            $rowCount = 0;

            while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) {
                $rowNumber++;

                if ($row === [null]
                    || (count($row) === 1 && trim((string) $row[0]) === '')
                ) {
                    continue;
                }

                if (count($row) !== count($header)) {
                    throw new InvalidArgumentException(
                        $rowNumber . '行目の列数がヘッダーと一致しません。',
                    );
                }

                $values = array_combine($header, $row);

                if ($values === false) {
                    throw new InvalidArgumentException(
                        $rowNumber . '行目を読み込めませんでした。',
                    );
                }

                if (trim((string) $values['name']) === '') {
                    throw new InvalidArgumentException(
                        $rowNumber . '行目のnameが空です。',
                    );
                }

                if (filter_var(
                    trim((string) $values['email']),
                    FILTER_VALIDATE_EMAIL,
                ) === false) {
                    throw new InvalidArgumentException(
                        $rowNumber . '行目のemailが正しくありません。',
                    );
                }

                $rowCount++;
            }

            return $rowCount;
        } finally {
            fclose($handle);
        }
    }
}

Laravelのファイル検証には、ファイル種別やサイズを指定できるFileルールがあります。File::image()は画像として扱えるファイルを確認し、max('20mb')でサイズを制限します。

今回はJPEGとPNGだけを対象にするため、mimesextensionsも追加しました。mimesはファイル内容から推測したMIMEタイプに基づく検証であり、利用者が指定した拡張子そのものの確認にはextensionsを使います。拡張子だけでファイルの種類を判断しないようにします。詳しくはLaravel 13.xのファイル検証を参照してください。

CSVでは、Laravelのファイル検証に加えて、アプリケーション側で内容を読み込みます。ここでは説明用のCSVとして、nameemailの2列を必須にしました。CSVの利用目的によって必要な列は変わるため、実際のアプリケーションでは要件に合わせて変更してください。

mb_detect_encodingでUTF-8、SJIS-win、CP932のいずれかを判定し、mb_convert_encodingでUTF-8へ変換しています。BOMが付いている場合は、ヘッダー名の先頭に余計な文字が入らないように取り除きます。

fgetcsvの第5引数には空文字列を明示しています。PHP 8.4以降では、fgetcsvのescape引数を省略して既定値に依存することが非推奨になっているためです。CSVを読み込む方法についてはPHPのfgetcsv、文字コード変換についてはPHPのmb_convert_encodingを参照してください。

この例では20MBまでの内容を読み込んでから一時ストリームへ渡しています。大きなCSVを本格的に処理する場合は、ファイル全体をメモリへ読み込まず、ストリーム処理やキューを使う構成を検討してください。

Bladeに2つのフォームを用意する

resources/views/uploads/index.blade.phpを作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>ファイルアップロード</title>
</head>
<body>
    <h1>ファイルアップロード</h1>

    <h2>画像</h2>
    <div id="image-upload-form"></div>

    <h2>CSV</h2>
    <div id="csv-upload-form"></div>

    @vite('resources/js/app.js')
</body>
</html>

画像とCSVでマウント先を分けています。CSRFトークンはmeta要素へ出力し、Vue.jsのコンポーネントから参照します。

Vue.jsのアップロードフォームを作成する

画像とCSVのフォームでは、送信先と選択可能なファイルの種類が異なります。処理の大部分は共通しているため、FileUploadForm.vueを作成して、propsで差し替えます。

resources/js/app.jsを次のようにします。

import { createApp } from 'vue';
import FileUploadForm from './components/FileUploadForm.vue';

const mountFileUploadForm = (selector, props) => {
    const target = document.querySelector(selector);

    if (target) {
        createApp(FileUploadForm, props).mount(target);
    }
};

mountFileUploadForm('#image-upload-form', {
    title: '画像を選択',
    accept: 'image/jpeg,image/png',
    endpoint: '/uploads/image',
});

mountFileUploadForm('#csv-upload-form', {
    title: 'CSVを選択',
    accept: '.csv,text/csv',
    endpoint: '/uploads/csv',
});

続いて、resources/js/components/FileUploadForm.vueを作成します。

<script setup>
import { ref } from 'vue';

const props = defineProps({
    title: {
        type: String,
        required: true,
    },
    accept: {
        type: String,
        required: true,
    },
    endpoint: {
        type: String,
        required: true,
    },
});

const fileInput = ref(null);
const selectedFile = ref(null);
const errors = ref({});
const generalError = ref('');
const successMessage = ref('');
const processing = ref(false);

const csrfToken = document
    .querySelector('meta[name="csrf-token"]')
    ?.getAttribute('content');

const selectFile = (event) => {
    selectedFile.value = event.target.files?.[0] ?? null;
    errors.value = {};
    generalError.value = '';
    successMessage.value = '';
};

const submit = async () => {
    errors.value = {};
    generalError.value = '';
    successMessage.value = '';

    if (!selectedFile.value) {
        errors.value = {
            file: ['ファイルを選択してください。'],
        };
        return;
    }

    processing.value = true;

    const formData = new FormData();
    formData.append('file', selectedFile.value);

    try {
        const response = await fetch(props.endpoint, {
            method: 'POST',
            headers: {
                'Accept': 'application/json',
                'X-CSRF-TOKEN': csrfToken,
            },
            body: formData,
        });

        const data = await response.json().catch(() => ({}));

        if (response.status === 422) {
            errors.value = data.errors ?? {};
            return;
        }

        if (!response.ok) {
            throw new Error('アップロードに失敗しました。');
        }

        successMessage.value = data.message ?? 'アップロードが完了しました。';
        selectedFile.value = null;

        if (fileInput.value) {
            fileInput.value.value = '';
        }
    } catch {
        generalError.value = '通信に失敗しました。時間をおいて再度お試しください。';
    } finally {
        processing.value = false;
    }
};
</script>

<template>
    <form @submit.prevent="submit">
        <p v-if="generalError">{{ generalError }}</p>
        <p v-if="successMessage">{{ successMessage }}</p>

        <div>
            <label>
                {{ props.title }}
                <input
                    ref="fileInput"
                    type="file"
                    :accept="props.accept"
                    :disabled="processing"
                    @change="selectFile"
                >
            </label>

            <p v-if="selectedFile">
                選択中: {{ selectedFile.name }}
                ({{ Math.ceil(selectedFile.size / 1024) }}KB)
            </p>

            <p v-for="message in errors.file" :key="message">
                {{ message }}
            </p>
        </div>

        <button type="submit" :disabled="processing">
            {{ processing ? '送信中...' : 'アップロードする' }}
        </button>
    </form>
</template>

accept属性は、ファイル選択ダイアログで候補を絞るための指定です。利用者が拡張子を変更したり、別の方法でリクエストを送信したりできるため、これだけでファイルの種類を保証することはできません。最終的な検証はLaravel側で行います。

送信中はprocessingtrueにして、ファイル入力欄とボタンを無効にしています。今回は進捗率を表示せず、送信中であることだけを表示します。

また、fetchのリクエストへContent-Typeを設定していません。FormDataを使う場合は、ブラウザにboundaryを設定させるためです。

fetchとaxiosの違い

今回の基本実装には、ブラウザ標準のfetchを使いました。追加パッケージが不要で、FormDataをそのままリクエストbodyへ渡せます。

Axiosをすでにプロジェクトで使っている場合は、同じFormDataを渡して送信できます。

import axios from 'axios';

const formData = new FormData();
formData.append('file', selectedFile.value);

try {
    const response = await axios.post('/uploads/image', formData, {
        headers: {
            'Accept': 'application/json',
            'X-CSRF-TOKEN': csrfToken,
        },
    });

    successMessage.value = response.data.message;
} catch (error) {
    if (error.response?.status === 422) {
        errors.value = error.response.data.errors ?? {};
    } else {
        generalError.value = '通信に失敗しました。';
    }
}

Axiosでも、ブラウザでFormDataを送信する場合は、Content-Typeを手動で指定しない構成にします。Axiosのバージョンや実行環境による挙動は、対象プロジェクトで確認してください。

比較項目 fetch axios
導入 ブラウザ標準のため追加インストール不要 npmでパッケージを追加する
FormData bodyへ渡して送信する リクエストデータへ渡して送信する
HTTPエラー response.okやステータスを自分で確認する 2xx以外をcatchで扱いやすい
共通設定 共通処理を自分で用意する インスタンスや初期設定へまとめやすい
今回の選択 依存を増やさず小さく実装するため採用 既存プロジェクトで利用中なら候補

今回のように送信中の表示と基本的なエラー処理だけであれば、fetchで実装できます。複数画面で共通の通信設定やエラー処理を使う場合は、既存の依存関係も考慮してAxiosを選択します。

動作を確認する

LaravelとViteの開発サーバーを起動します。

php artisan serve
npm run dev

ブラウザで次のURLを開きます。

http://127.0.0.1:8000/uploads
画像をアップロードする

JPEGまたはPNGの画像を選択して、「アップロードする」をクリックします。エラーが発生せず、画面に「画像のアップロードが完了しました。」と表示されれば、送信と保存の処理は完了です。

今回は非公開ディスクへ保存しているため、保存した画像のURLを画面には表示していません。保存先はLaravelプロジェクトのstorage/app/private/uploads/imagesです。

CSVをアップロードする

次のようなCSVを作成します。

name,email
山田太郎,taro@example.test
佐藤花子,hanako@example.test

CSVを選択して送信すると、Laravel側で文字コードを確認してUTF-8へ変換し、ヘッダー、列数、nameemailの値を確認します。問題がなければCSVを保存し、読み込んだ行数をレスポンスへ含めます。

バリデーションエラーを確認する

次のような入力でエラーを確認します。

  • ファイルを選択せずに送信する
  • JPEG・PNG以外の画像を選択する
  • CSV以外のファイルを選択する
  • 20MBを超えるファイルを選択する
  • CSVのヘッダーからnameまたはemailを削除する
  • CSVの行ごとの列数を変える
  • CSVのemailへメールアドレスではない値を入れる

Laravelの通常のファイル検証に失敗した場合は422のJSONが返り、Vue.jsのerrors.fileへメッセージが表示されます。CSVの内容確認で失敗した場合も、Controllerで422のJSONへ変換しています。

ブラウザのNetworkタブを確認する

開発者ツールのNetworkタブで、リクエストの次の項目を確認します。

  • Request MethodがPOSTであること
  • Request HeadersにX-CSRF-TOKENが含まれていること
  • Request Payloadがmultipart/form-dataとして送信されていること
  • Responseのステータスが、成功時は201、入力エラー時は422であること

Request HeadersのContent-Typeには、ブラウザが設定したboundaryが含まれます。コードでContent-Type: multipart/form-dataを手動設定している場合は削除します。

PHPのアップロード上限を確認する

Laravel側で20MBを許可していても、PHPのupload_max_filesizepost_max_sizeが小さい場合は、Laravelへ到達する前に制限されることがあります。現在の設定は次のコマンドで確認できます。

php -i | Select-String "upload_max_filesize|post_max_size"

Webサーバーが使用しているPHPと、ターミナルで確認したPHPが異なる場合があります。ブラウザからLaravelへアクセスするときに使用されるPHPの設定も確認してください。

注意点

  • accept属性だけでファイルの種類を判断せず、Laravel側で検証する。
  • 拡張子だけで画像やCSVの安全性を判断しない。
  • 元のファイル名をそのまま保存先のファイル名に使わず、Laravelの保存機能で生成される名前を使う。
  • 公開する必要がないファイルをpublicディスクへ保存したり、公開URLを返したりしない。
  • ファイルの保存先、元のファイル名、CSVの内容をログへ不用意に出力しない。
  • 実際のアプリケーションでは、認証・認可を行い、利用者が保存先へアクセスできるかを確認する。
  • ファイル数、合計容量、保存期間、不要ファイルの削除方法を別途設計する。
  • CSVの内容を後で表計算ソフトへ出力する場合は、セルの値が数式として解釈される問題も確認する。
  • 大きなCSVや時間のかかる処理は、リクエスト中に完了させずキューなどを検討する。
  • ファイルアップロード処理を本番環境へ導入する場合は、Webサーバー、PHP、ストレージ側の制限も確認する。

まとめ

今回は、Laravel 13とVue.jsの非同期フォームから、画像またはCSVファイルをアップロードしました。

ファイルはFormDataへ追加して送信し、fetch側ではContent-Typeを手動で指定しません。Laravel側では、画像をJPEG・PNGに限定し、CSVは文字コードをUTF-8へ変換して、ヘッダー、列数、必須項目を確認しました。

保存先には非公開のlocalディスクを使い、アップロードしたファイルのURLは画面へ返していません。画像やCSVを利用者へ公開する必要がある場合は、公開範囲、認証・認可、ダウンロード処理を別途設計する必要があります。

今回は送信中であることだけを表示しました。アップロード進捗率、大きなCSVの非同期処理、画像加工、複数ファイルの扱いなどは、必要になった段階で追加の設計を検討します。

参考資料

この記事を書いた人

※上が私です。

奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。

これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。

【制作会社・企業様向けサポート】
  • 専任エンジニアのいない企業様に対するシステム面の不安を解消
  • 柔軟な契約形態や短納期での対応により、急なニーズにも迅速にサポート
  • システムの企画段階から運用まで、ワンストップでのサービスを提供

Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。

ぜひ、プロフィールWeb制作会社様向け業務案内一般企業様向け業務案内もご覧くださいね。

Laravel 13+Vue.jsで登録・編集フォームを非同期化する:fetchとaxiosを比較

2026.08.26

Laravel 13のBlade画面にVue.jsを組み込み、membersテーブルのnameとemailを登録・編集するフォームを作ります。Vue.jsからLaravelのWebルートへJSONを送信し、CSRFトークン、バリデーションエラー、送信中・通信失敗の状態を扱います。リクエスト送信にはfetchとaxiosを使う方法を比較し、追加パッケージの有無やエラー処理の違いを整理します。

JavaScript Laravel PHP Vue.js

Laravel 13のBladeにVue.jsを組み込む

2026.08.18

Laravel 13のBlade画面にVue.jsを部分的に組み込み、Controllerから渡した商品データを使って商品名を検索する方法を解説します。Viteの設定、BladeからVue.jsへの初期データの渡し方、Js::fromとdata-*属性の比較、npm run devによる確認方法、ブラウザ内検索とサーバー側検索の使い分けを扱います。

JavaScript Laravel PHP Vue.js

CodeIgniter 4のViewにVue.jsを組み込む

2026.08.20

CodeIgniter 4のView全体はサーバー側で描画したまま、一部分だけVue.jsを組み込む方法を解説します。Viteの開発サーバーからJavaScriptを読み込む方法、本番ビルドしたアセットをmanifestから取得してApacheで配信する方法、ControllerからViewへ渡したデータをVue.jsで検索する方法を確認します。

CodeIgniter JavaScript PHP Vue.js

MarkdownをHTMLへ変換する方法とXSS対策:PHP・JavaScript・Vue.jsの実装例

2026.08.12

PHP のleague/commonmarkと、Node.js のMarked・DOMPurifyを使い、MarkdownをHTMLへ変換する方法を紹介します。ユーザー入力やAI生成Markdownを未信頼入力として扱い、Vue.js のv-htmlへ安全に表示するための設定と注意点も整理します。

JavaScript PHP Vue.js

阿部辰也へのお仕事の依頼・お問い合わせ

軽いご相談もお気軽にどうぞ!

個人情報の取り扱いについて *必須 プライバシーポリシーをご確認いただき、同意いただける場合は「同意する」にチェックをしてください。

keyboard_double_arrow_up
TOP