公開日 2026-03-31

Laravelでファイルアップロードを扱う(Storage + validation)

Laravel 13 で画像と PDF のファイルアップロードを作り、Storage の public disk、validation、一覧表示までを最小構成でつなげる。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 新規デモを開いて開始位置をそろえる
  4. 3. public disk と storage:link の役割を先に見る
  5. 4. アップロード記録用のテーブルとモデルを作る
  6. コードのポイント
  7. 5. Form Request と Controller で validation と保存をつなぐ
  8. コードのポイント
  9. 6. multipart/form-data のフォームと一覧画面を作る
  10. 7. 画像 / PDF / validation エラーを順に確認する
  11. 7-1. 画像を 1 件アップロードする
  12. 7-2. PDF を 1 件アップロードする
  13. 7-3. 無効なファイルで validation を確認する
  14. 8. よくある詰まりどころ
  15. storage:link を実行していない
  16. form に multipart/form-data がない
  17. 元ファイル名を保存名に使っている
  18. APP_URL が現在のポートと合っていない
  19. 9. まとめ

Laravel 13 で、画像と PDF のアップロードを最小構成で試す手順です。fresh app から uploads テーブル、FormRequest による validation、public disk への保存、Storage::url() を使った一覧表示までを 1 本でつなぎます。認証、S3 互換 storage、複数ファイル同時アップロードは扱いません。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)
  • composer:2 コンテナ
  • Laravel 13
  • SQLite

コマンド実行は、特記がない限り WSL 側ターミナルです。

1. ゴールと非対象

この記事で着地したいのは次の 4 点です。

  • multipart/form-data のフォームから画像と PDF を送信できる
  • StoreUploadRequest でファイル種別とサイズを検証できる
  • public disk へ保存し、Storage::url() で一覧から開ける
  • 画像はプレビュー、PDF はリンクとして同じ一覧画面で確認できる

今回は、Laravel 13 の file upload の入口だけを扱います。認証、所有者ごとのアクセス制御、S3 / R2 / MinIO への切り替え、画像リサイズ、複数ファイル同時アップロードは含めません。

2. 新規デモを開いて開始位置をそろえる

Laravel 13 の installation docs では laravel new が基本導線です。この記事ではローカルに PHP を直接入れずに試すため、composer:2 コンテナでプロジェクトを作ります。

ここから先のコマンドは、特記がない限り WSL ターミナルで実行します。 Windows 側の PowerShell / Windows Terminal から始める場合は、先に wsl で Ubuntu に入ってください。

wsl

開始位置は ~/projects/laravel-file-upload-demo です。~/projects/... は WSL 側のホーム配下を指し、code . は Remote - WSL の VS Code ウィンドウを開きます。

mkdir -p ~/projects/laravel-file-upload-demo
cd ~/projects/laravel-file-upload-demo
code .
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .

compose.yml は次の内容で作成します。

services:
  app:
    image: composer:2
    user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
    working_dir: /app
    volumes:
      - ./:/app
    ports:
      - "8000:8000"
    command: php artisan serve --host=0.0.0.0 --port=8000

同じシェルで UID / GID を合わせて起動します。

export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose up -d
docker compose ps

.envAPP_URL は、いま開く URL に合わせて更新してください。Storage::url()public disk の URL 設定として既定で APP_URL/storage を使うため、ここが http://localhost のままだと、アップロード後のリンクが http://localhost/storage/... になってポート 8000 へ届きません。

APP_URL=http://localhost:8000

ポートを 8001:8000 に変えた場合は APP_URL=http://localhost:8001 に合わせます。もし変更後も反映されない場合は、docker compose exec app php artisan config:clear を 1 回実行してください。

ブラウザで http://localhost:8000 を開き、Laravel の welcome 画面が見えれば準備完了です。ポート 8000 が埋まっている場合は、compose.yml の左側を 8001:8000 のように変えてから docker compose up -d をやり直してください。

起動確認の例:

docker compose ps で app コンテナが起動している状態 Laravel の welcome 画面

Laravel 13 の filesystem docs では、公開ファイルの既定導線として public disk が用意されています。今回使うのはこの local public disk です。

流れ:

