Laravelを使った画像アップロードの実装方法について考える

## はじめに

ウェブアプリケーションにおいて、画像アップロード機能は不可欠な要素となっています。
ユーザーが自由にコンテンツを投稿したり、プロフィール画像を設定したりする機能は、多くのサービスで標準的に提供されています。
しかし、この一見シンプルな機能の裏には、セキュリティ、パフォーマンス、ユーザビリティなど、多くの考慮すべき要素が隠れています。
そこで、Laravelを使用して、画像アップロード機能を実装する際のポイントについて考えてみたいと思います。
この記事を通じて、画像アップロード機能を実装する際の参考になれば幸いです。

※ Laravel11を使用しています。
※ Laravelの基本的な知識があることを前提としています。


## 基本的な画像アップロードの実装

### HTTPにおける画像アップロードの仕組み

画像アップロードは、HTTPのPOSTメソッドを使用して行われます。
クライアントはマルチパートフォームデータ（multipart/form-data）形式でリクエストを送信し、サーバーサイドでそのデータを受け取って処理します。

この過程は以下のように進行します：

1. クライアントがフォームにファイルを選択
2. フォームが送信され、ブラウザがmultipart/form-dataとしてエンコード
3. サーバーがリクエストを受信し、ファイルデータを解析
4. サーバーがファイルを一時的な場所に保存
5. アプリケーションがファイルを処理（バリデーション、リサイズなど）
6. ファイルが最終的な保存先に移動または保存

Laravelでは、`Request`オブジェクトを通じてアップロードされたファイルにアクセスできます。
このオブジェクトは、ファイルの取り扱いを簡素化する多くのメソッドを提供しています。

### Laravelでの基本的な実装

Laravelでの基本的な画像アップロードの実装例を示します。

```php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ImageController extends Controller
{
    public function store(Request $request)
    {
        if ($request->hasFile('image')) {

            $file = $request->file('image');
            $path = Storage::disk('public')->putFile('images', $file);

            return response()->json(['message' => 'Image uploaded successfully', 'url' => Storage::disk('public')->url($path)]);
        }

        return response()->json(['error' => 'No image file uploaded'], 400);
    }
}
```

このコードでは、`hasFile`メソッドを使用してファイルの存在を確認し、Storageファサードを使用してファイルを保存しています。
`putFile`メソッドは、ランダムなファイル名を生成してファイルを保存します。

対応するルート定義は以下のようになります：

```php:routes/web.php
use App\Http\Controllers\ImageController;

Route::post('/upload', [ImageController::class, 'store']);
```

フロントエンドのHTMLフォームは以下のようになります：

```html
<form action="/upload" method="POST" enctype="multipart/form-data">
    @csrf
    <input type="file" name="image">
    <button type="submit">アップロード</button>
</form>
```

### ファイルバリデーション

Laravelで、画像ファイルのバリデーションを簡易的に行う方法を示します。

```php
public function store(Request $request)
{
    $request->validate([
        'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
    ]);

    $file = $request->file('image');
    $path = Storage::disk('public')->putFile('images', $file);
    return response()->json(['message' => 'Image uploaded successfully', 'url' => Storage::disk('public')->url($path)]);
}
```

このバリデーションルール：

- `required`: ファイルが必須であること
- `image`: アップロードされたファイルが画像であること
- `mimes:jpeg,png,jpg,gif`: 許可される画像形式を指定
- `max:2048`: ファイルサイズの上限を2MBに設定（単位はキロバイト）

バリデーションに失敗した場合、Laravelは自動的にエラーレスポンスを返します。


## 画像の保存と管理

### 保存ファイル名

ファイル名の衝突を避け、セキュリティを向上させるため、アップロードされた画像には一意のファイル名を付けることが重要です。
`putFileAs`メソッドを使用すると、ファイル名を指定してファイルを保存できます。

```php
$path = Storage::disk('public')->putFileAs('images', $file, $filename);
```

ファイル名を生成する方法はいくつかありますが、以下の方法が一般的です：

###### 1. タイムスタンプを使用する

