目次
- 1. 目的
- 2. 実装前の状態
- 3. 実装方針
- 4. ProfileUpdateRequestのバリデーション
- 5. ProfileController::update()の理解と実装
- 6. 退会処理で発生した重要な問題
- 7. Userモデルのアクセサ
- 8. Bladeコンポーネント化
- 9. 本人レビュー一覧での配置判断
- 10. 保存前プレビュー
- 11. ファイル入力のアクセシビリティ
- 12. モバイルナビゲーション
- 13. 手動確認
- 14. 今回学んだこと
- 使用したクラス・メソッド一覧
- Bladeコンポーネント・ディレクティブ
- Alpine.js
1. 目的
Laravel 10 + Laravel Breezeで構築している映画レビューアプリへ、ログインユーザーがアカウント編集画面からユーザーアイコンを登録・差し替えできる機能を追加した。
今回の実装では、単純なファイルアップロードだけでなく、次の点まで扱った。
- JPEG / PNG / WebPのみ許可
- 最大2MB
-
publicディスクのavatars配下へ保存 - DBには公開URLではなく相対パスを保存
- 新画像保存 → DB更新 → 旧画像削除
- DB更新失敗時の新画像削除
- 旧画像削除失敗時のログ記録
- 退会時のユーザー固有画像削除
- 未設定・退会済み・実ファイル不存在時のNo Image表示
- Bladeコンポーネントによる表示共通化
- 保存前プレビュー
- PC・モバイルナビゲーションを含む複数画面への表示
- ファイル入力とエラーメッセージのARIA関連付け
- CodexとClaude Codeによる二重レビュー
2. 実装前の状態
usersテーブルには、すでにavatar_pathカラムが存在していた。
ただし、次の機能は未実装だった。
- ファイルアップロード
- 画像形式・サイズのバリデーション
- 画像の保存
- DBへの相対パス保存
- 差し替え時の旧画像削除
- 退会時の画像削除
- No Image表示
- 各画面への表示
- Feature Test
また、MVP1では次を対象外とした。
- 画像単独削除
- 「No Imageへ戻す」機能
- 画像リサイズ
- トリミング
- WebPへの自動変換
- EXIF除去
- 画像最適化
画像最適化はIssue #61へ分離した。
3. 実装方針
保存先
ユーザーアイコンはLaravelのpublicディスクを使い、次へ保存する。
storage/app/public/avatars
storage/app/public配下のファイルをブラウザから表示するには、storage:linkで公開用のシンボリックリンクを作成する。
シンボリックリンクを作成する
次のコマンドを実行する。
sail artisan storage:link
実行結果:
INFO The [public/storage] link has been connected to [storage/app/public].
これにより、次のシンボリックリンクが作成される。
public/storage
→ storage/app/public
DBへ保存する値
DBのavatar_pathには、公開URLではなくpublicディスク内の相対パスを保存する。
avatars/ランダムファイル名.jpg
差し替え順序
差し替え時は次の順序とした。
新画像を保存
↓
DBのavatar_pathを更新
↓
DB更新成功後に旧画像を削除
理由は、旧画像を先に消してからDB更新に失敗すると、DBに旧画像のパスだけが残る不整合が起こるため。
4. ProfileUpdateRequestのバリデーション
最終的なルールは次の形になった。
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)
->ignore($this->user()->id)],
'profile' => ['nullable', 'string', 'max:1000'],
'avatar_image' => [
'bail',
'nullable',
'image',
'mimes:jpg,jpeg,png,webp',
'max:2048',
],
];
}
}
bailを追加した理由
imageとmimesを併用すると、PDFやテキストファイルを選んだ場合に、同じ原因に対して複数のエラーメッセージが表示される可能性があった。
bailを先頭に追加し、最初に失敗したルールでその項目の検証を止めるようにした。
カスタムメッセージ
lang/ja/validation.phpへ、項目固有のメッセージを追加した。
'avatar_image' => [
'mimes' => 'アップロードできる画像の形式はJPEG、PNG、WebPです。',
'max' => 'アップロードする画像の容量は2MB以下にしてください。',
'uploaded' => 'ユーザーアイコンをアップロードできませんでした。容量が2MB以下か確認してください。',
],
uploadedは容量超過だけでなく、通信中断や書き込み失敗などでも発生し得るため、容量だけを断定しない文言へ修正した。
5. ProfileController::update()の理解と実装
ログインユーザーの取得
$user = $request->user();
ProfileUpdateRequestから、現在ログインしているUserモデルのインスタンスを取得している。
PHPではJavaのように変数宣言時の型がコード上に現れないため、最初は$userの実体が分かりにくかった。
既存画像と新画像のパスを保持
$oldAvatarPath = $user->avatar_path;
$newAvatarPath = null;
-
$oldAvatarPath:差し替え前の既存画像パス -
$newAvatarPath:今回新たに保存した画像パス
一括代入から画像を除外
$user->fill(
$request->safe()->except('avatar_image')
);
safe()でバリデーション済みの値だけを取得し、except('avatar_image')でアップロードファイルを除外した。
アップロードファイル自体を一括代入せず、保存後に返された相対パスだけをavatar_pathへ設定する。
ファイルの存在確認
if ($request->hasFile('avatar_image')) {
avatar_imageにアップロードされたファイルがある場合だけ、保存処理へ進む。
ファイル取得
$avatarImage = $request->file('avatar_image');
アップロードされたファイルを取得する。
保存
$newAvatarPath = $avatarImage->store('avatars', 'public');
- 第1引数:保存先ディレクトリ
- 第2引数:使用するディスク
- 戻り値:
publicディスク内の相対パス
例:
avatars/kdfxYkohQNPAyDRbcRCJmdjpQI7wXCdqjmUDAOJU.jpg
保存失敗時
if ($newAvatarPath === false) {
throw new \RuntimeException('ユーザーアイコンの保存に失敗しました。');
}
保存に失敗した場合は例外を投げる。
Userモデルへ新しい相対パスを設定
$user->avatar_path = $newAvatarPath;
アップロードしたファイルそのものではなく、保存後の相対パスを設定する。
メールアドレス変更時
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
メールアドレスが変更されていた場合、以前のメール認証結果を無効にする。
DB更新失敗時の補償処理
try {
if (! $user->save()) {
throw new \RuntimeException('アカウント情報の更新に失敗しました。');
}
} catch (\Throwable $e) {
if ($newAvatarPath !== null) {
try {
$deleted = Storage::disk('public')->delete($newAvatarPath);
if (! $deleted) {
report(new \RuntimeException(
'DB更新失敗後の新ユーザーアイコン削除に失敗しました。'
));
}
} catch (\Throwable $cleanupException) {
report($cleanupException);
}
}
throw $e;
}
DB更新に失敗した場合、新しく保存した画像を削除し、旧状態を維持する。
クリーンアップ側で例外が発生しても、元のDB更新例外を上書きしないようにしている。
DB更新成功後の旧画像削除
if (
$newAvatarPath !== null
&& is_string($oldAvatarPath)
&& str_starts_with($oldAvatarPath, 'avatars/')
&& $oldAvatarPath !== $newAvatarPath
) {
try {
$deleted = Storage::disk('public')->delete($oldAvatarPath);
if (! $deleted) {
report(new \RuntimeException(
'旧ユーザーアイコンの削除に失敗しました。'
));
}
} catch (\Throwable $e) {
report($e);
}
}
旧画像削除に失敗しても、DB更新と新画像表示は成功扱いとした。
画像削除失敗で更新全体を失敗扱いにすると、DB上は新画像へ切り替わっているのに、利用者へエラー表示される不自然な状態になるため。
6. 退会処理で発生した重要な問題
最初は、退会処理で次の順序を採用した。
$user->delete();
Auth::logout();
これはDB削除成功後にログアウトするため、一見すると整合性が高いように見えた。
しかしClaude Codeのレビューで、remember_tokenが残っている場合、Auth::logout()内部で削除済みのUserモデルが再保存され、usersレコードが再INSERTされる可能性が指摘された。
修正後
if (! $user->delete()) {
throw new \RuntimeException('退会処理に失敗しました。');
}
// logout時に削除済みUserのremember_tokenが更新され、再保存されることを防ぐ
$user->setRememberToken(null);
Auth::logout();
処理順は次のとおり。
DB削除成功を確認
↓
メモリ上のremember_tokenをnullへ変更
↓
Auth::logout()
↓
ユーザー固有画像の削除を試みる
↓
セッション無効化
今回の実装で最も重要なレビュー指摘だった。
7. Userモデルのアクセサ
各Bladeで毎回条件分岐を書く代わりに、Userモデルへ表示用URLを返すアクセサを追加した。
/**
* 表示用のユーザーアイコンURLを取得する。
*/
protected function avatarUrl(): Attribute
{
return Attribute::make(
get: function (): string {
if (
is_string($this->avatar_path)
&& str_starts_with($this->avatar_path, 'avatars/')
&& Storage::disk('public')->exists($this->avatar_path)
) {
return Storage::disk('public')->url($this->avatar_path);
}
return asset('images/no-image.png');
},
);
}
アクセサで行っていること
avatar_pathが文字列
かつ
avatars/から始まる
かつ
実ファイルが存在する
↓
/storage/avatars/xxx.jpg を返す
それ以外は、
/images/no-image.png
を返す。
学んだこと
アクセサは、DBから取得した値をそのまま返すのではなく、画面で使いやすい形へ加工して返せる。
今回の場合、DB上の相対パスから表示用URLを作成し、ファイル不存在時のフォールバックもまとめている。
8. Bladeコンポーネント化
アバター表示を共通化するため、次のコンポーネントを作成した。
@props([
'user' => null,
'alt' => '',
])
@php
$avatarUrl = $user?->avatar_url ?? asset('images/no-image.png');
@endphp
<img
src="{{ $avatarUrl }}"
alt="{{ $alt }}"
{{ $attributes->class([
'shrink-0 rounded-full object-cover',
]) }}
>
shrink-0を追加した理由
長いニックネームと横並びになった場合、flex要素の画像が横方向に縮んで楕円になる可能性があった。
共通コンポーネントへshrink-0を追加し、すべての表示箇所へ一括で反映した。
表示箇所
- PCナビゲーション
- モバイルナビゲーション
- アカウント編集画面
- 作品詳細のレビュー投稿者
- レビュー返信投稿者
- 本人レビュー一覧
Issue本文では「ナビゲーション」を1か所として数えているが、実装上はPCとモバイルの両方に対応した。
9. 本人レビュー一覧での配置判断
本人レビュー一覧は、同じ本人のレビューだけが並ぶ画面である。
各レビューカードへ同じアイコンを繰り返すと冗長になるため、一覧上部の見出し部分へ1回だけ表示した。
<div class="flex items-center gap-4">
<x-user-avatar
:user="Auth::user()"
alt=""
class="h-12 w-12"
/>
<div>
<h2 class="text-lg font-bold text-slate-900">
レビュー履歴
</h2>
<p class="mt-1 text-sm text-slate-500">
自分が投稿したレビューだけを新しい順に表示しています。
</p>
</div>
</div>
躓いた点
最初、作品詳細画面で使用していた変数をそのままコピーし、次のようにしてしまった。
:user="$comment->user"
本人レビュー一覧の見出し部分には$commentが存在しないため、
Undefined variable $comment
が発生した。
本人専用画面なので、次へ修正した。
:user="Auth::user()"
10. 保存前プレビュー
ファイル選択後、保存ボタンを押すまで選択画像が分からなかったため、Alpine.jsで保存前プレビューを追加した。
<div
x-data="{
previewUrl: null,
updatePreview(event) {
const file = event.target.files[0];
if (! file) {
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = null;
return;
}
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = URL.createObjectURL(file);
}
}"
>
表示部分は、プレビューURLがある場合とない場合で切り替えた。
<div class="mb-3 mt-2">
<template x-if="previewUrl">
<img
:src="previewUrl"
alt="選択したユーザーアイコンのプレビュー"
class="h-20 w-20 rounded-full object-cover"
>
</template>
<template x-if="! previewUrl">
<x-user-avatar
:user="$user"
alt="現在のユーザーアイコン"
class="h-20 w-20"
/>
</template>
</div>
ファイル入力側:
<input
id="avatar_image"
name="avatar_image"
type="file"
accept=".jpg,.jpeg,.png,.webp"
@change="updatePreview($event)"
>
躓いた点
最初、previewUrlとupdatePreview()を使うコードだけを追加し、親要素のx-dataを付け忘れた。
そのため、Alpine.jsが変数とメソッドを解決できず、画像表示が崩れた。
Object URLの解放
最初は別画像を選び直した場合だけURL.revokeObjectURL()を呼んでいた。
Claudeレビューで、ファイル選択を解除した場合も解放すべきと指摘され、次を追加した。
if (! file) {
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = null;
return;
}
11. ファイル入力のアクセシビリティ
入力エラーをスクリーンリーダーへ伝えられるようにした。
<input
id="avatar_image"
name="avatar_image"
type="file"
accept=".jpg,.jpeg,.png,.webp"
@change="updatePreview($event)"
@error('avatar_image')
aria-invalid="true"
aria-describedby="avatar-image-error"
@enderror
>
エラー要素:
<x-input-error
id="avatar-image-error"
class="mt-2"
:messages="$errors->get('avatar_image')"
/>
躓いた点
最初、次のスペルミスをした。
@error('abatar_image')
正しくは次。
@error('avatar_image')
また、@enderrorを2つ書いてしまい、1つ削除した。
12. モバイルナビゲーション
最初はPCナビゲーションだけにアバターを追加していた。
Codexレビューで、スマートフォンではPC側が非表示になるため、モバイルナビゲーションにも表示が必要と指摘された。
ログイン時メニューへ、アイコンとユーザー名を追加した。
<div class="flex items-center gap-3 rounded-2xl bg-slate-50 px-4 py-3">
<x-user-avatar
:user="Auth::user()"
alt=""
class="h-10 w-10"
/>
<span class="min-w-0 truncate text-sm font-semibold text-slate-700">
{{ Auth::user()->name }}
</span>
</div>
Bladeが長くなり、PC用とモバイル用の境界が分かりにくかったため、区切りコメントも追加した。
{{-- ==================== PCナビゲーション:開始 ==================== --}}
{{-- ==================== PCナビゲーション:終了 ==================== --}}
{{-- ==================== モバイルナビゲーション:開始 ==================== --}}
{{-- ==================== モバイルナビゲーション:終了 ==================== --}}
13. 手動確認
初回登録
- アカウント画面へ反映
- PCナビゲーションへ反映
- 作品詳細のレビュー投稿者へ反映
- 返信投稿者へ反映
- 本人レビュー一覧へ反映
保存前プレビュー
ファイル選択後はアカウント画面のプレビューだけが新画像へ変わり、保存前なのでナビゲーションは旧画像のままだった。
保存後、両方が新画像へ切り替わった。
差し替え
別画像へ差し替え後、次を確認した。
ls storage/app/public/avatars/
結果、新しい画像だけが1件残り、旧画像が削除されていた。
躓いた点
最初に次のコマンドを実行した。
ls /storage/app/public/avatars/...
先頭の/があるため、Linuxルート直下の/storageを探してしまい、ファイルが見つからなかった。
正しくはプロジェクトルートからの相対パス。
ls storage/app/public/avatars/
14. 今回学んだこと
ファイルアップロードは保存だけでは終わらない
実アプリでは、次まで考える必要がある。
- バリデーション
- 保存先
- 保存名
- DBへ保存する値
- 差し替え順序
- 失敗時の補償処理
- 旧画像削除
- 退会時削除
- No Image
- 複数画面での共通表示
- レスポンシブ対応
- アクセシビリティ
- テスト
DBとファイルは同じトランザクションで扱えない
DBトランザクションでファイル操作そのものはロールバックできない。
そのため、失敗時にどの状態を正とするかを決め、補償処理を実装する必要がある。
Laravel内部実装まで確認する価値
delete()とlogout()の順序は、アプリコードだけを見ると問題が分かりにくかった。
Claude CodeがSessionGuardやEloquentUserProviderまで確認したことで、remember_tokenによる再INSERTの可能性を発見できた。
コンポーネント化とアクセサで責務を分離できる
- Userモデル:表示URLを決める
- Bladeコンポーネント:画像タグと共通見た目を決める
- 各画面:どこに表示するかだけを書く
使用したクラス・メソッド一覧
| クラス | メソッド | 用途 |
|---|---|---|
ProfileUpdateRequest |
rules() |
アイコン画像のバリデーション |
ProfileController |
update() |
アイコンの保存・差し替え |
ProfileController |
destroy() |
退会時の画像削除 |
User |
avatarUrl() |
表示用URLを返すアクセサ |
UploadedFile |
store() |
publicディスクへ画像保存 |
Storage |
disk() |
使用するストレージディスク取得 |
Storage |
delete() |
新画像・旧画像の削除 |
Storage |
exists() |
実ファイル存在確認 |
Storage |
url() |
表示用URL生成 |
Request |
hasFile() |
ファイルが送信されたか判定 |
Request |
file() |
UploadedFile取得 |
Request |
safe() |
バリデーション済み入力取得 |
Collection |
except() |
avatar_imageを除外 |
Model |
fill() |
一括代入 |
Model |
isDirty() |
メールアドレス変更確認 |
Model |
save() |
DB更新 |
Model |
delete() |
会員退会 |
Authenticatable |
setRememberToken() |
logout時の再保存防止 |
Auth |
logout() |
ログアウト |
URL |
createObjectURL() |
保存前プレビュー生成 |
URL |
revokeObjectURL() |
Object URL解放 |
Bladeコンポーネント・ディレクティブ
| 機能 | 使用したもの |
|---|---|
| アバター表示 | <x-user-avatar> |
| エラー表示 | <x-input-error> |
| ラベル | <x-input-label> |
| バリデーション | @error |
| プロパティ | @props |
Alpine.js
| 機能 | 内容 |
|---|---|
x-data |
プレビュー状態管理 |
x-if |
プレビューと現在画像の切り替え |
@change |
ファイル選択時のイベント |
previewUrl |
Object URL保持 |
updatePreview() |
プレビュー更新 |
