【PHP】Laravelでファイルアップロードとストレージを扱う方法

対象バージョン: 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 拡張が必要

関連記事

参考リンク

コメントする