```php
$filename = time() . '.' . $file->getClientOriginalExtension();
```

###### 2. ランダムな文字列を使用する

```php
$filename = Str::random(20) . '.' . $file->getClientOriginalExtension();
```

###### 3. UUIDを使用する

```php
$filename = Str::uuid(). '.' . $file->getClientOriginalExtension();
```

###### 4. ULIDを使用する

```php
$filename = Str::ulid() . '.' . $file->getClientOriginalExtension();
```


### 画像情報のデータベースへの保存

アップロードされた画像の情報をデータベースに保存することで、効率的な管理と検索が可能になります。

まず、画像テーブルを作成します：

```php
public function up()
{
    Schema::create('images', function (Blueprint $table) {
        $table->id();
        $table->string('filename');
        $table->string('original_filename');
        $table->string('mime_type');
        $table->integer('size');
        $table->string('path');
        $table->timestamps();
    });
}
```

次に、Imageモデルを作成します：

```php
class Image extends Model
{
    protected $fillable = ['filename', 'original_filename', 'mime_type', 'size', 'path'];
}
```

そして、コントローラーで画像情報をデータベースに保存します：

```php
use App\Models\Image;

public function store(Request $request)
{
    $request->validate([
        'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
    ]);

    $file = $request->file('image');
    $path = Storage::disk('public')->putFile('images', $file);

    $image = new Image([
        'filename' => basename($path),
        'original_filename' => $file->getClientOriginalName(),
        'mime_type' => $file->getMimeType(),
        'size' => $file->getSize(),
        'path' => $path,
    ]);

    $image->save();

    return response()->json([
        'message' => 'Image uploaded and processed successfully',
        'image_id' => $image->id,
        'url' => Storage::url($path),
    ]);
}
```

この方法により、以下のメリットがあります：

1. 画像のメタデータ（元のファイル名、MIMEタイプ、サイズなど）を保存できる
2. データベースを使用した高速な検索と並べ替えが可能
3. 他のエンティティとの関連付けが容易
4. アクセス制御やバージョン管理の実装が簡単

### 画像の削除

不要になった画像を削除する機能も重要です。
以下は、データベースからの削除とストレージからのファイル削除を組み合わせた例です：

```php
public function destroy($id)
{
    $image = Image::findOrFail($id);

    // ストレージからファイルを削除
    Storage::disk('public')->delete($image->path);

    // データベースから画像情報を削除
    $image->delete();

    return response()->json(['message' => 'Image deleted successfully']);
}
```

対応するルート定義：

```php:routes/web.php
Route::delete('/images/{id}', [ImageController::class, 'destroy'])->name('images.destroy');
```


## 高度な画像処理

### 画像のリサイズと圧縮

画像のリサイズと圧縮は、ストレージスペースの節約とページロード時間の改善に役立ちます。
LaravelでSDK機能を実装するには、一般的に Intervention Image ライブラリが使用されます。

まず、Composerを使用してライブラリをインストールします：

```bash
composer require intervention/image-laravel
```

`config/image.php`ファイルを生成して、設定をカスタマイズできます：

```bash
php artisan vendor:publish --provider="Intervention\Image\Laravel\ServiceProvider"
```

次に、画像のリサイズと圧縮を行うコードを実装します：

```php
use Illuminate\Support\Str;
use Intervention\Image\Laravel\Facades\Image as InterventionImage;

public function store(Request $request)
{
    $request->validate([
        'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
    ]);

    $file = $request->file('image');

    $ulid = Str::ulid();
    $filename = $ulid . '.' . $file->getClientOriginalExtension();

    $path = 'images/' . $filename;

    $imageFile = InterventionImage::read($file);
    // リサイズ ※元のサイズを超えないようにアスペクト比を維持
    $imageFile->scaleDown(640, 480);
    // エンコード（品質は0-100で指定）
    $imageFileEncoded = $imageFile->encode(new \Intervention\Image\Encoders\AutoEncoder(quality: 80));

    Storage::disk('public')->put($path, $imageFileEncoded);

    // データベースに保存
    $image = new Image([
        'filename' => $filename,
        'original_filename' => $file->getClientOriginalName(),
        'mime_type' => $imageFileEncoded->mimetype(),
        'size' => $imageFileEncoded->size(),
        'path' => $path,
    ]);

    $image->save();

    return response()->json([
        'message' => 'Image uploaded and processed successfully',
        'image_id' => $image->id,
        'url' => Storage::url($path),
    ]);
}
```

