💨

spatie/laravel-medialibraryを深掘りするための初歩

2025/02/05に公開

オフィシャルドキュメント的

https://packagist.org/packages/spatie/laravel-medialibrary

あるいは

https://spatie.be/docs/laravel-medialibrary/v11/introduction

設計が独特なので利用する場合は事前にプロトタイプでよく掘っておく必要あり

前準備

composer require spatie/laravel-medialibrary
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations""

とすると

database/migrations/xxxx_create_media_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('media', function (Blueprint $table) {
            $table->id();

            $table->morphs('model');
            $table->uuid()->nullable()->unique();
            $table->string('collection_name');
            $table->string('name');
            $table->string('file_name');
            $table->string('mime_type')->nullable();
            $table->string('disk');
            $table->string('conversions_disk')->nullable();
            $table->unsignedBigInteger('size');
            $table->json('manipulations');
            $table->json('custom_properties');
            $table->json('generated_conversions');
            $table->json('responsive_images');
            $table->unsignedInteger('order_column')->nullable()->index();

            $table->nullableTimestamps();
        });
    }
};

なファイルができるのでmigrateする。設定ファイルもpublishできるんだけど、とりあえずはdefaultで動作させよう

モデル

ここではユーザーモデルにくっつけていく。

以下はlaravelが最初から用意するユーザーモデルである

app/Models/User.php
app/Models/User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

これを以下のように変更する

--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -7,10 +7,14 @@
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;

