💬

laravel12 starter kit(+react)でavatar アップロードを作る

に公開

https://zenn.dev/catatsumuri/articles/0b501aa2e33293

の続き

laravel12のスターターキットデフォルトではこの通り、avatarのカラムを想定しているにもかかわらず、当該項目の管理など何も考えられていない為、諸々uiが欠落している

ここではまず、非常にベタなアプローチでここにアップロード機能を追加する方法を解説してみる。

前提

usersテーブルにavatarカラムを追加(詳細は前回の記事を参照)

ここで開発するもの

こんな機能

何はともあれアップロードされたavatarファイルをcontrollerで受けとらなくてはいけない

現状のlaravelのdefaultスキーマーではavatarすら保存できないのであるが、それは後で考えるとして、何はともあれcontrollerでavatar画像ファイルを受けとる必要があるかと思う。

受けとるrouteを定義

starter kitのroutes/web.phpを見てみると

routes/web.php
// <snip>
require __DIR__.'/settings.php';
require __DIR__.'/auth.php';

このようにroutes/settings.phpに流れている

routes/settings.php
<?php
// <snip>
Route::middleware('auth')->group(function () {
    Route::redirect('settings', 'settings/profile');

    Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// <snip>

つわけで根本的にはProfileControllerでやるのがよさそうだという事で、

routes/settings.php
@@ -11,6 +11,7 @@
     Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
     Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
     Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
+    Route::post('settings/profile/avatar', [ProfileController::class, 'uploadAvatar'])->name('profile.avatar.upload');

     Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit');
     Route::put('settings/password', [PasswordController::class, 'update'])->name('password.update');

このように追加した。

ベタなアップロードフォームを定義

元々inertia.jsつのはベタなもんなんです、ってことで

resources/js/pages/settings/profile.tsx
@@ -41,11 +41,54 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
     });
   };

+  const {
+    data: avatarData,
+    setData: setAvatarData,
+    post: postAvatar,
+    processing: avatarUploading,
+    errors: avatarErrors,
+    reset: resetAvatarForm,
+  } = useForm<{ avatar: File | null }>({
+    avatar: null,
+  });
+
+  const handleAvatarUpload: FormEventHandler = (e) => {
+    e.preventDefault();
+
+    postAvatar(route('profile.avatar.upload'), {
+      forceFormData: true,
+      preserveScroll: true,
+      onSuccess: () => resetAvatarForm(),
+    });
+  };
+
   return (
     <AppLayout breadcrumbs={breadcrumbs}>
       <Head title={t('Profile settings')} />

       <SettingsLayout>
+        <div className="mt-6 space-y-6 border-t pt-6">
+          <HeadingSmall title="プロフィール画像の変更" description="画像をアップロードするとプロフィール画像が更新されます。" />
+
+          <form onSubmit={handleAvatarUpload} className="space-y-4">
+            <div className="grid gap-2">
+              <Label htmlFor="avatar">画像ファイル</Label>
+              <Input
+                id="avatar"
+                type="file"
+                name="avatar"
+                accept="image/*"
+                required
+                onChange={(e) => setAvatarData('avatar', e.currentTarget.files?.[0] ?? null)}
+              />
+              {avatarErrors.avatar && <InputError className="mt-2" message={avatarErrors.avatar} />}
+            </div>
+            <Button type="submit" variant="outline" disabled={avatarUploading}>
+              アップロード
+            </Button>
+          </form>
+        </div>
+
         <div className="space-y-6">
           <HeadingSmall title={t('Profile information')} description={t('Update your name and email address')} />

これにより

こういうのが追加された。とりあえずわかりやすいようにこの部分は多言語にしていない。

受信する処理

まあとりあえず

app/Http/Controllers/Settings/ProfileController.php
@@ -60,4 +60,9 @@ public function destroy(Request $request): RedirectResponse

         return redirect('/');
     }
+
+    public function uploadAvatar(Request $request): RedirectResponse
+    {
+        dd($request->all());
+    }
 }

など作っておいてリクエストを受信し、dumpできればとりあえずはok

ファイルを受けとった後

さて、いよいよここから保存方法を考えていってみよう。もちろん、ベタにこの作業を自前で書いていってもよいいのであるが、ここではspatie/laravel-medialibraryを利用する事とする。これに関して基本的な事は

https://zenn.dev/catatsumuri/articles/9fb4c6eeb2cdb1

ここに書いてあるから必要に応じて見ていただいてok

基本的な設計

1ユーザーにつき1つのavatarのみ格納し、古いものは捨てる事とする。1ユーザーに複数のavatarを補完させて適当に選択みたいな事をし初めると設計が一気に難解になるので(もちろん可能ではあるが)工数次第ではある程度割切りが必要である。