このコードでは、アップロードされた画像を640x480にリサイズし（アスペクト比を維持）、80%の品質で保存しています。

### 画像のサムネイル生成

画像のプレビューやギャラリー表示など、サムネイルの生成は非常に便利です。

```php
public function store(Request $request)
{
    // ...

    // サムネイル生成を追加
    $thumbnailPath = 'thumbnails/' . $filename;
    $imageFile->coverDown(300, 300); // トリミング 元のサイズを超えないように
    Storage::disk('public')->put($thumbnailPath, $imageFile->encode());

    // ...
    
    // データベースにサムネイル情報を保存
    $image->thumbnail_path = $thumbnailPath;
    $image->save();

    // ...
}
```

サムネイルは300x300ピクセルのサイズで作成され、指定されたサイズに合わせてトリミングされます。


## ユーザーインターフェイスの改善

### 画像アップロードのプレビュー

アップロード前に画像のプレビューを表示すると、アップロードする画像を確認できるため、ユーザーエクスペリエンスが向上します。
以下は、画像アップロードフォームにプレビュー機能を追加する方法です。

```html
<input type="file" name="image" id="imageInput">
<img id="preview" src="#" style="display:none; max-width:300px;">
```

```javascript
document.addEventListener('DOMContentLoaded', function () {
    const input = document.getElementById('imageInput');
    const preview = document.getElementById('preview');

    input.addEventListener('change', function(e) {
        const file = e.target.files[0];
        if (file) {
            const reader = new FileReader();
            reader.onload = function(e) {
                preview.src = e.target.result;
                preview.style.display = 'block';
            }
            reader.readAsDataURL(file);
        }
    });
});
```

ファイルが選択されると即座にプレビューが表示されます。
`FileReader` APIを使用して、選択されたファイルをデータURLとして読み込み、それを `img` 要素のソースとして設定しています。

### ドラッグアンドドロップによるアップロード

ドラッグアンドドロップ機能を実装することで、ユーザーエクスペリエンスをさらに向上させることができます。
JavaScriptを使用して、ファイルをドラッグアンドドロップするとファイルがアップロードされるようにします。

```html
<meta name="csrf-token" content="{{ csrf_token() }}">
```

```html
<div id="drop-area">
    <p>アップロードするファイルをドラッグするか、クリックして選択してください</p>
    <input type="file" name="image" onchange="handleFiles(this.files)">
    <div id="gallery"></div>
</div>
```

```css
#drop-area {
    border: 2px dashed #ccc;
    border-radius: 5px;
    padding: 20px;
}

#drop-area.highlight {
    background-color: #f0f0f0;
}

#gallery {
    display: flex;
    flex-wrap: wrap;
    margin-top: 20px;
}

#gallery img {
    margin: 10px;
    width: 200px;
    height: 200px;
    object-fit: cover;
}
```

```javascript
let dropArea = document.getElementById('drop-area');

['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
    dropArea.addEventListener(eventName, preventDefaults, false);
});

function preventDefaults(e) {
    e.preventDefault();
    e.stopPropagation();
}

['dragenter', 'dragover'].forEach(eventName => {
    dropArea.addEventListener(eventName, highlight, false);
});

['dragleave', 'drop'].forEach(eventName => {
    dropArea.addEventListener(eventName, unhighlight, false);
});

function highlight(e) {
    dropArea.classList.add('highlight');
}

function unhighlight(e) {
    dropArea.classList.remove('highlight');
}

dropArea.addEventListener('drop', handleDrop, false);

function handleDrop(e) {
    let dt = e.dataTransfer;
    let files = dt.files;

    handleFiles(files);
}

function handleFiles(files) {
    ([...files]).forEach(uploadFile);
}

async function uploadFile(file) {
    const url = '/upload';
    const formData = new FormData();
    formData.append('image', file);

    const response = await fetch(url, {
        headers: {
            'X-Requested-With': 'XMLHttpRequest',
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
        },
        method: 'POST',
        body: formData
    });

    const data = await response.json();

    if (response.ok) {
        previewUrl(data.url);
    } else {
        console.error(data.message);
    }
}

function previewUrl(url) {
    let img = document.createElement('img');
    img.src = url;
    document.getElementById('gallery').appendChild(img);
}
```


