diff --git a/app/Console/Commands/AvatarsMigrate.php b/app/Console/Commands/AvatarsMigrate.php
index 45f893d..6267e3c 100644
--- a/app/Console/Commands/AvatarsMigrate.php
+++ b/app/Console/Commands/AvatarsMigrate.php
@@ -2,82 +2,124 @@

 namespace App\Console\Commands;

+use App\Services\AvatarService;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\DB;
-use App\Services\AvatarService;

 class AvatarsMigrate extends Command
 {
-    /**
-     * The name and signature of the console command.
-     *
-     * @var string
-     */
-    protected $signature = 'avatars:migrate {--force}';
-
-    /**
-     * The console command description.
-     *
-     * @var string
-     */
-    protected $description = 'Migrate legacy avatars to new WebP avatar storage';
-
-    protected $service;
-
-    public function __construct(AvatarService $service)
+    protected $signature = 'avatars:migrate {--force : Reprocess avatars even when avatar_hash is already present} {--limit=0 : Limit number of users processed}';
+
+    protected $description = 'Migrate legacy avatars into CDN-ready WebP variants and avatar_hash metadata';
+
+    public function __construct(private readonly AvatarService $service)
     {
         parent::__construct();
-        $this->service = $service;
     }

-    public function handle()
+    public function handle(): int
     {
+        $force = (bool) $this->option('force');
+        $limit = max(0, (int) $this->option('limit'));
+
         $this->info('Starting avatar migration...');

-        // Try to read legacy data from user_profiles.avatar_legacy or users.avatar_legacy or users.icon
-        $rows = DB::table('user_profiles')->select('user_id', 'avatar_legacy')->whereNotNull('avatar_legacy')->get();
+        $rows = DB::table('user_profiles as p')
+            ->leftJoin('users as u', 'u.id', '=', 'p.user_id')
+            ->select([
+                'p.user_id',
+                'p.avatar_hash',
+                'p.avatar_legacy',
+                'u.icon as user_icon',
+            ])
+            ->when(!$force, fn ($query) => $query->whereNull('p.avatar_hash'))
+            ->where(function ($query) {
+                $query->whereNotNull('p.avatar_legacy')
+                    ->orWhereNotNull('u.icon');
+            })
+            ->orderBy('p.user_id')
+            ->when($limit > 0, fn ($query) => $query->limit($limit))
+            ->get();

         if ($rows->isEmpty()) {
-            // fallback to users table
-            $rows = DB::table('users')->select('user_id', 'icon as avatar_legacy')->whereNotNull('icon')->get();
+            $this->info('No avatars require migration.');
+
+            return self::SUCCESS;
         }

-        $count = 0;
+        $migrated = 0;
+        $skipped = 0;
+        $failed = 0;
+
         foreach ($rows as $row) {
-            $userId = $row->user_id;
-            $legacy = $row->avatar_legacy ?? null;
-            if (!$legacy) {
+            $userId = (int) $row->user_id;
+            $legacyName = $this->normalizeLegacyName($row->avatar_legacy ?: $row->user_icon);
+
+            if ($legacyName === null) {
+                $skipped++;
+                continue;
+            }
+
+            $path = $this->locateLegacyAvatarPath($userId, $legacyName);
+            if ($path === null) {
+                $failed++;
+                $this->warn("User {$userId}: legacy avatar not found ({$legacyName})");
                 continue;
             }

-            // Try common legacy paths
-            $candidates = [
-                public_path('user-picture/' . $legacy),
-                public_path('avatar/' . $userId . '/' . $legacy),
-                storage_path('app/public/user-picture/' . $legacy),
-                storage_path('app/public/avatar/' . $userId . '/' . $legacy),
-            ];
-
-            $found = false;
-            foreach ($candidates as $p) {
-                if (file_exists($p) && is_readable($p)) {
-                    $this->info("Processing user {$userId} from {$p}");
-                    $hash = $this->service->storeFromLegacyFile($userId, $p);
-                    if ($hash) {
-                        $this->info(" -> migrated, hash={$hash}");
-                        $count++;
-                        $found = true;
-                        break;
-                    }
+            try {
+                $hash = $this->service->storeFromLegacyFile($userId, $path);
+                if (!$hash) {
+                    $failed++;
+                    $this->warn("User {$userId}: unable to process legacy avatar ({$legacyName})");
+                    continue;
                 }
+
+                $migrated++;
+                $this->line("User {$userId}: migrated ({$hash})");
+            } catch (\Throwable $e) {
+                $failed++;
+                $this->warn("User {$userId}: migration failed ({$e->getMessage()})");
             }
+        }
+
+        $this->info("Avatar migration complete. Migrated={$migrated}, Skipped={$skipped}, Failed={$failed}");
+
+        return $failed > 0 ? self::FAILURE : self::SUCCESS;
+    }
+
+    private function normalizeLegacyName(?string $value): ?string
+    {
+        if (!$value) {
+            return null;
+        }
+
+        $trimmed = trim($value);
+        if ($trimmed === '') {
+            return null;
+        }
+
+        return basename(urldecode($trimmed));
+    }
+
+    private function locateLegacyAvatarPath(int $userId, string $legacyName): ?string
+    {
+        $candidates = [
+            public_path('avatar/' . $legacyName),
+            public_path('avatar/' . $userId . '/' . $legacyName),
+            public_path('user-picture/' . $legacyName),
+            storage_path('app/public/avatar/' . $legacyName),
+            storage_path('app/public/avatar/' . $userId . '/' . $legacyName),
+            storage_path('app/public/user-picture/' . $legacyName),
+            base_path('oldSite/www/files/usericons/' . $legacyName),
+        ];

-            if (!$found) {
-                $this->warn("Legacy file not found for user {$userId}, filename={$legacy}");
+        foreach ($candidates as $candidate) {
+            if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) {
+                return $candidate;
             }
         }

-        $this->info("Migration complete. Processed: {$count}");
-        return 0;
+        return null;
     }
 }
diff --git a/app/Http/Controllers/AvatarController.php b/app/Http/Controllers/AvatarController.php
index b80150d..f6c1c23 100644
--- a/app/Http/Controllers/AvatarController.php
+++ b/app/Http/Controllers/AvatarController.php
@@ -2,10 +2,9 @@

 namespace App\Http\Controllers;

+use App\Http\Requests\AvatarUploadRequest;
 use App\Services\AvatarService;
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Validator;
+use Illuminate\Http\JsonResponse;

 class AvatarController
 {
@@ -19,22 +18,13 @@ public function __construct(AvatarService $service)
     /**
      * Handle avatar upload request.
      */
-    public function upload(Request $request)
+    public function upload(AvatarUploadRequest $request): JsonResponse
     {
-        $user = Auth::user();
+        $user = $request->user();
         if (!$user) {
             return response()->json(['error' => 'Unauthorized'], 401);
         }

-        $rules = [
-            'avatar' => 'required|image|max:2048|mimes:jpg,jpeg,png,webp',
-        ];
-
-        $validator = Validator::make($request->all(), $rules);
-        if ($validator->fails()) {
-            return response()->json(['errors' => $validator->errors()], 422);
-        }
-
         $file = $request->file('avatar');

         try {
diff --git a/app/Http/Controllers/Legacy/AvatarController.php b/app/Http/Controllers/Legacy/AvatarController.php
index eb322a0..391c514 100644
--- a/app/Http/Controllers/Legacy/AvatarController.php
+++ b/app/Http/Controllers/Legacy/AvatarController.php
@@ -4,64 +4,15 @@

 use App\Http\Controllers\Controller;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Storage;
+use App\Support\AvatarUrl;

 class AvatarController extends Controller
 {
     public function show(Request $request, $id, $name = null)
     {
-        $user_id = (int) $id;
+        $userId = (int) $id;
+        $target = AvatarUrl::forUser($userId, null, 128);

-        // default avatar in project public gfx
-        $defaultAvatar = public_path('gfx/avatar.jpg');
-
-        try {
-            $icon = DB::table('users')->where('user_id', $user_id)->value('icon');
-        } catch (\Throwable $e) {
-            $icon = null;
-        }
-
-        $candidates = [];
-        if (!empty($icon)) {
-            // common legacy locations to check
-            $candidates[] = base_path('oldSite/www/files/usericons/' . $icon);
-            $candidates[] = base_path('oldSite/www/files/usericons/' . rawurlencode($icon));
-            $candidates[] = base_path('oldSite/www/files/usericons/' . basename($icon));
-            $candidates[] = public_path('avatar/' . $user_id . '/' . $icon);
-            $candidates[] = public_path('avatar/' . $user_id . '/' . basename($icon));
-            $candidates[] = storage_path('app/public/usericons/' . $icon);
-            $candidates[] = storage_path('app/public/usericons/' . basename($icon));
-        }
-
-        // find first readable file
-        $found = null;
-        foreach ($candidates as $path) {
-            if ($path && file_exists($path) && is_readable($path)) {
-                $found = $path;
-                break;
-            }
-        }
-
-        if ($found) {
-            $type = @exif_imagetype($found);
-            if ($type) {
-                $mime = image_type_to_mime_type($type);
-            } else {
-                $f = finfo_open(FILEINFO_MIME_TYPE);
-                $mime = finfo_file($f, $found) ?: 'application/octet-stream';
-                finfo_close($f);
-            }
-
-            return response()->file($found, ['Content-Type' => $mime]);
-        }
-
-        // fallback to default
-        if (file_exists($defaultAvatar) && is_readable($defaultAvatar)) {
-            return response()->file($defaultAvatar, ['Content-Type' => 'image/jpeg']);
-        }
-
-        // final fallback: 404
-        abort(404);
+        return redirect()->away($target, 301);
     }
 }
diff --git a/app/Http/Controllers/Legacy/BuddiesController.php b/app/Http/Controllers/Legacy/BuddiesController.php
index cfb3b04..d1ec9cc 100644
--- a/app/Http/Controllers/Legacy/BuddiesController.php
+++ b/app/Http/Controllers/Legacy/BuddiesController.php
@@ -22,7 +22,7 @@ public function index(Request $request)
                 ->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
                 ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
                 ->where('t1.friend_id', $user->id)
-                ->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar as icon', 't1.date_added')
+                ->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
                 ->orderByDesc('t1.date_added');

             $followers = $query->paginate($perPage)->withQueryString();
diff --git a/app/Http/Controllers/Legacy/LatestCommentsController.php b/app/Http/Controllers/Legacy/LatestCommentsController.php
index eb091e2..02db175 100644
--- a/app/Http/Controllers/Legacy/LatestCommentsController.php
+++ b/app/Http/Controllers/Legacy/LatestCommentsController.php
@@ -7,6 +7,7 @@
 use App\Models\ArtworkComment;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
+use Illuminate\Support\Facades\DB;

 class LatestCommentsController extends Controller
 {
@@ -36,7 +37,7 @@ public function index(Request $request)
                 'comment_description' => $c->content,
                 'commenter_id' => $c->user_id,
                 'country' => $user->country ?? null,
-                'icon' => $user->avatar ?? null,
+                'icon' => $user ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : null,
                 'uname' => $user->username ?? $user->name ?? 'User',
                 'signature' => $user->signature ?? null,
                 'user_type' => $user->role ?? null,
diff --git a/app/Http/Controllers/Legacy/MyBuddiesController.php b/app/Http/Controllers/Legacy/MyBuddiesController.php
index 3ff2f96..9ab8db4 100644
--- a/app/Http/Controllers/Legacy/MyBuddiesController.php
+++ b/app/Http/Controllers/Legacy/MyBuddiesController.php
@@ -23,7 +23,7 @@ public function index(Request $request)
                 ->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
                 ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
                 ->where('t1.user_id', $user->id)
-                ->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar as icon', 't1.date_added')
+                ->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
                 ->orderByDesc('t1.date_added');

             $buddies = $query->paginate($perPage)->withQueryString();
diff --git a/app/Http/Controllers/Legacy/ProfileController.php b/app/Http/Controllers/Legacy/ProfileController.php
index dfe911c..5d3b149 100644
--- a/app/Http/Controllers/Legacy/ProfileController.php
+++ b/app/Http/Controllers/Legacy/ProfileController.php
@@ -10,6 +10,7 @@
 use App\Models\Artwork;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;

 class ProfileController extends Controller
 {
@@ -63,7 +64,7 @@ public function show(Request $request, ?int $id = null, ?string $slug = null)
             'user_id' => $user->id,
             'uname' => $user->name,
             'real_name' => $user->name,
-            'icon' => $user->avatar ?? null,
+            'icon' => DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash'),
             'about_me' => $user->bio ?? null,
         ];

diff --git a/app/Http/Controllers/Legacy/UserController.php b/app/Http/Controllers/Legacy/UserController.php
index a8a3546..e7286a8 100644
--- a/app/Http/Controllers/Legacy/UserController.php
+++ b/app/Http/Controllers/Legacy/UserController.php
@@ -4,11 +4,10 @@

 use App\Http\Controllers\Controller;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
-use App\Models\User;
+use App\Services\AvatarService;
 use Carbon\Carbon;

 class UserController extends Controller
@@ -72,12 +71,12 @@ public function index(Request $request)

                 // Files: avatar/photo/emoticon
                 if ($request->hasFile('avatar')) {
-                    $f = $request->file('avatar');
-                    $name = $user->id . '.' . $f->getClientOriginalExtension();
-                    $f->move(public_path('avatar'), $name);
-                    // store filename in profile avatar (legacy field) ÔÇö modern avatar pipeline will later migrate
-                    $profileUpdates['avatar'] = $name;
-                    $user->icon = $name;
+                    try {
+                        $hash = app(AvatarService::class)->storeFromUploadedFile((int) $user->id, $request->file('avatar'));
+                        $user->icon = $hash;
+                    } catch (\Throwable $e) {
+                        $request->session()->flash('error', 'Avatar upload failed.');
+                    }
                 }

                 if ($request->hasFile('personal_picture')) {
@@ -141,7 +140,7 @@ public function index(Request $request)
                     if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
                     if (isset($profile->gender)) $user->gender = $profile->gender;
                     if (isset($profile->country_code)) $user->country_code = $profile->country_code;
-                    if (isset($profile->avatar)) $user->icon = $profile->avatar;
+                    if (isset($profile->avatar_hash)) $user->icon = $profile->avatar_hash;
                     if (isset($profile->cover_image)) $user->picture = $profile->cover_image;
                     if (isset($profile->signature)) $user->signature = $profile->signature;
                     if (isset($profile->description)) $user->description = $profile->description;
diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php
index d2d80cb..542b27d 100644
--- a/app/Http/Controllers/ProfileController.php
+++ b/app/Http/Controllers/ProfileController.php
@@ -95,11 +95,7 @@ public function update(ProfileUpdateRequest $request, \App\Services\AvatarServic
         // Files: avatar -> use AvatarService, emoticon and photo -> store to public disk
         if ($request->hasFile('avatar')) {
             try {
-                $hash = $avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
-                // store returned hash into profile avatar column
-                if (!empty($hash)) {
-                    $profileUpdates['avatar'] = $hash;
-                }
+                $avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
             } catch (\Exception $e) {
                 return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage());
             }
diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php
index 588d90d..d481540 100644
--- a/app/Http/Requests/ProfileUpdateRequest.php
+++ b/app/Http/Requests/ProfileUpdateRequest.php
@@ -37,7 +37,7 @@ public function rules(): array
             'about' => ['nullable', 'string'],
             'signature' => ['nullable', 'string'],
             'description' => ['nullable', 'string'],
-            'avatar' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
+            'avatar' => ['nullable', 'file', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp'],
             'emoticon' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
             'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
         ];
diff --git a/app/Models/User.php b/app/Models/User.php
index fc76f6c..d679929 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -4,6 +4,7 @@

 // use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -55,6 +56,11 @@ public function artworks(): HasMany
         return $this->hasMany(Artwork::class);
     }

+    public function profile(): HasOne
+    {
+        return $this->hasOne(UserProfile::class, 'user_id');
+    }
+
     public function hasRole(string $role): bool
     {
         return strtolower((string) ($this->role ?? '')) === strtolower($role);
diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php
index 46c06ce..7bb3de6 100644
--- a/app/Models/UserProfile.php
+++ b/app/Models/UserProfile.php
@@ -4,7 +4,7 @@

 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
-use Illuminate\Support\Facades\Storage;
+use App\Support\AvatarUrl;

 class UserProfile extends Model
 {
@@ -18,7 +18,7 @@ class UserProfile extends Model
         'about',
         'signature',
         'description',
-        'avatar',
+        'avatar_legacy',
         'avatar_hash',
         'avatar_mime',
         'avatar_updated_at',
@@ -43,27 +43,12 @@ public function user(): BelongsTo
         return $this->belongsTo(User::class, 'user_id');
     }

-    /**
-     * Return a public URL for the avatar when stored on the `public` disk under `avatars/`.
-     */
     public function getAvatarUrlAttribute(): ?string
     {
-        if (empty($this->avatar)) {
+        if (empty($this->user_id)) {
             return null;
         }

-        // If the stored value already looks like a full URL, return it.
-        if (preg_match('#^https?://#i', $this->avatar)) {
-            return $this->avatar;
-        }
-
-        // Prefer `public` disk and avatars folder.
-        $path = 'avatars/' . ltrim($this->avatar, '/');
-        if (Storage::disk('public')->exists($path)) {
-            return Storage::disk('public')->url($path);
-        }
-
-        // Fallback: return null if not found
-        return null;
+        return AvatarUrl::forUser((int) $this->user_id, $this->avatar_hash, 128);
     }
 }
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 1155fb1..8a4f9ed 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -35,7 +35,7 @@ public function boot(): void
         // Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
         View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
             $uploadCount = $favCount = $msgCount = $noticeCount = 0;
-            $avatar = null;
+            $avatarHash = null;
             $displayName = null;
             $userId = null;

@@ -72,15 +72,15 @@ public function boot(): void

                 try {
                     $profile = DB::table('user_profiles')->where('user_id', $userId)->first();
-                    $avatar = $profile->avatar ?? null;
+                    $avatarHash = $profile->avatar_hash ?? null;
                 } catch (\Throwable $e) {
-                    $avatar = null;
+                    $avatarHash = null;
                 }

                 $displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
             }

-            $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatar', 'displayName'));
+            $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatarHash', 'displayName'));
         });
     }

diff --git a/app/Services/AvatarService.php b/app/Services/AvatarService.php
index 1430ab9..2fb38dd 100644
--- a/app/Services/AvatarService.php
+++ b/app/Services/AvatarService.php
@@ -2,15 +2,22 @@

 namespace App\Services;

-use Illuminate\Support\Facades\Storage;
-use Illuminate\Support\Facades\DB;
-use Intervention\Image\ImageManagerStatic as Image;
-use RuntimeException;
+use App\Models\UserProfile;
 use Carbon\Carbon;
 use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
+use Intervention\Image\Drivers\Gd\Driver as GdDriver;
+use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
+use Intervention\Image\Encoders\WebpEncoder;
+use Intervention\Image\ImageManager;
+use RuntimeException;

 class AvatarService
 {
+    private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
+
+    private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
+
     protected $sizes = [
         'xs' => 32,
         'sm' => 64,
@@ -21,149 +28,160 @@ class AvatarService

     protected $quality = 85;

+    private ?ImageManager $manager = null;
+
     public function __construct()
     {
-        // Guard: if Intervention Image is not installed, defer error until actual use
-        if (class_exists(\Intervention\Image\ImageManagerStatic::class)) {
-            try {
-                Image::configure(['driver' => extension_loaded('gd') ? 'gd' : 'imagick']);
-                $this->imageAvailable = true;
-            } catch (\Throwable $e) {
-                // If configuration fails, treat as unavailable and log for diagnostics
-                logger()->warning('Intervention Image present but configuration failed: '.$e->getMessage());
-                $this->imageAvailable = false;
-            }
-        } else {
-            $this->imageAvailable = false;
+        $configuredSizes = array_values(array_filter((array) config('avatars.sizes', [32, 64, 128, 256, 512]), static fn ($size) => (int) $size > 0));
+        if ($configuredSizes !== []) {
+            $this->sizes = array_fill_keys(array_map('strval', $configuredSizes), null);
+            $this->sizes = array_combine(array_keys($this->sizes), $configuredSizes);
         }
-    }

-    /**
-     * Process an uploaded file for a user and store webp sizes.
-     * Returns the computed sha1 hash.
-     *
-     * @param int $userId
-     * @param UploadedFile $file
-     * @return string sha1 hash
-     */
-    public function storeFromUploadedFile(int $userId, UploadedFile $file): string
-    {
-        if (! $this->imageAvailable) {
-            throw new RuntimeException('Intervention Image is not available. If you just installed the package, restart your PHP process (php artisan serve or PHP-FPM) and run `composer dump-autoload -o`.');
-        }
+        $this->quality = (int) config('avatars.quality', 85);

-        // Load image and re-encode to webp after validating
         try {
-            $img = Image::make($file->getRealPath());
+            $this->manager = extension_loaded('gd')
+                ? new ImageManager(new GdDriver())
+                : new ImageManager(new ImagickDriver());
         } catch (\Throwable $e) {
-            throw new RuntimeException('Failed to read uploaded image: '.$e->getMessage());
-        }
-
-        // Ensure square center crop per spec
-        $max = max($img->width(), $img->height());
-        $img->fit($max, $max);
-
-        $basePath = "avatars/{$userId}";
-        Storage::disk('public')->makeDirectory($basePath);
-
-        // Save original as webp
-        $originalData = (string) $img->encode('webp', $this->quality);
-        Storage::disk('public')->put($basePath . '/original.webp', $originalData);
-
-        // Generate sizes
-        foreach ($this->sizes as $name => $size) {
-            $resized = $img->resize($size, $size, function ($constraint) {
-                $constraint->upsize();
-            })->encode('webp', $this->quality);
-            Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
+            logger()->warning('Avatar image manager configuration failed: ' . $e->getMessage());
+            $this->manager = null;
         }
+    }

-        $hash = sha1($originalData);
-        $mime = 'image/webp';
+    public function storeFromUploadedFile(int $userId, UploadedFile $file): string
+    {
+        $this->assertImageManagerAvailable();
+        $this->assertStorageIsAllowed();
+        $this->assertSecureImageUpload($file);

-        // Persist metadata to user_profiles if exists, otherwise users table fallbacks
-        if (SchemaHasTable('user_profiles')) {
-            DB::table('user_profiles')->where('user_id', $userId)->update([
-                'avatar_hash' => $hash,
-                'avatar_updated_at' => Carbon::now(),
-                'avatar_mime' => $mime,
-            ]);
-        } else {
-            DB::table('users')->where('id', $userId)->update([
-                'avatar_hash' => $hash,
-                'avatar_updated_at' => Carbon::now(),
-                'avatar_mime' => $mime,
-            ]);
+        $binary = file_get_contents($file->getRealPath());
+        if ($binary === false || $binary === '') {
+            throw new RuntimeException('Uploaded avatar file is empty or unreadable.');
         }

-        return $hash;
+        return $this->storeFromBinary($userId, $binary);
     }

-    /**
-     * Process a legacy file path for a user (path-to-file).
-     * Returns sha1 or null when missing.
-     *
-     * @param int $userId
-     * @param string $path Absolute filesystem path
-     * @return string|null
-     */
     public function storeFromLegacyFile(int $userId, string $path): ?string
     {
+        $this->assertImageManagerAvailable();
+        $this->assertStorageIsAllowed();
+
         if (!file_exists($path) || !is_readable($path)) {
             return null;
         }

-        try {
-            $img = Image::make($path);
-        } catch (\Exception $e) {
+        $binary = file_get_contents($path);
+        if ($binary === false || $binary === '') {
             return null;
         }

-        $max = max($img->width(), $img->height());
-        $img->fit($max, $max);
+        return $this->storeFromBinary($userId, $binary);
+    }
+
+    private function storeFromBinary(int $userId, string $binary): string
+    {
+        $image = $this->readImageFromBinary($binary);

+        $diskName = (string) config('avatars.disk', 'public');
+        $disk = Storage::disk($diskName);
         $basePath = "avatars/{$userId}";
-        Storage::disk('public')->makeDirectory($basePath);

-        $originalData = (string) $img->encode('webp', $this->quality);
-        Storage::disk('public')->put($basePath . '/original.webp', $originalData);
+        $hashSeed = '';
+        foreach ($this->sizes as $size) {
+            $variant = $image->cover($size, $size);
+            $encoded = (string) $variant->encode(new WebpEncoder($this->quality));
+            $disk->put("{$basePath}/{$size}.webp", $encoded, [
+                'visibility' => 'public',
+                'CacheControl' => 'public, max-age=31536000, immutable',
+                'ContentType' => 'image/webp',
+            ]);

-        foreach ($this->sizes as $name => $size) {
-            $resized = $img->resize($size, $size, function ($constraint) {
-                $constraint->upsize();
-            })->encode('webp', $this->quality);
-            Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
+            if ($size === 128) {
+                $hashSeed = $encoded;
+            }
         }

-        $hash = sha1($originalData);
-        $mime = 'image/webp';
+        if ($hashSeed === '') {
+            throw new RuntimeException('Avatar processing failed to generate a hash seed.');
+        }

-        if (SchemaHasTable('user_profiles')) {
-            DB::table('user_profiles')->where('user_id', $userId)->update([
-                'avatar_hash' => $hash,
-                'avatar_updated_at' => Carbon::now(),
-                'avatar_mime' => $mime,
-            ]);
-        } else {
-            DB::table('users')->where('id', $userId)->update([
+        $hash = hash('sha256', $hashSeed);
+        $this->updateProfileMetadata($userId, $hash);
+
+        return $hash;
+    }
+
+    private function readImageFromBinary(string $binary)
+    {
+        try {
+            return $this->manager->read($binary);
+        } catch (\Throwable $e) {
+            throw new RuntimeException('Failed to decode uploaded image.');
+        }
+    }
+
+    private function updateProfileMetadata(int $userId, string $hash): void
+    {
+        UserProfile::query()->updateOrCreate(
+            ['user_id' => $userId],
+            [
                 'avatar_hash' => $hash,
+                'avatar_mime' => 'image/webp',
                 'avatar_updated_at' => Carbon::now(),
-                'avatar_mime' => $mime,
-            ]);
+            ]
+        );
+    }
+
+    private function assertImageManagerAvailable(): void
+    {
+        if ($this->manager !== null) {
+            return;
         }

-        return $hash;
+        throw new RuntimeException('Avatar image processing is not available on this environment.');
     }
-}

-/**
- * Helper: check for table existence without importing Schema facade repeatedly
- */
-function SchemaHasTable(string $name): bool
-{
-    try {
-        return \Illuminate\Support\Facades\Schema::hasTable($name);
-    } catch (\Throwable $e) {
-        return false;
+    private function assertStorageIsAllowed(): void
+    {
+        if (!app()->environment('production')) {
+            return;
+        }
+
+        $diskName = (string) config('avatars.disk', 's3');
+        if (in_array($diskName, ['local', 'public'], true)) {
+            throw new RuntimeException('Production avatar storage must use object storage, not local/public disks.');
+        }
+    }
+
+    private function assertSecureImageUpload(UploadedFile $file): void
+    {
+        $extension = strtolower((string) $file->getClientOriginalExtension());
+        if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
+            throw new RuntimeException('Unsupported avatar file extension.');
+        }
+
+        $detectedMime = (string) $file->getMimeType();
+        if (!in_array($detectedMime, self::ALLOWED_MIME_TYPES, true)) {
+            throw new RuntimeException('Unsupported avatar MIME type.');
+        }
+
+        $binary = file_get_contents($file->getRealPath());
+        if ($binary === false || $binary === '') {
+            throw new RuntimeException('Unable to read uploaded avatar data.');
+        }
+
+        $finfo = new \finfo(FILEINFO_MIME_TYPE);
+        $finfoMime = (string) $finfo->buffer($binary);
+        if (!in_array($finfoMime, self::ALLOWED_MIME_TYPES, true)) {
+            throw new RuntimeException('Avatar content did not match allowed image MIME types.');
+        }
+
+        $dimensions = @getimagesizefromstring($binary);
+        if (!is_array($dimensions) || ($dimensions[0] ?? 0) < 1 || ($dimensions[1] ?? 0) < 1) {
+            throw new RuntimeException('Uploaded avatar is not a valid image.');
+        }
     }
 }
diff --git a/config/cdn.php b/config/cdn.php
index b1b1cd0..9c70607 100644
--- a/config/cdn.php
+++ b/config/cdn.php
@@ -4,4 +4,5 @@

 return [
     'files_url' => env('FILES_CDN_URL', 'https://files.skinbase.org'),
+    'avatar_url' => env('AVATAR_CDN_URL', 'https://files.skinbase.org'),
 ];
diff --git a/resources/views/components/avatar.blade.php b/resources/views/components/avatar.blade.php
index 0b90950..bae1a02 100644
--- a/resources/views/components/avatar.blade.php
+++ b/resources/views/components/avatar.blade.php
@@ -3,9 +3,7 @@
     $size = $size ?? 128;
     $profile = $user->profile ?? null;
     $hash = $profile->avatar_hash ?? null;
-    $src = $hash
-        ? asset("storage/avatars/{$user->id}/{$size}.webp?v={$hash}")
-        : asset('img/default-avatar.webp');
+    $src = \App\Support\AvatarUrl::forUser((int) $user->id, $hash, (int) $size);
     $alt = $alt ?? ($user->username ?? 'avatar');
     $class = $class ?? 'rounded-full';
 @endphp
diff --git a/resources/views/layouts/nova/toolbar.blade.php b/resources/views/layouts/nova/toolbar.blade.php
index 3833458..77b3c55 100644
--- a/resources/views/layouts/nova/toolbar.blade.php
+++ b/resources/views/layouts/nova/toolbar.blade.php
@@ -121,7 +121,7 @@ class="w-full h-10 rounded-lg bg-black/20 border border-sb-line pl-4 pr-12 text-
       <div class="relative">
           <button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
           <img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
-            src="{{ url('/avatar/' . ($userId ?? Auth::id() ?? 0) . '/' . ($avatar ? rawurlencode(basename($avatar)) : '1.png')) }}"
+            src="{{ \App\Support\AvatarUrl::forUser((int) ($userId ?? Auth::id() ?? 0), $avatarHash ?? null, 64) }}"
             alt="{{ $displayName ?? 'User' }}" />
           <span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
           <svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
diff --git a/resources/views/legacy/art.blade.php b/resources/views/legacy/art.blade.php
index 5852dd8..71bf8d5 100644
--- a/resources/views/legacy/art.blade.php
+++ b/resources/views/legacy/art.blade.php
@@ -11,7 +11,7 @@
             </div>

             <div style="clear:both;margin-top:10px;">
-                <img src="/avatar/{{ $artwork->user_id ?? 0 }}/{{ urlencode($artwork->icon ?? '') }}" class="pull-left" style="padding-right:10px;max-height:50px;" alt="Avatar">
+                <img src="{{ \App\Support\AvatarUrl::forUser((int) ($artwork->user_id ?? 0), null, 50) }}" class="pull-left" style="padding-right:10px;max-height:50px;" alt="Avatar">
                 <h1 class="page-header">{{ $artwork->name }}</h1>
                 <p>By <i class="fa fa-user fa-fw"></i> <a href="/profile/{{ $artwork->user_id }}/{{ \Illuminate\Support\Str::slug($artwork->uname) }}" title="Profile of member {{ $artwork->uname }}">{{ $artwork->uname }}</a></p>
                 <hr>
@@ -30,7 +30,7 @@
                     <div class="comment_box effect3">
                         <div class="cb_image">
                             <a href="/profile/{{ $comment->user_id }}/{{ urlencode($comment->uname) }}">
-                                <img src="/avatar/{{ $comment->user_id }}/{{ urlencode($comment->icon) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
+                                <img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->user_id, null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
                             </a>
                         </div>
                         <div class="bubble_comment panel panel-skinbase">
@@ -53,7 +53,7 @@
                 <div class="comment_box effect3">
                     <div class="cb_image">
                         <a href="/profile/{{ auth()->id() }}/{{ urlencode(auth()->user()->name) }}">
-                            <img src="/avatar/{{ auth()->id() }}/{{ urlencode(auth()->user()->avatar ?? '') }}" class="comment_avatar" width="50" height="50">
+                            <img src="{{ \App\Support\AvatarUrl::forUser((int) auth()->id(), null, 50) }}" class="comment_avatar" width="50" height="50">
                         </a>
                         <br>
                         <a href="/profile/{{ auth()->id() }}/{{ urlencode(auth()->user()->name) }}">{{ auth()->user()->name }}</a>
diff --git a/resources/views/legacy/buddies.blade.php b/resources/views/legacy/buddies.blade.php
index 1d91897..6b15b25 100644
--- a/resources/views/legacy/buddies.blade.php
+++ b/resources/views/legacy/buddies.blade.php
@@ -27,7 +27,7 @@

                     <div>
                         <a href="/profile/{{ $followerId }}/{{ Str::slug($uname) }}">
-                            <img src="/avatar/{{ $followerId }}/{{ rawurlencode($icon) }}" alt="{{ $uname }}">
+                            <img src="{{ \App\Support\AvatarUrl::forUser((int) $followerId, null, 50) }}" alt="{{ $uname }}">
                         </a>
                     </div>
                 </div>
diff --git a/resources/views/legacy/forum/posts.blade.php b/resources/views/legacy/forum/posts.blade.php
index d497a79..28e9f66 100644
--- a/resources/views/legacy/forum/posts.blade.php
+++ b/resources/views/legacy/forum/posts.blade.php
@@ -28,7 +28,7 @@
                     <div class="panel-body" style="display:flex; gap:12px;">
                         <div style="min-width:52px;">
                             @if (!empty($post->user_id) && !empty($post->icon))
-                                <img src="/avatar/{{ $post->user_id }}/{{ $post->icon }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail">
+                                <img src="{{ \App\Support\AvatarUrl::forUser((int) $post->user_id, null, 50) }}" alt="{{ $post->uname }}" width="50" height="50" class="img-thumbnail">
                             @else
                                 <div class="img-thumbnail" style="width:50px;height:50px;"></div>
                             @endif
diff --git a/resources/views/legacy/gallery.blade.php b/resources/views/legacy/gallery.blade.php
index 7c4c7c3..327e583 100644
--- a/resources/views/legacy/gallery.blade.php
+++ b/resources/views/legacy/gallery.blade.php
@@ -34,7 +34,7 @@
             <div class="panel panel-default effect2">
                 <div class="panel-heading"><strong>User</strong></div>
                 <div class="panel-body">
-                    <img src="/avatar/{{ (int)($user->user_id ?? $user->id) }}/{{ rawurlencode($user->icon ?? '') }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname ?? $user->name }}">
+                    <img src="{{ \App\Support\AvatarUrl::forUser((int) ($user->user_id ?? $user->id), null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname ?? $user->name }}">
                     <h3>{{ $user->uname ?? $user->name }}</h3>
                     <p>{{ $user->about_me ?? '' }}</p>
                 </div>
diff --git a/resources/views/legacy/interview.blade.php b/resources/views/legacy/interview.blade.php
index af1364f..2bb59b6 100644
--- a/resources/views/legacy/interview.blade.php
+++ b/resources/views/legacy/interview.blade.php
@@ -32,7 +32,7 @@
                     <tr>
                         <td rowspan="3" valign="top" width="100" style="background:#fff">
                             @if(!empty($comment->user_id) && !empty($comment->icon))
-                                <div align="center"><a href="/profile/{{ $comment->user_id }}"><img src="/avatar/{{ $comment->user_id }}/{{ \Illuminate\Support\Str::slug($comment->author ?? '') }}" width="50" height="50" border="0" alt="" /></a></div>
+                                <div align="center"><a href="/profile/{{ $comment->user_id }}"><img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->user_id, null, 50) }}" width="50" height="50" border="0" alt="" /></a></div>
                             @endif
                             <br/>Posted by: <b><a href="/profile/{{ $comment->user_id ?? '' }}">{{ $comment->author }}</a></b><br/>
                             Posts: {{ $postCounts[$comment->author] ?? 0 }}
diff --git a/resources/views/legacy/interviews.blade.php b/resources/views/legacy/interviews.blade.php
index a9650a4..b78d76a 100644
--- a/resources/views/legacy/interviews.blade.php
+++ b/resources/views/legacy/interviews.blade.php
@@ -22,7 +22,7 @@
                         <td style="width:60px;">
                             @if(!empty($interview->icon))
                                 <a href="/profile/{{ $interview->user_id }}/{{ \Illuminate\Support\Str::slug($interview->uname ?? '') }}">
-                                    <img src="/avatar/{{ $interview->user_id }}/{{ $interview->icon }}" width="50" height="50" alt="">
+                                    <img src="{{ \App\Support\AvatarUrl::forUser((int) $interview->user_id, null, 50) }}" width="50" height="50" alt="">
                                 </a>
                             @else
                                 <img src="/gfx/avatar.jpg" alt="">
diff --git a/resources/views/legacy/latest-comments.blade.php b/resources/views/legacy/latest-comments.blade.php
index ed99954..2e33899 100644
--- a/resources/views/legacy/latest-comments.blade.php
+++ b/resources/views/legacy/latest-comments.blade.php
@@ -15,7 +15,7 @@
                 <div class="comment_box effect3">
                     <div class="cb_image">
                         <a href="/profile/{{ $comment->commenter_id }}/{{ rawurlencode($comment->uname) }}">
-                            <img src="/avatar/{{ (int)$comment->commenter_id }}/{{ rawurlencode($comment->uname) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
+                            <img src="{{ \App\Support\AvatarUrl::forUser((int) $comment->commenter_id, null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $comment->uname }}">
                         </a>
                     </div>

diff --git a/resources/views/legacy/monthly-commentators.blade.php b/resources/views/legacy/monthly-commentators.blade.php
index 28b7921..7066d11 100644
--- a/resources/views/legacy/monthly-commentators.blade.php
+++ b/resources/views/legacy/monthly-commentators.blade.php
@@ -27,7 +27,7 @@
                             <tr>
                                 <td width="50" class="text-center">
                                     <a href="/profile/{{ (int)$row->user_id }}/{{ rawurlencode($row->uname) }}">
-                                        <img src="/avatar/{{ (int)$row->user_id }}/{{ rawurlencode($row->uname) }}" width="30" alt="{{ $row->uname }}">
+                                        <img src="{{ \App\Support\AvatarUrl::forUser((int) $row->user_id, null, 32) }}" width="30" alt="{{ $row->uname }}">
                                     </a>
                                 </td>
                                 <td>
diff --git a/resources/views/legacy/mybuddies.blade.php b/resources/views/legacy/mybuddies.blade.php
index 13fd2c6..c91f446 100644
--- a/resources/views/legacy/mybuddies.blade.php
+++ b/resources/views/legacy/mybuddies.blade.php
@@ -28,7 +28,7 @@

                     <div>
                         <a href="/profile/{{ $friendId }}/{{ Str::slug($uname) }}">
-                            <img src="/avatar/{{ $friendId }}/{{ rawurlencode($icon) }}" alt="{{ $uname }}">
+                            <img src="{{ \App\Support\AvatarUrl::forUser((int) $friendId, null, 50) }}" alt="{{ $uname }}">
                         </a>
                     </div>

diff --git a/resources/views/legacy/news.blade.php b/resources/views/legacy/news.blade.php
index 7c87583..0d87945 100644
--- a/resources/views/legacy/news.blade.php
+++ b/resources/views/legacy/news.blade.php
@@ -36,7 +36,7 @@
                 @if(!empty($ar->icon))
                     <div style="float:right;padding-left:20px;">
                         <a href="/profile/{{ $ar->user_id ?? '' }}/{{ Str::slug($ar->uname ?? '') }}">
-                            <img src="/avatar/{{ $ar->user_id ?? '' }}/{{ $ar->icon ?? '' }}" width="50" height="50" alt="">
+                            <img src="{{ \App\Support\AvatarUrl::forUser((int) ($ar->user_id ?? 0), null, 50) }}" width="50" height="50" alt="">
                         </a>
                     </div>
                 @endif
diff --git a/resources/views/legacy/profile.blade.php b/resources/views/legacy/profile.blade.php
index 87239b6..f3e9453 100644
--- a/resources/views/legacy/profile.blade.php
+++ b/resources/views/legacy/profile.blade.php
@@ -34,7 +34,7 @@
             <div class="panel panel-default effect2">
                 <div class="panel-heading"><strong>User</strong></div>
                 <div class="panel-body">
-                    <img src="/avatar/{{ (int)$user->user_id }}/{{ rawurlencode($user->icon ?? '') }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname }}">
+                    <img src="{{ \App\Support\AvatarUrl::forUser((int) $user->user_id, null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname }}">
                     <h3>{{ $user->uname }}</h3>
                     <p>{{ $user->about_me ?? '' }}</p>
                 </div>
diff --git a/resources/views/legacy/received-comments.blade.php b/resources/views/legacy/received-comments.blade.php
index 75f08fd..855bd95 100644
--- a/resources/views/legacy/received-comments.blade.php
+++ b/resources/views/legacy/received-comments.blade.php
@@ -21,7 +21,7 @@
                 <div class="comment_box effect3">
                     <div class="cb_image">
                         <a href="/profile/{{ (int)($author->id ?? $author->user_id) }}/{{ rawurlencode($author->name ?? $author->uname ?? '') }}">
-                            <img src="/avatar/{{ (int)($author->id ?? $author->user_id) }}/{{ rawurlencode($author->icon ?? '') }}" width="50" height="50" class="comment_avatar" alt="{{ $author->name ?? $author->uname ?? '' }}">
+                            <img src="{{ \App\Support\AvatarUrl::forUser((int) ($author->id ?? $author->user_id), null, 50) }}" width="50" height="50" class="comment_avatar" alt="{{ $author->name ?? $author->uname ?? '' }}">
                         </a>
                     </div>

diff --git a/resources/views/legacy/toolbar.blade.php b/resources/views/legacy/toolbar.blade.php
index 5dcbf97..0cefc74 100644
--- a/resources/views/legacy/toolbar.blade.php
+++ b/resources/views/legacy/toolbar.blade.php
@@ -119,9 +119,9 @@
                         }
                         try {
                             $profile = \Illuminate\Support\Facades\DB::table('user_profiles')->where('user_id', $userId)->first();
-                            $avatar = $profile->avatar ?? null;
+                            $avatarHash = $profile->avatar_hash ?? null;
                         } catch (\Throwable $e) {
-                            $avatar = null;
+                            $avatarHash = null;
                         }
                         $displayName = auth()->user()->name ?: (auth()->user()->username ?? '');
                     @endphp
@@ -141,9 +141,7 @@

                     <li class="dropdown">
                         <a href="#" class="dropdown-toggle c-white" data-toggle="dropdown" data-hover="dropdown" data-close-others="true">
-                            @if($avatar)
-                                <img src="/storage/{{ ltrim($avatar, '/') }}" alt="{{ $displayName }}" width="18">&nbsp;&nbsp;
-                            @endif
+                            <img src="{{ \App\Support\AvatarUrl::forUser((int) $userId, $avatarHash ?? null, 32) }}" alt="{{ $displayName }}" width="18">&nbsp;&nbsp;
                             <span class="username">{{ $displayName }}</span>
                             &nbsp;<i class="fa fa-angle-down"></i>
                         </a>
diff --git a/resources/views/legacy/top-authors.blade.php b/resources/views/legacy/top-authors.blade.php
index febb459..ed4513b 100644
--- a/resources/views/legacy/top-authors.blade.php
+++ b/resources/views/legacy/top-authors.blade.php
@@ -27,7 +27,7 @@
                                 <tr>
                                     <td width="80">
                                         <a href="/profile/{{ $u->user_id }}/{{ \Illuminate\Support\Str::slug($u->uname) }}">
-                                            <img src="/avatar/{{ $u->user_id }}/{{ rawurlencode($u->icon ?? '') }}" width="30" height="30" alt="{{ $u->uname }}">
+                                            <img src="{{ \App\Support\AvatarUrl::forUser((int) $u->user_id, null, 32) }}" width="30" height="30" alt="{{ $u->uname }}">
                                         </a>
                                     </td>
                                     <td>
@@ -60,7 +60,7 @@
                                 <tr>
                                     <td width="60" align="center">
                                         <a href="/profile/{{ $f->user_id }}/{{ \Illuminate\Support\Str::slug($f->uname) }}">
-                                            <img src="/avatar/{{ $f->user_id }}/{{ rawurlencode($f->uname) }}" width="30" alt="{{ $f->uname }}">
+                                            <img src="{{ \App\Support\AvatarUrl::forUser((int) $f->user_id, null, 32) }}" width="30" alt="{{ $f->uname }}">
                                         </a>
                                     </td>
                                     <td>
@@ -92,7 +92,7 @@
                                 <tr>
                                     <td width="50" align="center">
                                         <a href="/profile/{{ $c->user_id }}/{{ \Illuminate\Support\Str::slug($c->uname) }}">
-                                            <img src="/avatar/{{ $c->user_id }}/{{ rawurlencode($c->uname) }}" width="30" alt="{{ $c->uname }}">
+                                            <img src="{{ \App\Support\AvatarUrl::forUser((int) $c->user_id, null, 32) }}" width="30" alt="{{ $c->uname }}">
                                         </a>
                                     </td>
                                     <td>
diff --git a/tests/Feature/AvatarUploadTest.php b/tests/Feature/AvatarUploadTest.php
index aafbf67..3928cbf 100644
--- a/tests/Feature/AvatarUploadTest.php
+++ b/tests/Feature/AvatarUploadTest.php
@@ -1,15 +1,64 @@
 <?php

 use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use App\Models\User;
 use Tests\TestCase;

 class AvatarUploadTest extends TestCase
 {
     use RefreshDatabase;

+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        config()->set('avatars.disk', 'public');
+        Storage::fake('public');
+    }
+
     public function test_upload_requires_authentication()
     {
         $response = $this->postJson(route('avatar.upload'));
         $response->assertStatus(401);
     }
+
+    public function test_upload_rejects_invalid_avatar_type(): void
+    {
+        $user = User::factory()->create();
+
+        $response = $this->actingAs($user)->postJson(route('avatar.upload'), [
+            'avatar' => UploadedFile::fake()->create('avatar.txt', 50, 'text/plain'),
+        ]);
+
+        $response->assertStatus(422);
+    }
+
+    public function test_upload_processes_avatar_and_stores_webp_variants(): void
+    {
+        $user = User::factory()->create();
+
+        $response = $this->actingAs($user)->postJson(route('avatar.upload'), [
+            'avatar' => UploadedFile::fake()->image('avatar.png', 300, 180),
+        ]);
+
+        $response->assertOk()->assertJson(['success' => true]);
+
+        $payload = $response->json();
+        $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) ($payload['hash'] ?? ''));
+
+        $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/32.webp"));
+        $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/64.webp"));
+        $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/128.webp"));
+        $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/256.webp"));
+        $this->assertTrue(Storage::disk('public')->exists("avatars/{$user->id}/512.webp"));
+
+        $record = DB::table('user_profiles')->where('user_id', $user->id)->first();
+        $this->assertNotNull($record);
+        $this->assertSame('image/webp', $record->avatar_mime);
+        $this->assertSame($payload['hash'], $record->avatar_hash);
+        $this->assertNotNull($record->avatar_updated_at);
+    }
 }