flowchart LR
    F[ブラウザのフォーム] -->|POST /uploads| R[StoreUploadRequest]
    R -->|validation OK| C[UploadController store]
    C -->|save file| S[storage/app/public/uploads]
    C -->|save metadata| D[uploads table]
    S -->|storage link| P[public/storage]
    D --> I[一覧画面]
    P --> I

確認する観点は 3 つです。

  • public disk の実体は storage/app/public
  • ブラウザから見える入口は public/storage
  • その橋渡しをするのが php artisan storage:link

最初にリンクを作ります。

docker compose exec app php artisan storage:link

すでにリンクがある場合は The [public/storage] link already exists. と出ます。そのまま先へ進んで構いません。

Laravel 13 の upgrade docs では CSRF middleware が PreventRequestForgery に整理されていますが、通常の Blade form を web middleware 配下で使うだけなら、今回やることは @csrf を書くことだけです。追加設定は不要です。

4. アップロード記録用のテーブルとモデルを作る

ここでは、ファイル本体は disk へ保存し、一覧表示に必要なメタデータだけを DB に残します。保存するのはタイトル、保存先パス、元ファイル名、MIME type、サイズです。

Laravel アプリのルートで次を実行します。

docker compose exec app php artisan make:model Upload -m
docker compose exec app php artisan migrate:status

