対象バージョン: Laravel 13.x / PHP 8.3+
Laravel では、フォームから送られたファイルを UploadedFile として受け取り、store() でストレージ(ディスク)に保存できます。この記事は、画像アップロード機能を初めて作る方向けです。フォームの書き方、バリデーション、public ディスクへの保存と公開 URL、非公開ファイルの扱い、削除、テストまでを順に説明します。
この記事で分かること
localディスクとpublicディスクの違い- ファイルを送信するフォームに必要な
enctypeと@csrf - ファイルのバリデーション(
maxはキロバイト単位)とstore()による保存 php artisan storage:linkで公開 URL から表示できるようにする方法と、APP_URLの関係- 非公開ファイルのダウンロード、ファイルの削除
Storage::fake()を使った Feature テスト
前提
Laravel 13.x は PHP 8.3 以上が必要です。動作確認は laravel/framework v13.37.0 で行いました。プロジェクトの作成やルーティングの基本は【PHP】Laravel入門:インストールから簡単なサンプルアプリケーションの作成を参照してください。
ディスクの基本: local と public
Laravel のファイル保存先は「ディスク」という単位で config/filesystems.php に定義されています。新規作成した 13.x アプリでは、次のようになっています(S3 用の設定は省略)。
'default' => env('FILESYSTEM_DISK', 'local'),
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
],
'links' => [
public_path('storage') => storage_path('app/public'),
],
ポイントは次の2つです。
- 既定のディスクは
localで、保存先はstorage/app/privateです。ディスク名を指定せずに保存したファイルはここに入り、ブラウザから直接は開けません。 - ブラウザに表示したい画像などは
publicディスク(storage/app/public)に保存します。
この記事のアバター画像の例では、すべて public ディスクを使います。
アップロード用のフォームを作る
コントローラーを作成します。
php artisan make:controller AvatarController
routes/web.php を次の内容にします(/ のルートは新規作成時からあるものです)。
<?php
use App\Http\Controllers\AvatarController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/avatars', [AvatarController::class, 'create']);
Route::post('/avatars', [AvatarController::class, 'store']);
ビュー resources/views/avatars/create.blade.php を作ります。
<!DOCTYPE html>
<html lang="ja">
<head><meta charset="utf-8"><title>Avatar upload</title></head>
<body>
@if (session('url'))
<p>保存先: {{ session('path') }}</p>
<img src="{{ session('url') }}" alt="アップロードした画像" width="120">
@endif
<form action="/avatars" method="POST" enctype="multipart/form-data">
@csrf
<input type="file" name="avatar">
@error('avatar')
<p class="error">{{ $message }}</p>
@enderror
<button type="submit">Upload</button>
</form>
</body>
</html>
ファイルを送るフォームには、次の3点が必要です。
method="POST"enctype="multipart/form-data"。これが無いとファイル本体は送られず、ファイル名の文字列だけが届きます。その場合、fileルールで「The avatar field must be a file.」というエラーになります。@csrf。CSRF トークンの隠しフィールドを出力します。トークンが無い POST は 419 エラーになります。
レイアウトを共通化したい場合は【PHP】LaravelでBladeコンポーネントとレイアウトを使う方法を参照してください。
コントローラーで検証して保存する
app/Http/Controllers/AvatarController.php を次のように書きます。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class AvatarController extends Controller
{
public function create(): View
{
return view('avatars.create');
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'avatar' => ['required', 'file', 'image', 'mimes:jpg,png', 'max:1024'],
]);
$path = $request->file('avatar')->store('avatars', 'public');
return redirect('/avatars')
->with('path', $path)
->with('url', Storage::disk('public')->url($path));
}
}
$request->file('avatar') は Illuminate\Http\UploadedFile のインスタンスを返します。store('avatars', 'public') の第2引数がディスク名です。ファイルは storage/app/public/avatars に保存され、戻り値としてディスクのルートからの相対パスが返ります。
avatars/v76I0fOIrtJF9KTR6mtGxanYH7RgAUfzNXiG2Dcc.png
ファイル名は 40 文字のランダムな文字列で、拡張子はファイルの MIME タイプから決まります。第2引数を省略すると既定の local ディスク(storage/app/private)に保存されるため、公開したいファイルでは public を必ず指定してください。
バリデーションを使わずにアップロードの有無を確かめたい場合は、$request->hasFile('avatar') でファイルの有無を、$request->file('avatar')->isValid() でアップロードが成功したかを確認できます。
ファイルのバリデーション
上の例のルールは、それぞれ次の意味です。
| ルール | 内容 |
|---|---|
file |
正常にアップロードされたファイルであること |
image |
画像であること |
mimes:jpg,png |
指定した種類のファイルであること |
max:1024 |
1024 KB(約 1MB)以下であること |
ファイルに対する max は キロバイト単位 です。max:1024 で 1MB 程度、max:2048 で 2MB 程度になります。1,439 KB の画像を送ると「The avatar field must not be greater than 1024 kilobytes.」と表示されます。
テキストファイルの拡張子を .jpg に変えて送った場合も、「The avatar field must be an image.」で拒否されます。SVG ファイルも、image ルールでは既定で拒否されます。検証に失敗するとフォームへリダイレクトされ、ファイルは保存されません。
同じ内容は File ルールオブジェクトでも書けます。
use Illuminate\Validation\Rules\File;
$request->validate([
'avatar' => ['required', File::image()->max(1024)],
]);
File::image()->max(1024) でも、サイズ超過や画像以外のファイルに対して同じエラーメッセージになります。
ここで表示しているメッセージは英語の既定文です。エラーメッセージの日本語化や @error の使い方は【PHP】Laravelでフォームバリデーションを実装する方法を参照してください。
アップロードできるサイズは、Laravel のルールとは別に PHP の upload_max_filesize と post_max_size でも制限されます。値は環境ごとの php.ini によって異なります。upload_max_filesize を超えるファイルは正常なアップロードとして扱われず、max のメッセージではなく「The avatar failed to upload.」と表示されます。
保存するファイル名を決める
store() は内部でランダムなファイル名を付けます。同じ形式の名前は hashName() で取得できます。
$file = $request->file('avatar');
$file->hashName(); // 例: v76I0fOIrtJF9KTR6mtGxanYH7RgAUfzNXiG2Dcc.png
$file->hashName('avatars'); // 例: avatars/v76I0fOIrtJF9KTR6mtGxanYH7RgAUfzNXiG2Dcc.png
$file->extension(); // ファイルの中身から推測した拡張子
getClientOriginalName() と getClientOriginalExtension() は、ブラウザから送られてきた名前と拡張子をそのまま返します。利用者が自由に変えられる値なので、公式ドキュメントでも安全ではないとされています。保存用のファイル名には hashName() と extension() を使ってください。たとえばテキストファイルを fake.jpg という名前で送ると、getClientOriginalExtension() は jpg を返しますが、extension() は txt を返します。
自分で名前を決めたい場合は storeAs() を使います。第3引数がディスク名です。
$file = $request->file('avatar');
$path = $file->storeAs('avatars', 'user-1.'.$file->extension(), 'public');
// avatars/user-1.jpg
url() が返す URL はエンコードされないため、ファイル名には英数字やハイフンなど URL でそのまま使える文字を選んでください。
storage:link で公開 URL から表示する
public ディスクに保存しただけでは、ブラウザから画像を開けません。public/storage から storage/app/public へのシンボリックリンクを作成します。
php artisan storage:link
INFO The [public/storage] link has been connected to [storage/app/public].
作成されるリンクは絶対パスを指します。すでにリンクがある状態でもう一度実行すると「ERROR The [public/storage] link already exists.」と表示されます。
リンク作成前は 403(production では 404)になる
APP_ENV が production 以外の環境(新規作成したアプリの .env は APP_ENV=local)では、storage:link を実行する前に /storage/avatars/... を開くと、404 ではなく 403 が返ります。local ディスクの 'serve' => true によって、署名付き URL だけを受け付けるルート storage/{path}(storage.local)が登録されているためです。APP_ENV=production の場合、同じ状況では 404 が返ります。
アップロードは成功しているのに画像が 403(APP_ENV=production では 404)で表示されない場合は、まず storage:link を実行したか確認してください。リンク作成後は、同じ URL で画像が 200 で返ります。ファイルを削除した後や、local ディスクに保存した非公開ファイルの URL を開いた場合も、同じく 403(APP_ENV=production では 404)になります。
url() は APP_URL を使う
Storage::disk('public')->url($path) は、public ディスクの url 設定(APP_URL + /storage)をもとに URL を作ります。新規作成したアプリの .env は APP_URL=http://localhost:8000 です。
Storage::disk('public')->url('avatars/user-1.jpg');
// http://localhost:8000/storage/avatars/user-1.jpg
一方、asset('storage/'.$path) は HTTP リクエスト中であれば、アクセスしているホストをもとに URL を作ります。ブラウザで http://127.0.0.1:8000 を開いている場合、url() は http://localhost:8000/...、asset() は http://127.0.0.1:8000/... を返すため、両者の URL が食い違います。.env の APP_URL は、実際にブラウザで開く URL に合わせておいてください。
非公開ファイルを保存してダウンロードさせる
公開したくないファイルは、ディスクを指定せずに既定の local ディスクへ保存します。
$path = $request->file('document')->store('documents');
// storage/app/private/documents/<ランダムな名前>.txt に保存される
このファイルは /storage/... の URL では開けません(403。APP_ENV=production では 404)。ブラウザに渡すときは download() を使います。
use Illuminate\Support\Facades\Storage;
// $path は、閲覧してよいことを確認したレコードに保存しておいたパス
return Storage::disk('local')->download($path, 'document.txt');
レスポンスには Content-Disposition: attachment; filename=document.txt が付き、ダウンロードとして扱われます。指定したパスのファイルが存在しない場合、download() は例外を投げ、そのままでは 500 エラーになります。
ダウンロードするパスは、権限を確認したレコードなどアプリ側で管理している値から取り出してください。クエリ文字列やフォームから受け取ったパスをそのまま download() に渡すと、本来見せるべきでないファイルまで取得できてしまいます。
store() が返すパスは、データベースのカラムに保存しておくと表示・ダウンロード・削除に使えます。データベースの扱い方は【PHP】Laravelでデータベースと連携する方法を参照してください。
ファイルの存在確認と削除
Storage ファサードでは、ディスクを指定してファイルを操作できます。
use Illuminate\Support\Facades\Storage;
$disk = Storage::disk('public');
$disk->put('notes/hello.txt', 'Hello'); // 成功すると true
$disk->get('notes/hello.txt'); // "Hello"
$disk->exists('notes/hello.txt'); // true
$disk->missing('notes/hello.txt'); // false
$disk->path('notes/hello.txt'); // storage/app/public/notes/hello.txt の絶対パス
削除には delete() を使います。
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
delete() は存在しないファイルを指定しても true を返します。削除後は、そのファイルの公開 URL も表示されなくなります。ここでの $path も、保存時に記録しておいたパスを使ってください。
アップロードをテストする
Storage::fake() を使うと、実際のディスクに書き込まずにアップロード処理をテストできます。新規作成した 13.x アプリのテストは PHPUnit です。tests/Feature/AvatarUploadTest.php を作成します。
<?php
namespace Tests\Feature;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class AvatarUploadTest extends TestCase
{
public function test_avatar_can_be_uploaded(): void
{
Storage::fake('public');
$file = UploadedFile::fake()->image('avatar.jpg');
$response = $this->post('/avatars', [
'avatar' => $file,
]);
$response->assertRedirect('/avatars');
$response->assertSessionHasNoErrors();
Storage::disk('public')->assertExists('avatars/'.$file->hashName());
}
public function test_oversized_file_is_rejected(): void
{
Storage::fake('public');
$file = UploadedFile::fake()->image('big.jpg')->size(2048); // KB
$this->from('/avatars')->post('/avatars', ['avatar' => $file])
->assertRedirect('/avatars')
->assertSessionHasErrors('avatar');
Storage::disk('public')->assertMissing('avatars/'.$file->hashName());
}
public function test_non_image_is_rejected(): void
{
Storage::fake('public');
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$this->from('/avatars')->post('/avatars', ['avatar' => $file])
->assertSessionHasErrors('avatar');
Storage::disk('public')->assertMissing('avatars/'.$file->hashName());
}
}
php artisan test
store() は hashName() と同じ名前で保存するため、'avatars/'.$file->hashName() で保存先を確認できます。size() に渡す値もキロバイト単位です。
UploadedFile::fake()->image() には PHP の GD 拡張が必要です。GD が無い環境では「GD extension is not installed.」というエラーでテストが失敗します。UploadedFile::fake()->create() は GD なしでも使えます。テストの基本は【PHP】Laravelでテストを実装する方法を参照してください。
よくあるつまずき
| 症状 | 原因と対処 |
|---|---|
画像の URL が 403(APP_ENV=production では 404)になる |
php artisan storage:link を実行していない。または local ディスクに保存している |
| 画像の URL のホストが開いているページと違う | url() は APP_URL を使う。.env の APP_URL を実際の URL に合わせる |
| 「must be a file」になる | フォームに enctype="multipart/form-data" が無い |
| 419 エラーになる | フォームに @csrf が無い |
| 想定より小さいファイルで弾かれる | max はキロバイト単位。max:2 は 2KB |
| 「failed to upload」になる | PHP の upload_max_filesize を超えている |
| テストで「GD extension is not installed.」 | UploadedFile::fake()->image() は GD 拡張が必要 |
S3 などのクラウドストレージや、アップロード画像のリサイズ・変換はこの記事では扱いません。
まとめ
- 既定の
localディスクは非公開(storage/app/private)。ブラウザに表示するファイルはstore('avatars', 'public')のようにpublicディスクへ保存する - フォームには
method="POST"、enctype="multipart/form-data"、@csrfが必要 - ファイルの
maxはキロバイト単位。PHP 側のupload_max_filesize/post_max_sizeも上限になる - 保存名には
hashName()/extension()を使い、クライアントが送った名前は使わない php artisan storage:linkを実行するまで公開 URL は表示されない(APP_ENVがproduction以外では 403、productionでは 404)。url()はAPP_URLをもとに URL を作る- 非公開ファイルは
download()で返し、パスはアプリ側で管理している値から取り出す - テストは
Storage::fake()とUploadedFile::fake()で書ける。image()は GD 拡張が必要
関連記事
- 【PHP】Laravel入門:インストールから簡単なサンプルアプリケーションの作成
- 【PHP】Laravelでフォームバリデーションを実装する方法
- 【PHP】Laravelでテストを実装する方法
- 【PHP】Laravelでデータベースと連携する方法
- 【PHP】LaravelでBladeコンポーネントとレイアウトを使う方法
- 【PHP】Laravelでキューとジョブを実装する方法とその利用シーン