【ソース有】spatie/laravel-medialibrary を使った簡易ファイルアプリの設計
新しいライブラリーを使う場合設計を何度か試す必要は必ずある。たとえばユーザーが適当にファイルを置くようなものを考えてみよう。
完成イメージ。最終的にFile ID 1つにつき2つ以上のファイルを想定
ソース
Fileモデル
artisan make:model File -mrf
ここで通常Fileモデルとユーザーモデルは関連付けられるはずで、
public function up(): void
{
Schema::create('files', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
}
このようになるはずだ。さらにFileモデルにhasMedia
をひっつける
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class File extends Model implements HasMedia
{
/** @use HasFactory<\Database\Factories\FileFactory> */
use HasFactory, InteractsWithMedia;
public function registerMediaCollections(): void
{
$this->addMediaCollection('file');
}
}
ここでは冒頭あるように最終的に2つ以上のファイルを想定するのでsingleFile
にしない。
ユーザーのseeder
<?php
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}
このように10人作成する。
routeing
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('files', FileController::class)->only(['index', 'store', 'destroy']);
});
とする
ファイル置き場をさっと作ってみる
とりあえずファイルを置けそうなところをさっと作る
これはFileController::indexの画面であり、とくにコードは示さなくてもいいだろう。
ここでファイルと同時にファイル名と公開/非公開を取り付けている。ファイル名はもちろん、内部的にはライブラリー側で保存されるのだが、上書きしたいというような案件だとしよう。
いずせにせよ
このような状態で送信されるものとする
public function store(Request $request): RedirectResponse
{
DB::beginTransaction();
try {
$file = auth()->user()->files()->create([
'name' => $request->filename,
'visibility' => $request->visibility,
]);
$file->addMedia($request->file('files'))->toMediaCollection('files');
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
die($e->getMessage());
}
return redirect()->route('files.index')->with('success', 'File uploaded successfully.');
}
のようにするとする、しかし現状では構造が足りないので修正
migrationの更新
visibility
の持たせ方はいろいろ考えられるが、ここではenum
とした
public function up(): void
{
Schema::create('files', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->enum('visibility', ['public', 'private'])->default('private');
$table->timestamps();
});
}
いずれにせよ、このようにリストされるものとする
Fileモデル外の情報にアクセスする場合
繰り返しになるがここでは
Schema::create('files', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->enum('visibility', ['public', 'private'])->default('private');
$table->timestamps();
});
こういうスキーマしか組んでいないので、実ファイル(つまりここではMedia
)の情報にアクセスするには工夫が必要である。たとえば実際に格納されているファイルのサイズはSpatieのライブラリー側に保存されているから、これにアクセスしたい場合は工夫が必要だ
public function index(): Response
{
$files = File::latest()->get();
dd($files);
return Inertia::render('Files/Index', [
'files' => $files,
]);
}
ここには基本的な情報しか入ってはいない
public function index(): Response
{
$files = File::latest()->get();
$files->map(function ($file) {
$firstMedia = $file->getFirstMedia('files');
dd($firstMedia);
});
dd($files);
return Inertia::render('Files/Index', [
'files' => $files,
]);
}
このように、このライブラリーは指定されたプロパティーに1対多の関係を持つため、最初の情報が欲しい場合はgetFirstMedia()
をする必要があるわけだ。従ってファイルサイズなどを構造体に詰めこむ場合はこのようにする。
public function index(): Response
{
$files = File::latest()->with('media')->get()->map(function ($file) {
$firstMedia = $file->media->first();
return [
'id' => $file->id,
'name' => $file->name,
'visibility' => $file->visibility,
'created_at' => $file->created_at,
'filesize' => $firstMedia?->human_readable_size ?? null,
];
});
return Inertia::render('Files/Index', [
'files' => $files,
]);
}
すると
となる
これで
こういうのは実現できる。
ここまでのまとめ
spatie/laravel-medialibrary
はその汎用性から親テーブルに1対多リレーション的なものを構築する。ほとんどの場合1対1で事足りるはずなのであるが、複数リレーションある事を意識していないと意外とハマる事があるので注意が必要だ。
seeding
ここでやったようなファイルをseedしたいという事もあるだろう。これにtryしてみよう
FileFactoryの修正
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use App\Models\User;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\File>
*/
class FileFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::inRandomOrder()->first()->id,
'name' => fake()->word . '.png',
'visibility' => fake()->randomElement(['public', 'private']),
];
}
}
<?php
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\File;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
User::factory(3)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
File::factory()->count(30)->create();
}
}
とすると30個ファイルができる。ユーザーを10人作ると外れそうなので3人にした。この段階ではまだファイルは生成していない
ファイルが無いのでサイズが取れていない
テスト専用のモックファイルをアップする
まあ、ファイル自体は存在してないんだけど、これをごまかすような仕組みで実行する。実際にconversionなどを試す場合はこれ使えないので実ファイルを用意したりする必要があるが、とりあえず
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use App\Models\User;
use App\Models\File;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\File>
*/
class FileFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::inRandomOrder()->first()->id,
'name' => fake()->word . '.png',
'visibility' => fake()->randomElement(['public', 'private']),
];
}
public function configure(): static
{
return $this->afterCreating(function (File $file) {
// ランダムな画像サイズ (300〜800px)
$width = rand(300, 800);
$height = rand(300, 800);
$imageUrl = "https://picsum.photos/{$width}/{$height}";
$fileName = "{$width}x{$height}.png"; // ファイル名を指定
// Spatie Media Library に直接 URL から追加し、ファイル名を指定
$file->addMediaFromUrl($imageUrl)
->usingFileName($fileName) // ファイル名を変更
->toMediaCollection('files');
});
}
}
もうちょっと、こうそれっぽい雰囲気になる。
実際の画像を配置する場合
public function configure(): static
{
return $this->afterCreating(function (File $file) {
// ランダムな画像サイズ (300〜800px)
$width = rand(300, 800);
$height = rand(300, 800);
$imageUrl = "https://picsum.photos/{$width}/{$height}";
// Spatie Media Library に直接 URL から追加
$file->addMediaFromUrl($imageUrl)
->toMediaCollection('files');
});
}
プログラムの修正
public function index(): Response
{
$files = File::latest()->with('media')->get()->map(function ($file) {
$firstMedia = $file->media->first();
return [
'id' => $file->id,
'name' => $file->name,
'visibility' => $file->visibility,
'created_at' => $file->created_at,
'filesize' => $firstMedia?->human_readable_size ?? null,
];
});
return Inertia::render('Files/Index', [
'files' => $files,
]);
}
現在、owner以外のファイルも出ているのとオリジナルファイル名がわからないので少し修正。とりあえずownerの絞りこみは後でやるとして
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class File extends Model implements HasMedia
{
/** @use HasFactory<\Database\Factories\FileFactory> */
use HasFactory, InteractsWithMedia;
protected $fillable = [
'name',
'visibility',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('file');
}
}
public function index(): Response
{
$files = File::latest()->with(['media', 'user'])->get()->map(function ($file) {
$firstMedia = $file->media->first();
return [
'id' => $file->id,
'name' => $file->name,
'owner' => $file->user->name,
'visibility' => $file->visibility,
'created_at' => $file->created_at,
'filesize' => $firstMedia?->human_readable_size ?? null,
];
});
return Inertia::render('Files/Index', [
'files' => $files,
]);
}
として適当にviewを加工すると、こういった形になるはずだ
後からconversionを仕掛けて適用する
ここで詳しく書いたような画像変換をここで試みる
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\Image\Enums\Fit;
class File extends Model implements HasMedia
{
/** @use HasFactory<\Database\Factories\FileFactory> */
use HasFactory, InteractsWithMedia;
protected $fillable = [
'name',
'visibility',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('file');
}
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')
->fit(Fit::Crop, 150, 150)
->nonQueued();
}
}
seedし直してもいいのだが、後から追加した場合は
artisan media-library:regenerate
でいつでも再度生成できる
thumbnailも表示する
とりあえずrouteを1つ作っておく
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('files/{file}/media/{media}/conversion/{conversion}', [FileController::class, 'conversion'])->name('files.conversion');
Route::resource('files', FileController::class)->only(['index', 'show', 'store', 'destroy']);
});
としたときに
でmediaIdを渡しておけばサムネイルが作れるはず
実ファイルの表示
これもrouteを1本用意してもいいんだけど、面倒なので今回はshowをそのまま使っちゃう(設計的にはかなり無理がある)。showへリンクを貼る
public function show(Request $request, File $file): StreamedResponse
{
$media = $file->getFirstMedia('files');
return $media->toInlineResponse($request);
}
showはこんな感じでok。
いろいろやりようはありますよね
複数ファイルを使う設計に変更してみよう
Filesのid1本に大してメディアを複数割り当てる
こうしても現在のプログラムは一応複数ある想定なので動作はするのであるが、
public function index(): Response
{
$files = File::latest()->with(['media', 'user'])->get()->map(function ($file) {
$firstMedia = $file->media->first();
ここでfirstメディアしか取り扱っていないので何もおきない。とりあえず今何本メディアが入っているかカウントを出してみよう
このようにID1につき複数ファイルを納める事ができる。最初のファイルを使うか使わないかはプログラムで決定するとしてorderとかは内部で持っているので、それをいじるようなUIも作れるならば作れる
ZIPでDL
これらを一括でDLする事もできる。こっちは簡単なのでこれを作って終わりにしましょう。
とする。zipへの動線を貼る
こんな風にすると
2ファイルあるのがわかる。
これのzipダウンロードは簡単にできる
Discussion