💾

Laravel 画像のサムネイル作成に「ディスク」を利用するという選択肢

2021/05/03に公開

前置き

画像のアップロードに際し、元画像と同時にそのサムネイルも一緒に保存したい時は良くあります。

サムネイルの作成は、Intervention Image のライブラリを使えば簡単に行えますが、そのサムネイルをどう管理するかは、少し考えてしまう所があります。
ワードプレスなどの場合、元画像と同じディレクトリに、接尾辞を付けたファイル名で保存したりします。(例:CIMG1030-150x150.jpg)
私が過去に利用した事がある CKFinder2 などでは、元画像は元画像用のディレクトリに保存し、サムネイルはサムネイル用のディレクトリに保存し、ファイル名は同じです。

ワードプレス方式のフォルダが同じというのは、手動で削除する際などは役に立ったりしますが、運用が始まってしまうとあまり関係なかったりします。また、ファイル名を変えたりするのは、微妙に面倒だったりもします。

そこで、今回は、CKFinder2 方式で行く事にします。具体的には、元のファイルは、storage/app/public/ 以下に、サムネイルは、storage/app/public/_thumbs/ 以下に同名のファイル名(パスも)で保存します。

(Laravel でのファイルのアップロード方法の詳細については、ドキュメント(ファイルストレージ)を参照して下さい)

動作確認済 Larvel Ver.8.40.0(極端に古くなければ動作するはず)

ディスク

と言うことで、元画像とは異なるフォルダに保存する為に、Laravel Storage で言う所の「ディスク」を活用します。別にサムネイル用のディスクを設定しなくても、普通にできたりしますし、ソースが短くなるとかは基本無いのですが、あちこちに実際のパスを埋め込まなくて良いという点では、少しすっきりした感はあると思います。

ということで、今回はキャプション名付きの画像のアップロード&一覧表示するという簡単なものを作ってみます。

以下手順です。DBの設定は予めやっておいて下さい。

作業

プロジェクトのルートで、以下を実行して、サムネイル用ライブラリの intervention/image をインストールします。

composer require intervention/image

続けて、モデル、マイグレーション、コントローラを作成します。

php artisan make:model Photo -mc

マイグレーションは、以下の通り。

    public function up()
    {
        Schema::create('photos', function (Blueprint $table) {
            $table->id();
            $table->string('caption');
            $table->string('image');
            $table->timestamps();
        });
    }

作成後、マイグレーションの実行とシンボリックリンクを張って下さい。

php artisan migrate
php artisan storage:link

モデルには、guarded() を追記しておきます。

class Photo extends Model
{
    use HasFactory;

    protected $guarded = [];
}

コントローラは後回しとし、config/filesystems.php で、'public' => の下に以下を追加します。

        'public' => [
	    ....
        ],

        'thumb' => [
            'driver' => 'local',
            'root' => storage_path('app/public/_thumbs'),
            'url' => '/storage/_thumbs',
            'visibility' => 'public',
        ],

root の設定で、保存場所の起点を storage_path('app/public/_thumbs') として、
url は、'/storage/_thumbs' が付与されるようにしてやります。

web.phpは以下とします。

<?php

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

Route::get('photos', [PhotoController::class, 'index']);
Route::post('photos', [PhotoController::class, 'store']);

resources/views/photos/index.blade.php は以下とします。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>画像ギャラリー</title>
</head>
<body>

    @if($errors->any())
        <ul style="color: red">
        @foreach($errors->all() as $error)
            <li>{{ $error }}</li>
        @endforeach
        </ul>
    @endif


    <form method="post" enctype="multipart/form-data">
        @csrf
        キャプション:<input type="text" name="caption" value="{{ old('caption') }}">
        <br>
        画像:<input type="file" name="image">
        <br><br>
        <input type="submit" value="送信する">
    </form>

    @foreach ($photos as $photo)
        <figure>
            <a href="{{ Storage::url($photo->image) }}">
                <img src="{{ Storage::disk('thumb')->url($photo->image) }}" alt="">
            </a>
            <figcaption>{{ $photo->caption }}</figcaption>
        </figure>
    @endforeach


</body>
</html>

一覧に表示する画像はサムネイルの画像とし、リンク先は元画像としています。