また、画像ファイルはpublicに置いてアクセス可能としてもいい

install

何はともあれライブラリーを投入する。

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

とするとライブラリーがinstallされ

   INFO  Publishing assets.

  Copying file [vendor/spatie/laravel-medialibrary/config/media-library.php] to [config/media-library.php] .................... DONE
  File [database/migrations/2025_05_10_055716_create_media_table.php] already exists ....................................... SKIPPED
  Copying directory [vendor/spatie/laravel-medialibrary/resources/views] to [resources/views/vendor/media-library] ............ DONE

となる。まあ resources/views/vendor/media-library/ は必要ないかもしれないな。

migrateする

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();
        });
    }
};

こんな感じのmigrationが付いてくるので、とりあえずmigrateしておく

php artisan migrate

Userモデルを拡張

こんな感じで拡張

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

-class User extends Authenticatable
+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.
@@ -45,4 +48,9 @@ protected function casts(): array
             'password' => 'hashed',
         ];
     }
+
+    public function registerMediaCollections(): void
+    {
+        $this->addMediaCollection('avatar')->singleFile();
+    }
 }

configの設定とファイル格納場所について

まずconfig/media-library.php

    /*
     * The disk on which to store added files and derived images by default. Choose
     * one or more of the disks you've configured in config/filesystems.php.
     */
    'disk_name' => env('MEDIA_DISK', 'public'),

この部分を見るとdefaultでpublicを使うぞと書いてある。avatarの場合基本的にpublicでもいいのであるが、ここではlocalディスクにしたいという場合defaultをlocalにしておく。これは基本的にconfig/filesystem.phpのキーに依存する。増やす場合は自分でfilesystem.phpに定義する必要がある。

config/media-library.php
@@ -6,7 +6,7 @@
      * The disk on which to store added files and derived images by default. Choose
      * one or more of the disks you've configured in config/filesystems.php.
      */