### 画像アップロードの進捗表示

とくに大きなファイルをアップロードする場合、進捗状況を表示することでユーザーの不安を軽減し、より良いユーザーエクスペリエンスを提供できます。

```html
<div id="progress-bar" style="display: none; margin:5px 0">
    <div id="progress" style="width: 0%; background-color: #4CAF50; height: 10px;"></div>
</div>
```

```javascript
function uploadFile(file) {
    let url = '/upload';
    let formData = new FormData();
    formData.append('image', file);

    let xhr = new XMLHttpRequest();
    xhr.open('POST', url, true);
    xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
    xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name="csrf-token"]').content);

    xhr.upload.addEventListener("progress", function(e) {
        let percent = (e.loaded / e.total) * 100;
        updateProgress(percent);
    });

    xhr.onreadystatechange = function() {
        if (xhr.readyState == 4 && xhr.status == 200) {
            hideProgress();
            previewUrl(JSON.parse(xhr.responseText).url);
        }
    };

    showProgress();
    xhr.send(formData);
}

function showProgress() {
    document.getElementById('progress-bar').style.display = 'block';
}

function hideProgress() {
    document.getElementById('progress-bar').style.display = 'none';
}

function updateProgress(percent) {
    document.getElementById('progress').style.width = percent + '%';
}
```

この実装では、`XMLHttpRequest`オブジェクトを使用してファイルをアップロードし、`progress`イベントを利用して進捗状況を更新しています。
プログレスバーはCSSを使用して視覚的に表示されます。


## セキュリティとパフォーマンスの最適化

### セキュリティ対策

画像アップロードにおけるセキュリティは非常に重要です。以下に主要な対策を示します：

##### 1. ファイルタイプの検証

```php
$request->validate([
    'image' => 'required|image|mimes:jpeg,png,jpg,gif',
]);
```

##### 2. ファイルサイズの制限

```php
$request->validate([
    'image' => 'max:2048', // 2MB
]);
```

##### 3. ファイル名のサニタイズ

```php
$safeName = Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)) . '.' . $file->getClientOriginalExtension();
```

##### 4. アップロードディレクトリの権限設定

アップロードディレクトリのパーミッションを適切に設定し、PHPファイルの実行を禁止します。

```bash
chmod 755 storage/app/public/images
```

##### 5. 画像の内容検証

```php
if (exif_imagetype($file->path()) === false) {
    throw new \Exception('Invalid image file');
}
```

##### 6. CDNやS3の利用

画像を別のドメインやS3で提供することで、XSSなどの攻撃リスクを軽減します。

##### 7. 画像処理ライブラリの最新版使用

Intervention Imageなどのライブラリは常に最新版を使用し、既知の脆弱性に対処します。
    
##### 8. ユーザー認証と認可
    ```php
    public function store(Request $request)
    {
        $this->authorize('create', Image::class);
        // アップロード処理
    }
    ```


### 大容量ファイルのアップロード対応

大容量のファイルをアップロードする場合、以下の点を考慮する必要があります：

##### 1. PHPの設定

`php.ini`ファイルで以下の設定を調整します：

```ini
upload_max_filesize = 20M
post_max_size = 20M
max_execution_time = 300
```

##### 2. Webサーバーの設定

NginxやApacheなどのWebサーバーの設定も調整する必要があります。

###### 2.1. Nginxの場合：
```
client_max_body_size 20M;
```

###### 2.2. Apacheの場合：
```
LimitRequestBody 20971520
```

##### 3. チャンクアップロード