-class User extends Authenticatable
+// use spatie medialibs
+use Spatie\MediaLibrary\HasMedia;
+use Spatie\MediaLibrary\InteractsWithMedia;
+
+class User extends Authenticatable implements HasMedia
 {
     /** @use HasFactory<\Database\Factories\UserFactory> */
-    use HasFactory, Notifiable;
+    use HasFactory, Notifiable, InteractsWithMedia;

     /**
      * The attributes that are mass assignable.

これで準備が整った

作例: ユーザーにavaterを付ける

app/Models/User.php
    // 追記
    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('avatar')
            ->singleFile(); 
    }

このような形で「柔軟に」「指定した文字列で」格納先を与える事ができる。ここでは見ての通りavatarとしている。

breezeのprofileからavatarをアップロードする

ここから先はbreezeを使って初期認証スキャフォールドを展開する例だけど、そこはあまり重要ではないのでさらっと読めるように書いていく。なお、Inertia.jsのreactが使われているが基本的にそこも重要じゃないので一応今の環境がそうなってるだけという事で適時読み替えていただければと思う。

Profileのedit

app/Http/Controllers/ProfileController.php
    public function edit(Request $request): Response
    {
        return Inertia::render('Profile/Edit', [
            'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
            'status' => session('status'),
        ]);
    }

こんな感じでやっている。Profile/Editが編集UIである事が想像つくと思うけど、ここでの環境だとresources/js/Pages/Profile/Edit.jsx であるがresources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsxが実態だったりする。まあそんな事はどうでもいいので、アップロードフォームを追加してみよう

Inertia.js+Reactでのファイルアップロードフォーム追加例
--- a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx
+++ b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx
@@ -14,16 +14,18 @@ export default function UpdateProfileInformation({
   const user = usePage().props.auth.user;
   const { t } = useLaravelReactI18n();

-  const { data, setData, patch, errors, processing, recentlySuccessful } =
+  const { data, setData, post, errors, processing, recentlySuccessful } =
     useForm({
       name: user.name,
       email: user.email,
+      avatar: null,
+      _method: 'patch',
     });

   const submit = (e) => {
     e.preventDefault();

-    patch(route('profile.update'));
+    post(route('profile.update'));
   };

   return (
@@ -71,6 +73,14 @@ export default function UpdateProfileInformation({
           <InputError className='mt-2' message={errors.email} />
         </div>

+        <div>
+          <InputLabel htmlFor='avatar' value={t('Avatar')} />
+
+          <input type="file" onChange={e => setData('avatar', e.target.files[0])} />
+
+          <InputError className='mt-2' message={errors.avatar} />
+        </div>
+
         {mustVerifyEmail && user.email_verified_at === null && (
           <div>
             <p className='mt-2 text-sm text-gray-800'>


追加されたフォーム

フロントエンドはどういう手法でもいいのでアップロードのフォームを1つ追加し、avatarというキーで受けれるようにする。

    public function update(ProfileUpdateRequest $request): RedirectResponse
    {
        dd($request->all());

こんな感じで受け取れれば保存の準備はok

実際に保存してみる例

    public function update(ProfileUpdateRequest $request): RedirectResponse
    {
        $request->user()->fill($request->validated());

        if ($request->user()->isDirty('email')) {
            $request->user()->email_verified_at = null;
        }

        $request->user()->save();

        // ここでavatarを保存
        if ($request->hasFile('avatar')) {
            $request->user()->addMediaFromRequest('avatar')
                ->toMediaCollection('avatar');
        }

        return Redirect::route('profile.edit');
    }

このようにするとavatarを保存できる。これはmigrationで追加されたmediaテーブルへと保存されるから、それを見てみよう

mysqlで構造を確認

mysql> select * from media\G
*************************** 1. row ***************************
                   id: 1
           model_type: App\Models\User
             model_id: 1
                 uuid: e5ca1245-0280-4fbf-8f66-e9bc390c2781
      collection_name: avatar
                 name: FeYTJJsaEAAiMfE
            file_name: FeYTJJsaEAAiMfE.jpg
            mime_type: image/jpeg
                 disk: public
     conversions_disk: public
                 size: 555653
        manipulations: []
    custom_properties: []
generated_conversions: []
    responsive_images: []
         order_column: 1
           created_at: 2025-02-04 08:18:40
           updated_at: 2025-02-04 08:18:40
1 row in set (0.00 sec)

ここで注目すべきは

  • model_type: App\Models\User
  • model_id: 1

により、Userモデルのid:1と関連付けている事が理解できるこれをポリモーフィックリレーションとかいうようだ。

さらに

  • disk: public
  • conversions_disk: public

の指定によりpublicディスクが利用される。これは

config/filesystem.php
        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
            'throw' => false,
        ],

この項目に応じており、実際に

find storage/app/public
storage/app/public
storage/app/public/.gitignore
storage/app/public/1
storage/app/public/1/sample.jpg

このような形式で保存されているのが確認できる。ただし、public storageなどは今日日のユースケースで使われる事はほとんど無いと思うので、プロダクション環境では適切に設定する必要がある、が、ここはexampleなのでこのまま行く。

保存されたファイルの取得

これの取り出し方はいろいろ議論があると思うけど、ここでは認証済みユーザーのavatarを表示するデモを作成することにする

routes/web.php
Route::get('/profile/avatar', [ProfileController::class, 'avatar'])->name('profile.avatar');
app/Http/Controllers/ProfileController.php
    public function avatar()
    {
        $avatar = Auth::user()->getFirstMedia('avatar');
        $path = $avatar->getPath();
        $type = $avatar->mime_type;
        return response()->file($path, [
            'Content-Type' => $type,
            'Content-Disposition' => 'inline',
        ]);
    }

こんな感じにしてあげる。すなわち重要な所は

$avatar = Auth::user()->getFirstMedia('avatar');

ここで、「柔軟に」指定したキーにより取り出しが可能。

表示

{/* display the current avatar */}
{hasAvatar && (
<div className='mt-2'>
  <img src={route('profile.avatar')} alt={user.name} />
</div>
)}

DALL-Eで適当に作ったavatarをuploadすると...

とまあこんな感じになる。ここまでが基本動作。

同一ルーチンでさらに画像をアップした場合

mysql> select * from media\G
*************************** 1. row ***************************
                   id: 2
           model_type: App\Models\User
             model_id: 1
                 uuid: 7bc195d8-ee96-4983-b09a-047bb267cf69
      collection_name: avatar
                 name: sample
            file_name: sample.jpg
            mime_type: image/jpeg
                 disk: public
     conversions_disk: public
                 size: 192516
        manipulations: []
    custom_properties: []
generated_conversions: []
    responsive_images: []
         order_column: 2
           created_at: 2025-02-04 10:07:58
           updated_at: 2025-02-04 10:07:58
1 row in set (0.00 sec)

このように前の画像が消えて新しい画像で上書きされる。これは何故かというともう一度メディアの定義を見てみると

app/Models/User.php
    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('avatar')
            ->singleFile();
    }

このようにsingleFile()が定義されているからである。avatarの場合は古いファイルを保存したいとかいう特殊な用途でない限りこちらの方が大抵の場合においてうまいこと行くんじゃないだろうかと思うが、一応確認しておく。

さすがに画像がでかすぎるやろという問題

フォームアップロード後の状況を見る限りどう見ても画像がでかい。これはconversionという機能で小さいのも保存できる。