-    'disk_name' => env('MEDIA_DISK', 'public'),
+    'disk_name' => env('MEDIA_DISK', 'local'),

     /*
      * The maximum file size of an item in bytes.

もちろん、Modelの中のコードでもそれぞれ格納場所を指定できるのであるが、ここではdefaultをlocalとさせていただく、すなわち簡単なリンクではavatar画像は取れませんからね。

テストで保存してみる

    public function uploadAvatar(Request $request): RedirectResponse
    {
        $request->validate([
            'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,svg', 'max:2048'],
        ]);

        $user = $request->user();

        $user->addMediaFromRequest('avatar')
            ->toMediaCollection('avatar');

        dd($user->getFirstMediaUrl('avatar'));

などすると

"/storage/1/idle.png" // app/Http/Controllers/Settings/ProfileController.php:75

となり、保存されている事がわかる。これは実pathでは storage/app/private/1/idle.png というpathに保存されている

この段階で構造をチェックしてみると

tinkerで確認する

$ php artisan tinker
Psy Shell v0.12.8 (PHP 8.2.28 — cli) by Justin Hileman
> $user = App\Models\User::find(1);
= App\Models\User {#6485
    id: 1,
    name: "Test User",
    email: "test@example.com",
    email_verified_at: "2025-05-08 04:45:47",
    #password: "$2y$12$hwDVE4Xg/6CNJ5B98wOzT.tUnQ9RAulXLfnYQY6MeBO4laLvrwnnC",
    avatar: "https://i.pravatar.cc/300?u=test@example.com",
    #remember_token: "vQcba4ROwn",
    created_at: "2025-05-08 04:45:48",
    updated_at: "2025-05-08 04:45:48",
  }

> $media = $user->getFirstMedia('avatar');
= Spatie\MediaLibrary\MediaCollections\Models\Media {#6538
    id: 1,
    model_type: "App\Models\User",
    model_id: 1,
    uuid: "096d5cf0-b7ae-43a0-b344-d578ca91e1a4",
    collection_name: "avatar",
    name: "idle",
    file_name: "idle.png",
    mime_type: "image/png",
    disk: "local",
    conversions_disk: "local",
    size: 373932,
    manipulations: "[]",
    custom_properties: "[]",
    generated_conversions: "[]",
    responsive_images: "[]",
    order_column: 1,
    created_at: "2025-05-10 06:29:08",
    updated_at: "2025-05-10 06:29:08",
    +original_url: "/storage/1/idle.png",
    +preview_url: "",
  }

> $media->toArray()
= [
    "id" => 1,
    "model_type" => "App\Models\User",
    "model_id" => 1,
    "uuid" => "096d5cf0-b7ae-43a0-b344-d578ca91e1a4",
    "collection_name" => "avatar",
    "name" => "idle",
    "file_name" => "idle.png",
    "mime_type" => "image/png",
    "disk" => "local",
    "conversions_disk" => "local",
    "size" => 373932,
    "manipulations" => [],
    "custom_properties" => [],
    "generated_conversions" => [],
    "responsive_images" => [],
    "order_column" => 1,
    "created_at" => "2025-05-10T06:29:08.000000Z",
    "updated_at" => "2025-05-10T06:29:08.000000Z",
    "original_url" => "/storage/1/idle.png",
    "preview_url" => "",
  ]

このように

$user = App\Models\User::find(1);

でユーザーオブジェクトを取得し

$media = $user->getFirstMedia('avatar');

これでmedia情報を取得しているさらに

$media->toArray()

これで配列化して情報を眺めていると、現在はそのような状態だ

保存したので戻す

とりあえずto_route()でやるだけ

    public function uploadAvatar(Request $request): RedirectResponse
    {
        $request->validate([
            'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,svg', 'max:2048'],
        ]);

        $user = $request->user();

        $user->addMediaFromRequest('avatar')
            ->toMediaCollection('avatar');

        return to_route('profile.edit'); // <--------------- これ
    }

with()を使ったsessionフラッシュはとりあえずここでは割愛

なお、2度アップロードされた挙動

今はidle.pngというのをアップロードしたがthinking.pngというのをさらにアップロードしたらどうなるかというと

storage/app/private/2/talking.png

このように新たにuploadされ、1は消滅する。DB情報もそのようになっているので確認してみる事をおすすめしたい。

保存されたファイル情報を扱う

authオブジェクトはavatarオブジェクトを参照できない

たとえばbladeなら{{ $auth->avatar->なんとか }} でいいんだろうけど、javascriptはもちろんそんな事できないので事前に挿入しておく必要がある。これはどこでやっているかというと

./app/Http/Middleware/HandleInertiaRequests.php
<?php
// <snip>
{
// <snip>
    public function share(Request $request): array
    {
        [$message, $author] = str(Inspiring::quotes()->random())->explode('-');

        return [
            ...parent::share($request),
            'name' => config('app.name'),
            'quote' => ['message' => trim($message), 'author' => trim($author)],
            'auth' => [
                'user' => $request->user(),
            ],
            'ziggy' => fn (): array => [
                ...(new Ziggy)->toArray(),
                'location' => $request->url(),
            ],
            'sidebarOpen' => $request->cookie('sidebar_state') === 'true',
        ];
    }
}

このようなshare()メソッドがあるので、この中でloadすればよい

$user = $request->user();

if ($user) {
    $user->load('media'); // mediaはSpatieのPolymorphic Relation
}

return [
    ...parent::share($request),
    'auth' => [
        'user' => $user,
    ],
];

みたいな。まあもうちょっとそれっぽく書くなら

'auth' => [
    'user' => tap($request->user(), fn ($user) =>
        $user?->load('media')
    ),
],

とか

フロントエンドでの表示

では、この時点でフロントエンドでこれがうまいこと表示できるか確認してみる。

resources/js/pages/settings/profile.tsx

{auth.avatar && (
  <div className="text-sm text-green-700 mt-2">
    アップロード成功:{auth.avatar.file_name}
  </div>
)}

こんなのを追加してアップロードしてみる


アップロード前


アップロード後

これにより正確にアップロードできた事がわかった

しかしファイル名だけ出ていても何なのか全くよくわからない

ということで最終的にここでは画像を表示させて終わりにするが、avatar表示ロジックとrouteを与える

routes/settings.php
    Route::post('settings/profile/avatar', [ProfileController::class, 'uploadAvatar'])->name('profile.avatar.upload');
    // ↓ 追加
    Route::get('settings/profile/avatar', [ProfileController::class, 'showAvatar'])->name('profile.avatar.show');

こんな形で追加したらurlに直接アクセスしてみる

コントローラーの中にshowAvatarのmethodを書いていく

app/Http/Controllers/Settings/ProfileController.php
  use Symfony\Component\HttpFoundation\BinaryFileResponse;
// ...

    public function showAvatar(): BinaryFileResponse
    {
        $user = auth()->user();
        $avatar = $user->getFirstMedia('avatar');

        if (!$avatar) {
            abort(404);
        }

        return response()->file($avatar->getPath(), [
            'Content-Type' => $avatar->mime_type,
            'Content-Disposition' => 'inline; filename="' . $avatar->file_name . '"',
        ]);
    }

ファイル名を出すか出さないかは割とセンシティブに関わるところなので考えなくてはいけないかもしれないが、これにより以下のように正しく画像が出力されるはずだ。


avatar.png

avatarフィールドにセットする

前回見てきたように、基本的にこのstarter kitは表示可能なurlをavatarフィールドにセットすればよいという作りであった。従って現在画像を出力できているため、このフィールドをユーザーのavatarに挿入していく。なお、保存時にavatarへのカラムを挿入するとユーザー一覧とかでの管理でも使えるので、こっちの方がよいかもしれないね。

routeを作り直す

現在の設計は

    Route::get('settings/profile/avatar', [ProfileController::class, 'showAvatar'])->name('profile.avatar.show');

となっており、ログインユーザーのみのavatarを返却する、が、ユーザー一覧などでもつかいたい場合はUserコントローラーに移動するべきだろう。そこで以下のように変更する事にした

routes/web.php
@@ -2,6 +2,7 @@

 use Illuminate\Support\Facades\Route;
 use Inertia\Inertia;
+use App\Http\Controllers\UserController;

 Route::get('/', function () {
     return Inertia::render('welcome');
@@ -11,6 +12,8 @@
     Route::get('dashboard', function () {
         return Inertia::render('dashboard');
     })->name('dashboard');
+
+    Route::get('/users/{user}/avatar', [UserController::class, 'showAvatar'])->name('users.avatar.show');
 });

 require __DIR__.'/settings.php';

UserControllerを作成する

php artisan make:controller UserController --model=User -r

   INFO  Controller [app/Http/Controllers/UserController.php] created successfully.

一応Userモデルを指定し、リソースフルコントローラーとした

ここにshowAvatar()を移動している

app/Http/Controllers/UserController.php
// ...
    public function showAvatar(User $user): BinaryFileResponse
    {
        $avatar = $user->getFirstMedia('avatar');

        if (!$avatar) {
            abort(404);
        }

        return response()->file($avatar->getPath(), [
            'Content-Type' => $avatar->mime_type,
            'Content-Disposition' => 'inline; filename="' . $avatar->file_name . '"',
        ]);
    }

パラメーターとしてuidを受けとるようになっている

avatar保存時にavatarカラムにurlをつっこむ

今現在

app/Http/Controllers/Settings/ProfileController.php

    public function uploadAvatar(Request $request): RedirectResponse
    {
        $request->validate([
            'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,svg', 'max:2048'],
        ]);

        $user = $request->user();

        $user->addMediaFromRequest('avatar')
            ->toMediaCollection('avatar');

        return to_route('profile.edit');
    }

このように保存しているが、保存した後でurlを生成する

    public function uploadAvatar(Request $request): RedirectResponse
    {
        $request->validate([
            'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,svg', 'max:2048'],
        ]);

        $user = $request->user();

        $media = $user->addMediaFromRequest('avatar')
            ->toMediaCollection('avatar');
        $avatarUrl = route('users.avatar.show', auth()->id(), false);
        dd($avatarUrl);

フルurlすると管理が面倒なので第二引数にfalseを与えるすると

このように取れてくるので、これを保存しよう。

    public function uploadAvatar(Request $request): RedirectResponse
    {
        $request->validate([
            'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,svg', 'max:2048'],
        ]);

        $user = $request->user();

        $media = $user->addMediaFromRequest('avatar')
            ->toMediaCollection('avatar');
        $avatarUrl = route('users.avatar.show', auth()->id(), false);
        $user->avatar = $avatarUrl;
        $user->save();

        return to_route('profile.edit');
    }

これで根本的にはokなのだがブラウザーキャッシュが効いてしまいavatarが更新されない問題に対応するため以下のように?v=<タイムスタンプ>のようなurlにするとよいだろう。

$avatarUrl = route('users.avatar.show', auth()->id(), false). '?v=' .   now()->timestamp;

Profile画面をもうちょっとまともにする

今現在

こうなっており、さすがにこれじゃ何なのか意味がわからんので、何とかする。これは簡単にやるにはuser-infoコンポーネントを使うといい

resources/js/pages/settings/profile.tsx
import { UserInfo } from '@/components/user-info';
// ...
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
  <UserInfo user={auth.user} showEmail={true} />
</div>

fallbackでinitialを表示してくれるため、条件分岐も最早必要がない。

ここまでの完成系


avatarなし


avatar1


avatar2

さて、avatar画像が小さいとかいう問題があるが、これはuser-infoを拡張するとかしないと難しい。いずれにせよ、基本的な機能は既に整ったので次回はuiの部分でもうちょっとsmartにしていこう。

Discussion