spatie/laravel-medialibraryを深掘りするための初歩
オフィシャルドキュメント的
あるいは
設計が独特なので利用する場合は事前にプロトタイプでよく掘っておく必要あり
前準備
composer require spatie/laravel-medialibrary
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations""
とすると
<?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
<?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を付ける
// 追記
public function registerMediaCollections(): void
{
$this->addMediaCollection('avatar')
->singleFile();
}
このような形で「柔軟に」「指定した文字列で」格納先を与える事ができる。ここでは見ての通りavatar
としている。
breezeのprofileからavatarをアップロードする
ここから先はbreezeを使って初期認証スキャフォールドを展開する例だけど、そこはあまり重要ではないのでさらっと読めるように書いていく。なお、Inertia.js
のreactが使われているが基本的にそこも重要じゃないので一応今の環境がそうなってるだけという事で適時読み替えていただければと思う。
Profileのedit
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が実態だったりする。まあそんな事はどうでもいいので、アップロードフォームを追加してみよう
--- 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ディスクが利用される。これは
'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を表示するデモを作成することにする
Route::get('/profile/avatar', [ProfileController::class, 'avatar'])->name('profile.avatar');
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)
このように前の画像が消えて新しい画像で上書きされる。これは何故かというともう一度メディアの定義を見てみると
public function registerMediaCollections(): void
{
$this->addMediaCollection('avatar')
->singleFile();
}
このようにsingleFile()
が定義されているからである。avatarの場合は古いファイルを保存したいとかいう特殊な用途でない限りこちらの方が大抵の場合においてうまいこと行くんじゃないだろうかと思うが、一応確認しておく。
さすがに画像がでかすぎるやろという問題
フォームアップロード後の状況を見る限りどう見ても画像がでかい。これはconversionという機能で小さいのも保存できる。
ではデモにあるような機能を付けてみよう
「thumb」を保存する
--- 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を指定してみよう
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を返却するようにする。
{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"
なんかで行う
他にも膨大な指定方法があるのでドキュメントをみよう
長くなってきたので続きは別項へ
この段階でのまとめ
ここではユーザーのアバターを添付する方法をみてきた
設計においては「ユーザーの」アバターというようにモデルの関連ファイルを定義する分には非常に便利なものになっている。ただし「ファイル」を先行する場合は結構工夫が必要だったりもするから、そのうち解説できたらいいすね
Discussion