https://spatie.be/docs/laravel-medialibrary/v11/converting-images/defining-conversions

ではデモにあるような機能を付けてみよう

「thumb」を保存する

app/Models/User.php
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -10,6 +10,7 @@
 // use spatie medialibs
 use Spatie\MediaLibrary\HasMedia;
 use Spatie\MediaLibrary\InteractsWithMedia;
+use Spatie\MediaLibrary\MediaCollections\Models\Media;

 class User extends Authenticatable implements HasMedia
 {
@@ -56,4 +57,12 @@ public function registerMediaCollections(): void
             ->singleFile();
     }

+    // Thumbnail Conversion
+    public function registerMediaConversions(?Media $media = null): void
+    {
+        $this->addMediaConversion('thumb')
+              ->width(368)
+              ->height(232)
+              ->sharpen(10);
+    }

とすると、uploadした際にthumbnailが生成、されない

これはデフォルトの動作で、サムネイルの生成などのconversionはqueueに送られるからである。


どうにかしてqueueを回せば生成される

queueを使いたくない場合は

    // Thumbnail Conversion
    public function registerMediaConversions(?Media $media = null): void
    {
        $this->addMediaConversion('thumb')
              ->width(368)
              ->height(232)
              ->sharpen(10)
              ->nonQueued();
    }

上記のようにnonQueued()とすると直ちに生成される

% find storage/app/public
storage/app/public
storage/app/public/3
storage/app/public/3/sample.jpg
storage/app/public/3/conversions
storage/app/public/3/conversions/sample-thumb.jpg

取り出しにthumbを指定してみよう

routes/web.php
Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::get('/profile/avatar/{size?}', [ProfileController::class, 'avatar'])->name('profile.avatar.size');

などして

    public function avatar($size = '')
    {
        // Get the user's avatar
        $avatar = Auth::user()->getFirstMedia('avatar');
        //return inline response with mime type
        $path = $avatar->getPath($size);
        $type = $avatar->mime_type;
        return response()->file($path, [
            'Content-Type' => $type,
            'Content-Disposition' => 'inline',
        ]);
    }

などして $size に応じたpathを返却するようにする。

resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx
{hasAvatar && (
<div className='mt-2'>
  <img src={route('profile.avatar', 'thumb')} alt={user.name} />
</div>
)}

のように呼び出せば


画像が小さくなりましたよね

このようになる。なお、画像のキャッシュが効いてしまっている問題の解決策はいろいろあるんだけど、ここでは面倒なので割愛。キャッシュを消去して読み込んでとりあえず解決する。

conversionが存在するかチェック

今現在

      public function edit(Request $request): Response
      {
          return Inertia::render('Profile/Edit', [
              'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
              'status' => session('status'),
              'hasAvatar' => $request->user()->hasMedia('avatar'),
          ]);
      }

このようなコードでhasMedia()メソッドを使い存在を確認しているが、conversion自体が存在しているかをチェックする場合は

$hasAvatar = $request->user()->getMedia('avatar')->first()->hasGeneratedConversion('thumb');
// あるいはgetFirstMedia()
dd($hasAvatar); // true

というようにhasGeneratedConversionを利用する。これはmediaが複数ある場合も想定して、それぞれのmediaごとにconversionがあるかチェックしないといけないため多少長くなるのでそこは注意。

画像最適化とexif

avatarなどに限らず現代のスマートフォンはjpegにexif画像が挟みこまれているため、スマートフォンから直接アップロードされた場合などについてはexif情報のカットを行う必要がある。

この辺はjpegoptimをインストールすると自動でやってくれるらしい。

というわけなんだけど、ファイルを用意したり環境を用意したりするのがダルいのでのうちちゃんとやってみるかも。exifカットは意外と重要だったりして。

画像の再生成

php artisan media-library:regenerate

とか

php artisan media-library:regenerate "App\Models\Post"

とか

php artisan media-library:regenerate "post"

なんかで行う

他にも膨大な指定方法があるのでドキュメントをみよう

https://spatie.be/docs/laravel-medialibrary/v11/converting-images/regenerating-images

長くなってきたので続きは別項へ

この段階でのまとめ

ここではユーザーのアバターを添付する方法をみてきた

設計においては「ユーザーの」アバターというようにモデルの関連ファイルを定義する分には非常に便利なものになっている。ただし「ファイル」を先行する場合は結構工夫が必要だったりもするから、そのうち解説できたらいいすね

Discussion