大きなファイルを小さな部分（チャンク）に分割し、順次アップロードします。

```javascript
function handleFiles(files) {
    for (let file of files) {
        uploadInChunks(file, 1024 * 1024);
    }
}

async function uploadInChunks(file, chunkSize) {
    const chunks = Math.ceil(file.size / chunkSize);
    for (let i = 0; i < chunks; i++) {
        const start = i * chunkSize;
        const end = Math.min(file.size, start + chunkSize);
        const chunk = file.slice(start, end);

        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('chunkNumber', i);
        formData.append('totalChunks', chunks);
        formData.append('filename', file.name);

        const response = await fetch('/upload-chunk', {
            headers: {
                'X-Requested-With': 'XMLHttpRequest',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
            },
            method: 'POST',
            body: formData
        });

        const data = await response.json();

        if (response.ok) {
            console.info(data.message);

            if (data.url) {
                previewUrl(data.url);
            }
        } else {
            console.error(data.message);
        }
    }

    console.log('All chunks uploaded');
}
```

```php
public function uploadChunk(Request $request)
{
    $chunk = $request->file('chunk');
    $chunkNumber = $request->input('chunkNumber');
    $totalChunks = $request->input('totalChunks');
    $originalFilename = $request->input('filename');

    $storage = Storage::disk('local');

    $tempFilename = md5($originalFilename . session()->getId()); // ユニークな一時ファイル名を生成
    $tempPath = 'temp/' . $tempFilename;

    $storage->put($tempPath . '_' . $chunkNumber, file_get_contents($chunk));

    if ($chunkNumber == $totalChunks - 1) {
        try {
            $tempPath = $this->mergeChunks($tempPath, $totalChunks);

            $filename = Str::ulid() . '.' . pathinfo($originalFilename, PATHINFO_EXTENSION);
            $path = Storage::disk('public')->putFileAs('images', $storage->path($tempPath), $filename);
            $storage->delete($tempPath);

            return response()->json(['message' => 'File upload completed', 'url' => $storage->url($path)]);
        } catch (\Exception $e) {
            return response()->json(['error' => $e->getMessage()], 500);
        }
    }

    return response()->json(['message' => 'Chunk received']);
}

private function mergeChunks($path, $total)
{
    $storage = Storage::disk('local');

    $fp = fopen($storage->path($path), 'wb');

    for ($i = 0; $i < $total; $i++) {
        $chunkPath = $storage->path($path) . '_' . $i;
        $chunkContents = file_get_contents($chunkPath);
        fwrite($fp, $chunkContents);
        unlink($chunkPath);
    }

    fclose($fp);

    return $path;
}
```

対応するルート定義：

```php:routes/web.php   
Route::post('/upload-chunk', [ImageController::class, 'uploadChunk']);
```

### 画像の最適化とWebP対応

画像の最適化は、ウェブサイトのパフォーマンスを向上させる重要な要素です。
WebPは、高品質を維持しながらファイルサイズを大幅に削減できる画像フォーマットです。

WebP変換の実装：

```php
public function store(Request $request)
{
    // ...

    // WebP形式に変換して保存
    $webpPath = 'webps/' . $ulid . '.webp';
    $imageFile->scaleDown(640, 480);
    Storage::disk('public')->put($webpPath, $imageFile->toWebp(80));

    // ...
}
```


## クラウドストレージ

### 画像アップロードのS3への保存

Amazon S3などのクラウドストレージサービスを利用することで、スケーラビリティの向上とインフラ管理の簡素化が可能になります。
LaravelではFilesystemを使用して、ローカルストレージとクラウドストレージを統一的に扱うことができます。

##### 1. AWS SDKのインストール

```bash
composer require league/flysystem-aws-s3-v3 --with-all-dependencies
```

##### 2. 環境設定

`.env`ファイルにS3の設定を追加します：
```
AWS_ACCESS_KEY_ID=your_key
AWS_SECRET_ACCESS_KEY=your_secret
AWS_DEFAULT_REGION=your_region
AWS_BUCKET=your_bucket
```

##### 3. S3へのアップロード実装