生成された migration を開き、database/migrations/*_create_uploads_table.php を次の内容に更新します。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('uploads', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('attachment_path');
            $table->string('original_name');
            $table->string('mime_type', 100);
            $table->unsignedBigInteger('size');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('uploads');
    }
};

app/Models/Upload.php は次の内容に更新します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

class Upload extends Model
{
    protected $fillable = [
        'title',
        'attachment_path',
        'original_name',
        'mime_type',
        'size',
    ];

    protected $casts = [
        'size' => 'integer',
    ];

    public function isImage(): bool
    {
        return str_starts_with($this->mime_type, 'image/');
    }

    public function publicUrl(): string
    {
        return Storage::disk('public')->url($this->attachment_path);
    }
}

コードのポイント

① DB にはファイル本体を持たせず、metadata だけを残す

Schema::create('uploads', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('attachment_path');
    $table->string('original_name');
    $table->string('mime_type', 100);
    $table->unsignedBigInteger('size');
    $table->timestamps();
});

ファイル本体を BLOB で DB に持つのではなく、disk 上の保存先パス・元ファイル名・MIME type・サイズだけを記録します。一覧表示に必要な情報がそろっていれば、Storage::url() でブラウザからファイルを参照できます。

isImage()publicUrl() を model に寄せて view を薄くする

public function isImage(): bool
{
    return str_starts_with($this->mime_type, 'image/');
}

public function publicUrl(): string
{
    return Storage::disk('public')->url($this->attachment_path);
}

MIME type の判定と公開 URL の生成を model 側に持たせることで、Blade 側では $upload->isImage()$upload->publicUrl() を呼ぶだけになります。view が直接 mime_type の文字列比較を書かずに済むため、画像プレビューと PDF バッジの切り替えを追いやすくなります。

最後に migration を実行します。

docker compose exec app php artisan migrate

ここでは DB にファイル本体を持たせません。attachment_path は disk 上の保存先で、実ファイルは storage/app/public/uploads/... に置かれます。

5. Form Request と Controller で validation と保存をつなぐ

Laravel 13 の validation docs では、複雑な入力では FormRequest を使う流れが基本です。今回はタイトルと添付ファイルの 2 項目だけですが、validation と controller の責務を分けるために FormRequest を使います。

先に request と controller を生成します。

docker compose exec app php artisan make:request StoreUploadRequest
docker compose exec app php artisan make:controller UploadController

app/Http/Requests/StoreUploadRequest.php は次の内容に更新します。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;

class StoreUploadRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:100'],
            'attachment' => [
                'required',
                File::types(['jpg', 'jpeg', 'png', 'webp', 'pdf'])
                    ->max('5mb'),
            ],
        ];
    }

    public function attributes(): array
    {
        return [
            'title' => 'タイトル',
            'attachment' => 'ファイル',
        ];
    }
}

File::types(...) は拡張子の見た目だけではなく、ファイル内容から MIME を推定して判定します。画像と PDF を同じ field で許可したいので、ここでは jpg / jpeg / png / webp / pdf をまとめて受け付けます。

app/Http/Controllers/UploadController.php は次の内容に更新します。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreUploadRequest;
use App\Models\Upload;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class UploadController extends Controller
{
    public function index(): View
    {
        return view('uploads.index', [
            'uploads' => Upload::query()->latest()->get(),
        ]);
    }

    public function store(StoreUploadRequest $request): RedirectResponse
    {
        $validated = $request->validated();
        $file = $request->file('attachment');
        $path = $file->store('uploads', 'public');

        Upload::query()->create([
            'title' => $validated['title'],
            'attachment_path' => $path,
            'original_name' => $file->getClientOriginalName(),
            'mime_type' => $file->getMimeType(),
            'size' => $file->getSize(),
        ]);

        return to_route('uploads.index')->with('status', 'ファイルを保存しました。');
    }
}

コードのポイント

File::types() でファイル種別と MIME を同時に検証する

'attachment' => [
    'required',
    File::types(['jpg', 'jpeg', 'png', 'webp', 'pdf'])
        ->max('5mb'),
],

File::types(...) は拡張子の見た目だけでなく、ファイル内容から MIME type を推定して判定します。->max('5mb') を chain で続けられるため、種別とサイズの制限を 1 か所にまとめられます。

② 保存名は store() に任せ、元ファイル名はメタデータとして残すだけにする

$validated = $request->validated();
$file = $request->file('attachment');
$path = $file->store('uploads', 'public');

Upload::query()->create([
    'title' => $validated['title'],
    'attachment_path' => $path,
    'original_name' => $file->getClientOriginalName(),
    'mime_type' => $file->getMimeType(),
    'size' => $file->getSize(),
]);

store('uploads', 'public') がハッシュ名ベースの保存先パスを返すため、元ファイル名をそのままディスクに使わずに済みます。original_name は表示用メタデータとして残しますが、実際の保存パスとは切り離されています。

ここでは、保存名に元ファイル名を使っていない点だけを押さえます。filesystem docs でも、元ファイル名や元拡張子は信用せず、store()hashName() を使う方向が案内されています。今回は表示用として original_name を残し、実際の保存先は store('uploads', 'public') が返すハッシュ名ベースのパスを使います。

6. multipart/form-data のフォームと一覧画面を作る

次に route と Blade を追加し、1 画面でアップロードと一覧確認を完結させます。

routes/web.php を次の内容に更新します。

<?php

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

Route::redirect('/', '/uploads');

Route::get('/uploads', [UploadController::class, 'index'])->name('uploads.index');
Route::post('/uploads', [UploadController::class, 'store'])->name('uploads.store');

resources/views/uploads/index.blade.php を新規作成し、次の内容を入れてください。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Laravel Upload Demo</title>
    <style>
        body {
            margin: 0;
            font-family: system-ui, sans-serif;
            background: #f6f7fb;
            color: #1f2937;
        }

        .shell {
            max-width: 960px;
            margin: 0 auto;
            padding: 32px 20px 64px;
        }

        .card {
            background: #ffffff;
            border: 1px solid #e5e7eb;
            border-radius: 16px;
            padding: 24px;
            box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
        }

        .card + .card {
            margin-top: 24px;
        }

        .stack {
            display: grid;
            gap: 16px;
        }

        label {
            display: block;
            font-weight: 600;
            margin-bottom: 8px;
        }

        input[type="text"],
        input[type="file"] {
            width: 100%;
            box-sizing: border-box;
            border: 1px solid #cbd5e1;
            border-radius: 10px;
            padding: 12px 14px;
            font: inherit;
            background: #fff;
        }

        button {
            border: 0;
            border-radius: 999px;
            padding: 12px 18px;
            font: inherit;
            font-weight: 700;
            background: #0f766e;
            color: #fff;
            cursor: pointer;
        }

        .status {
            padding: 12px 14px;
            border-radius: 10px;
            background: #ecfeff;
            color: #155e75;
        }

        .errors {
            padding: 12px 14px;
            border-radius: 10px;
            background: #fef2f2;
            color: #991b1b;
        }

        .error-text {
            margin-top: 6px;
            color: #b91c1c;
            font-size: 14px;
        }

        .uploads {
            display: grid;
            gap: 16px;
        }

        .upload-item {
            display: grid;
            grid-template-columns: 160px 1fr;
            gap: 16px;
            align-items: start;
            border: 1px solid #e5e7eb;
            border-radius: 14px;
            padding: 16px;
        }

        .preview {
            aspect-ratio: 4 / 3;
            border-radius: 12px;
            overflow: hidden;
            background: #f1f5f9;
            display: grid;
            place-items: center;
        }

        .preview img {
            width: 100%;
            height: 100%;
            object-fit: cover;
        }

        .pdf-badge {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 72px;
            height: 72px;
            border-radius: 999px;
            background: #fee2e2;
            color: #991b1b;
            font-weight: 800;
        }

        .meta {
            display: grid;
            gap: 8px;
        }

        .meta h2 {
            margin: 0;
            font-size: 20px;
        }

        .meta ul {
            margin: 0;
            padding-left: 18px;
        }

        @media (max-width: 720px) {
            .upload-item {
                grid-template-columns: 1fr;
            }
        }
    </style>
</head>
<body>
    <main class="shell">
        <section class="card stack">
            <div>
                <h1 style="margin: 0 0 8px;">ファイルを追加する</h1>
                <p style="margin: 0; color: #475569;">
                    画像または PDF を 1 件ずつ保存します。保存先は local の public disk です。
                </p>
            </div>

            @if (session('status'))
                <div class="status">{{ session('status') }}</div>
            @endif

            @if ($errors->any())
                <div class="errors">
                    <strong>入力を見直してください。</strong>
                    <ul>
                        @foreach ($errors->all() as $error)
                            <li>{{ $error }}</li>
                        @endforeach
                    </ul>
                </div>
            @endif

            <form action="{{ route('uploads.store') }}" method="post" enctype="multipart/form-data" class="stack">
                @csrf

                <div>
                    <label for="title">タイトル</label>
                    <input id="title" type="text" name="title" value="{{ old('title') }}" required>
                    @error('title')
                        <div class="error-text">{{ $message }}</div>
                    @enderror
                </div>

                <div>
                    <label for="attachment">ファイル</label>
                    <input id="attachment" type="file" name="attachment" accept=".jpg,.jpeg,.png,.webp,.pdf" required>
                    @error('attachment')
                        <div class="error-text">{{ $message }}</div>
                    @enderror
                </div>

                <div>
                    <button type="submit">アップロードする</button>
                </div>
            </form>
        </section>

        <section class="card stack">
            <div>
                <h2 style="margin: 0 0 8px;">保存済みファイル</h2>
                <p style="margin: 0; color: #475569;">
                    画像はその場で見え、PDF は別タブで開けます。
                </p>
            </div>

            <div class="uploads">
                @forelse ($uploads as $upload)
                    <article class="upload-item">
                        <div class="preview">
                            @if ($upload->isImage())
                                <img src="{{ $upload->publicUrl() }}" alt="{{ $upload->title }}">
                            @else
                                <div class="pdf-badge">PDF</div>
                            @endif
                        </div>

                        <div class="meta">
                            <h2>{{ $upload->title }}</h2>
                            <div>元ファイル名: {{ $upload->original_name }}</div>
                            <ul>
                                <li>MIME type: {{ $upload->mime_type }}</li>
                                <li>サイズ: {{ number_format($upload->size / 1024, 1) }} KB</li>
                                <li><a href="{{ $upload->publicUrl() }}" target="_blank" rel="noreferrer">ファイルを開く</a></li>
                            </ul>
                        </div>
                    </article>
                @empty
                    <p style="margin: 0; color: #64748b;">まだアップロードはありません。</p>
                @endforelse
            </div>
        </section>
    </main>
</body>
</html>

ここで必ず確認したいのが、form の enctype="multipart/form-data" です。これがないと、ブラウザはファイル本体を送信しません。

7. 画像 / PDF / validation エラーを順に確認する

ここまでできたら、ブラウザで http://localhost:8000/uploads を開いて次の順で確認します。

アップロードフォームの初期表示

7-1. 画像を 1 件アップロードする

  • タイトルに プロフィール画像 のような文字列を入れる
  • pngjpg を選ぶ
  • 送信後、一覧の上に成功メッセージが出ることを確認する
  • 一覧に画像プレビューが出ることを確認する

画像アップロード後の表示例:

画像アップロード後に一覧へ追加された状態

7-2. PDF を 1 件アップロードする

  • タイトルに 見積書 PDF のような文字列を入れる
  • pdf を選ぶ
  • 一覧でプレビュー枠が PDF バッジになり、ファイルを開く から別タブで開けることを確認する

7-3. 無効なファイルで validation を確認する

  • exezip のような許可していないファイルを選ぶ
  • 5MB を超えるファイルを選ぶ
  • 送信後、元の画面へ戻り、エラーが上部と各 field に表示されることを確認する

StoreUploadRequest を使っているので、validation エラー時は自動で直前の画面へ戻ります。$errorsold('title') はそのまま使えます。

validation エラー時の表示例:

許可していないファイルを送ったときの validation エラー表示

8. よくある詰まりどころ

DB にはレコードが入っているのに画像や PDF を開けないときは、まず php artisan storage:link を確認します。local の public disk は storage/app/public を使うため、public/storage 側のリンクがないとブラウザから届きません。

form に multipart/form-data がない

attachment が必須エラーになるのにファイルを選んだつもりなら、<form ... enctype="multipart/form-data"> を確認します。通常の application/x-www-form-urlencoded ではファイルは送れません。

元ファイル名を保存名に使っている

filesystem docs でも、getClientOriginalName()getClientOriginalExtension() をそのまま保存名に使う方向は勧められていません。ユーザー入力の見た目に依存するためです。この記事では表示用メタデータとしてだけ残し、保存先は store('uploads', 'public') に任せています。

APP_URL が現在のポートと合っていない

アップロード自体は成功するのに、画像や PDF のリンクだけ 404 になる場合は .envAPP_URL を確認します。Storage::url()public disk の URL 設定として既定で APP_URL/storage を使うため、APP_URL=http://localhost のままだと http://localhost:8000/uploads で作業していても、生成されるファイル URL は http://localhost/storage/... になってしまいます。

APP_URL=http://localhost:8000 に直し、ポートを変えたならその値にもそろえます。変更後も同じ URL が出る場合は docker compose exec app php artisan config:clear を実行してから再確認してください。

9. まとめ

Laravel 13 の file upload の最小構成では、考えることを 4 つに分けると追いやすくなります。

  • ブラウザ側は multipart/form-data で送る
  • validation は FormRequest に寄せる
  • ファイル本体は public disk へ保存する
  • 一覧画面は DB のメタデータと Storage::url() で組み立てる

ここまでできれば、次は認証を足してユーザーごとのアップロードへ広げたり、リレーションを足して Book と添付ファイルを結び付けたりしやすくなります。

補助導線として読むなら、次の記事もつながります。

シリーズ 8/16

このシリーズ

Laravelの基本を最初から通す

  1. 1. Laravel入門(Route / Controller / View / Model 最小構成)
  2. 2. Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除)
  3. 3. Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions)
  4. 4. Laravelで認証を足す(Breeze 最小導入)
  5. 5. Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする)
  6. 6. LaravelでQueueを始める(database queue + worker 最小構成)
  7. 7. Laravelでスケジューラを動かす(Command + Scheduler 最小構成)
  8. 8. Laravelでファイルアップロードを扱う(Storage + validation) 現在の記事
  9. 9. Laravelでリレーションを扱う(User / Book / Category の基本)
  10. 10. Laravelで検索・並び替え・ページネーション付き一覧を作る
  11. 11. LaravelでLivewireを始める
  12. 12. LaravelでLivewire一覧画面を作る(検索・並び替え・ページネーション)
  13. 13. LaravelでSanctum認証APIを作る
  14. 14. LaravelでBlade / Livewire / Inertia をどう使い分けるか
  15. 15. LaravelでInertia + Vue.js を始める
  16. 16. LaravelでInertia + React を始める