リンク先の元画像では、Storage::disk('public')->url($photo->image) と、publicディスクを指定していない為、デフォルトのlocalディスクが使われますが、'url'の設定が無いlocalディスクでは、URLの頭に /storage/ を付与してくれる為、これでOKです。

最後にコントローラです。

<?php

namespace App\Http\Controllers;

use App\Models\Photo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;

class PhotoController extends Controller
{
    public function index()
    {
        $photos = Photo::oldest()->get();

        return view('photos.index', compact('photos'));
    }

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

        $data['image'] = $request->file('image')->store('photos', 'public');

        $this->makeThumbnail(
            Storage::disk('public')->path($data['image']),
            Storage::disk('thumb')->path($data['image']),
        );

        Photo::create($data);

        return back();
    }

    private function makeThumbnail($originalPath, $thumbnailPath)
    {
        File::ensureDirectoryExists(dirname($thumbnailPath));

        // // サムネイル作成
        \Image::make($originalPath)
            ->fit(50, 50)
            ->save($thumbnailPath);
    }
}

storeメソッドでは、validationを入れた後に、画像をpublicディスクを指定して保存して、そのパスを $data['image'] に格納します。そして、private な makeThumbnail メソッドに元画像のフルパスとサムネイル用の画像のフルパスを渡して、サムネイルを作成します。

makeThumbnail()では、サムネイルを格納するディレクトリが予め存在しないとエラーになってしまう為、ensureDirectoryExists() でディレクトリを適宜作成します。そしてサムネイルを作成します。
(補足:Intervention Imageは、Laravel 5.5 以降であれば、Auto-Discovery に対応しており、\Image:: のように書けます)

以上がコードです。

ブラウザでアクセスして画像をアップロードして登録すると、DBのimage欄には、例えば、「photos/4hEIHU9vfofaQi86WPo6EylewyoOUKu003wQFqh6.jpg」のような感じで保存されます。

で、元の画像は、storage/app/public/photos/4hEIHU9vfofaQi86WPo6EylewyoOUKu003wQFqh6.jpg に保存され、サムネイルは、storage/app/public/_thumbs/photos/4hEIHU9vfofaQi86WPo6EylewyoOUKu003wQFqh6.jpg
に保存されます。

view側でのURLは、
元画像 … Storage::url($photo->image)
サムネイル … Storage::disk('thumb')->url($photo->image)
となり、ファイルの接頭辞、接尾辞や実際の保存場所のパスやらを気にしなくても良いため、精神的に楽になった気がします。(ファイルの削除時なども同様)

少しコードを整理

以上で終わりなのですが、コントローラーで private なメソッドを使ったりと、少し落ち着かない面もありますので、FormRequest を使って少し整理してみたいと思います。以下で、FormRequestを作成します。

php artisan make:request PhotoStoreRequest

まず、コントローラは以下のようにします。(makeThumbnailメソッドは、消します)

use App\Http\Requests\PhotoStoreRequest; // 上部に追加

    public function store(PhotoStoreRequest $request)
    {
        $data = $request->validated();

        $data['image'] = $request->storeImage();

        Photo::create($data);

        return back();
    }

validationと画像の保存処理をFormRequestに移動しました。

PhotoStoreRequestは、以下の通りです。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;

class PhotoStoreRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'caption' => ['required', 'string', 'max:255'],
            'image' => ['required', 'mimes:jpg,jpeg,png,gif'],
        ];
    }

    public function storeImage()
    {
        $path = $this->file('image')->store('photos', 'public');

        $this->makeThumbnail(
            Storage::disk('public')->path($path),
            Storage::disk('thumb')->path($path),
        );

        return $path;
    }

    private function makeThumbnail($originalPath, $thumbnailPath)
    {
        File::ensureDirectoryExists(dirname($thumbnailPath));

        // サムネイル作成
        \Image::make($originalPath)
            ->fit(50, 50)
            ->save($thumbnailPath);
    }
}

雑感

ディスクを活用することで、少しすっきりした感があります。
おかしな点等ありましたら、コメント下さい。

ちなみに、もしワードプレスみたいに大中小サイズの画像などが必要な場合、
URLでアクセスがある度に動的にファイルを生成して出力する(キャッシュもする)という機能が、Intervention Imageにある為、(intervention/imagecache というライブラリも必要)そちらの利用も検討してみると良いかも知れません。
URL based image manipulation

Discussion