👌

【ソース有】spatie/laravel-medialibrary を使った簡易ファイルアプリの設計

2025/02/13に公開

新しいライブラリーを使う場合設計を何度か試す必要は必ずある。たとえばユーザーが適当にファイルを置くようなものを考えてみよう。


完成イメージ。最終的にFile ID 1つにつき2つ以上のファイルを想定

ソース

https://github.com/catatsumuri/laravel-inertia-stack-ja/tree/spatie-lib-demo-fileapp

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をひっつける

app/Models/File.php
<?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

database/seeders/DatabaseSeeder.php
<?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

routes/web.php
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の修正

database/factories/FileFactory.php
<?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']),
        ];
    }
}
database/seeders/DatabaseSeeder.php
<?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などを試す場合はこれ使えないので実ファイルを用意したりする必要があるが、とりあえず

database/factories/FileFactory.php
<?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');
        });
    }
}


もうちょっと、こうそれっぽい雰囲気になる。

実際の画像を配置する場合

"database/factories/FileFactory.php" 45L, 1250B 書込み
      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の絞りこみは後でやるとして

app/Models/File.php
<?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');
    }
}
app/Http/Controllers/FileController.php
      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を仕掛けて適用する

https://zenn.dev/catatsumuri/articles/cefb7b72e48dd9

ここで詳しく書いたような画像変換をここで試みる

app/Models/File.php
<?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つ作っておく

routes/web.php

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']);
});

としたときに

https://github.com/catatsumuri/laravel-inertia-stack-ja/blob/spatie-lib-demo-fileapp/app/Http/Controllers/FileController.php#L20-L40

で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本に大してメディアを複数割り当てる

https://github.com/catatsumuri/laravel-inertia-stack-ja/blob/spatie-lib-demo-fileapp/database/factories/FileFactory.php#L30-L50

こうしても現在のプログラムは一応複数ある想定なので動作はするのであるが、

app/Http/Controllers/FileController.php
     public function index(): Response
      {
          $files = File::latest()->with(['media', 'user'])->get()->map(function ($file) {
              $firstMedia = $file->media->first();

ここでfirstメディアしか取り扱っていないので何もおきない。とりあえず今何本メディアが入っているかカウントを出してみよう

https://github.com/catatsumuri/laravel-inertia-stack-ja/blob/spatie-lib-demo-fileapp/app/Http/Controllers/FileController.php#L20-L40

このようにID1につき複数ファイルを納める事ができる。最初のファイルを使うか使わないかはプログラムで決定するとしてorderとかは内部で持っているので、それをいじるようなUIも作れるならば作れる

ZIPでDL

これらを一括でDLする事もできる。こっちは簡単なのでこれを作って終わりにしましょう。

https://github.com/catatsumuri/laravel-inertia-stack-ja/blob/spatie-lib-demo-fileapp/routes/web.php#L33-L38

とする。zipへの動線を貼る

https://github.com/catatsumuri/laravel-inertia-stack-ja/blob/spatie-lib-demo-fileapp/app/Http/Controllers/FileController.php#L20-L40

こんな風にすると

2ファイルあるのがわかる。

これのzipダウンロードは簡単にできる

https://github.com/catatsumuri/laravel-inertia-stack-ja/blob/spatie-lib-demo-fileapp/app/Http/Controllers/FileController.php#L120-L129

Discussion