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でファイル種別とサイズを検証できるpublicdisk へ保存し、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
.env の APP_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 をやり直してください。
起動確認の例:
3. public disk と storage:link の役割を先に見る
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 つです。
publicdisk の実体は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 件アップロードする
- タイトルに
プロフィール画像のような文字列を入れる pngかjpgを選ぶ- 送信後、一覧の上に成功メッセージが出ることを確認する
- 一覧に画像プレビューが出ることを確認する
画像アップロード後の表示例:
7-2. PDF を 1 件アップロードする
- タイトルに
見積書 PDFのような文字列を入れる pdfを選ぶ- 一覧でプレビュー枠が
PDFバッジになり、ファイルを開くから別タブで開けることを確認する
7-3. 無効なファイルで validation を確認する
exeやzipのような許可していないファイルを選ぶ- 5MB を超えるファイルを選ぶ
- 送信後、元の画面へ戻り、エラーが上部と各 field に表示されることを確認する
StoreUploadRequest を使っているので、validation エラー時は自動で直前の画面へ戻ります。$errors と old('title') はそのまま使えます。
validation エラー時の表示例:
8. よくある詰まりどころ
storage:link を実行していない
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 になる場合は .env の APP_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に寄せる - ファイル本体は
publicdisk へ保存する - 一覧画面は DB のメタデータと
Storage::url()で組み立てる
ここまでできれば、次は認証を足してユーザーごとのアップロードへ広げたり、リレーションを足して Book と添付ファイルを結び付けたりしやすくなります。
補助導線として読むなら、次の記事もつながります。