```php
public function store(Request $request)
{
    // ...

    $path = Storage::disk('s3')->put('images', $request->file('image'));
    $url = Storage::disk('s3')->url($path);

    // ...
}
```


## 非公開画像の取り扱い

### 非公開ディレクトリへのアップロード

セキュリティ上の理由から、画像を公開ディレクトリではなく非公開ディレクトリに保存する必要がある場合があります。

##### 1. ストレージの設定

非公開ディスクを追加します。

```php:config/filesystems.php
'disks' => [
    // ...
    'private' => [
        'driver' => 'local',
        'root' => storage_path('app/private'),
    ],
],
```

##### 2. 非公開ディレクトリへのアップロード実装 

```php
public function storePrivate(Request $request)
{
    // ...

    $path = Storage::disk('private')->putFile('images', $request->file('image'));

    // ...
}
```

### 非公開画像の安全な表示

非公開ディレクトリに保存された画像を直接URLで参照することはできないため、専用のルートとコントローラを通じて画像を提供します。

##### 1. ルートの定義

```php:routes/web.php
Route::get('images/{id}', [ImageController::class, 'show'])->name('image.show');
```

##### 2. コントローラの実装

```php
use Illuminate\Support\Facades\Storage;

public function show($id)
{
    $image = Image::findOrFail($id);

    // 認証チェックや権限チェック
    $this->authorize('view', $image);

    $path = $image->path;
    $filename = basename($path);

    return response()->stream(function() use ($path) {
        $stream = Storage::disk('private')->readStream($path);
        fpassthru($stream);
        if (is_resource($stream)) {
            fclose($stream);
        }
    }, 200, [
        'Content-Type' => Storage::disk('private')->mimeType($path),
        'Content-Length' => Storage::disk('private')->size($path),
        'Content-Disposition' => 'inline; filename="' . $filename . '"',
        'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0',
        'Pragma' => 'no-cache',
        'Expires' => '0',
    ]);
}
```

この実装では、ストリーミングを使用してメモリ効率よく画像を提供しています。また、適切なヘッダーを設定してキャッシュ制御とセキュリティを強化しています。

##### 3. 画像の表示

ビューで画像を表示する際は、`route()` ヘルパーを使用してURLを生成します。

```html
<img src="{{ route('image.show', ['id' => $image->id]) }}">
```


### 画像のダウンロード機能

ユーザーが画像をダウンロードできるようにする場合、以下のような実装が可能です。

##### 1. ルートの定義

```php:routes/web.php
Route::get('images/{id}/download', [ImageController::class, 'download'])->name('image.download');
```

##### 2. コントローラの実装

```php
public function download($id)
{
    $image = Image::findOrFail($id);
    
    // 認証チェックや権限チェック
    $this->authorize('download', $image);

    $path = $image->path;
    $filename = basename($path);

    return response()->stream(function() use ($path) {
        $stream = Storage::disk('private')->readStream($path);
        fpassthru($stream);
        if (is_resource($stream)) {
            fclose($stream);
        }
    }, 200, [
        'Content-Type' => Storage::disk('private')->mimeType($path),
        'Content-Length' => Storage::disk('private')->size($path),
        'Content-Disposition' => 'attachment; filename="' . $filename . '"',
        'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0',
        'Pragma' => 'no-cache',
        'Expires' => '0',
    ]);
}
```

この実装では、`Content-Disposition` ヘッダーを `attachment` に設定することで、ブラウザにファイルをダウンロードするよう指示しています。

##### 3. ダウンロードリンクの作成

```html
<a href="{{ route('image.download', ['id' => $image->id]) }}">ダウンロード</a>
```


## まとめ

実際の実装では、プロジェクトの要件や制約に応じて、必要な機能を選択し、パフォーマンスとユーザーエクスペリエンスを加味して設計する必要があります。
こちら開発の参考になれば幸いです。


一例としてサンプルを作成したので、こちらも参考にしてみてください。
https://github.com/horatjp/example-laravel-image-upload


## 参考

- [Laravel公式ドキュメント - ファイルストレージ](https://laravel.com/docs/11.x/filesystem)