2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Laravel Breezeでユーザーアイコンのアップロード・差し替え機能を実装する

2
Posted at

目次

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

storage-link-command.png

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を追加した理由

imagemimesを併用すると、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)"
>

躓いた点

最初、previewUrlupdatePreview()を使うコードだけを追加し、親要素の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がSessionGuardEloquentUserProviderまで確認したことで、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() プレビュー更新
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?