laravel12 starter kit(+react)でavatar アップロードを作る
の続き
laravel12のスターターキットデフォルトではこの通り、avatarのカラムを想定しているにもかかわらず、当該項目の管理など何も考えられていない為、諸々uiが欠落している
ここではまず、非常にベタなアプローチでここにアップロード機能を追加する方法を解説してみる。
前提
users
テーブルにavatar
カラムを追加(詳細は前回の記事を参照)
ここで開発するもの
こんな機能
何はともあれアップロードされたavatarファイルをcontrollerで受けとらなくてはいけない
現状のlaravelのdefaultスキーマーではavatarすら保存できないのであるが、それは後で考えるとして、何はともあれcontrollerでavatar画像ファイルを受けとる必要があるかと思う。
受けとるrouteを定義
starter kitのroutes/web.phpを見てみると
// <snip>
require __DIR__.'/settings.php';
require __DIR__.'/auth.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でやるのがよさそうだという事で、
@@ -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
つのはベタなもんなんです、ってことで
@@ -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')} />
これにより
こういうのが追加された。とりあえずわかりやすいようにこの部分は多言語にしていない。
受信する処理
まあとりあえず
@@ -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
を利用する事とする。これに関して基本的な事は
ここに書いてあるから必要に応じて見ていただいて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モデルを拡張
こんな感じで拡張
@@ -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に定義する必要がある。
@@ -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はもちろんそんな事できないので事前に挿入しておく必要がある。これはどこでやっているかというと
<?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を与える
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を書いていく
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コントローラーに移動するべきだろう。そこで以下のように変更する事にした
@@ -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()
を移動している
// ...
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をつっこむ
今現在
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コンポーネントを使うといい
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