A comprehensive avatar management system that allows users to upload custom profile pictures with client-side cropping, stored as optimized WebP images with dynamic size generation and Gravatar fallback.
Users can upload a custom avatar (JPG/PNG, max 2MB, minimum 400x400px) which overrides their default Gravatar. The system provides an intuitive modal interface with real-time cropping preview using Cropper.js. Uploaded images are stored as 400x400 WebP format (85% quality) and served dynamically at any requested size via a dedicated endpoint. The solution uses local storage for development and Digital Ocean Spaces with CDN for production. Users can remove their custom avatar at any time to revert to Gravatar. The interface is accessible by clicking/tapping the user avatar anywhere on the site, providing a seamless editing experience on both desktop and mobile devices.
- Minimum Upload Size: 400x400px (follows LinkedIn/Twitter standards)
- Maximum File Size: 2MB
- Accepted Formats: JPG, PNG (input) → WebP (storage)
- Storage Resolution: 400x400px (all uploads resized to this)
- Storage Format: WebP at 85% quality
- Dynamic Serving: Any size on-demand (48, 96, 192, etc.)
- Storage Backend: Local disk (dev) / Digital Ocean Spaces (production)
- Client-side Cropping: Cropper.js with 1:1 aspect ratio
- Fallback: Gravatar when no custom avatar exists
- Access: Click/tap user avatar → "Edit Avatar" link → Modal
- Upload Flow: Select file → Crop preview → Save → Page reload
- Remove Flow: "Remove Avatar" button → Confirms → Reverts to Gravatar
- Gravatar Option: Link to edit via Gravatar (opens in new window)
- Retina Support: srcset attributes for high-DPI displays
- Mobile Support: Same functionality as desktop (tap avatar)
- Storage Path:
avatars/{userId}/400.webp(master) - Cached Sizes:
avatars/{userId}/{size}.webp(generated on-demand) - Endpoint:
GET /avatar/{user}/{size?}/(default size: 96) - API Endpoints:
POST /api/profile/avatar/,DELETE /api/profile/avatar/ - Image Processing: Intervention Image v3 (already installed)
- Frontend: Alpine.js component with Cropper.js
- CDN: Digital Ocean Spaces CDN for production
Migration: Add avatar_path column to users table
- Column:
avatar_path(nullable string) - Purpose: Stores path to uploaded avatar (e.g.,
avatars/1/400.webp) - Default:
null(indicates no custom avatar, use Gravatar)
File: app/Models/User.php
Update getAvatarUrlAttribute() Accessor:
public function getAvatarUrlAttribute(): string
{
if ($this->avatar_path) {
return route('avatar', ['user' => $this->id, 'size' => 96]);
}
// Fallback to Gravatar
return 'https://www.gravatar.com/avatar/'
. md5(strtolower(trim($this->email)))
. '?s=96&d=identicon';
}Add to $fillable array:
protected $fillable = [
// ... existing fields
'avatar_path',
];File: app/Services/AvatarService.php (new)
This service handles all image processing using Intervention Image v3 (already installed).
Purpose: Process uploaded file and store as 400x400 WebP
Steps:
- Validate minimum dimensions (400x400)
- Load uploaded image with Intervention Image
- Apply crop coordinates from Cropper.js:
- Extract
x,y,width,heightfrom$cropData - Crop image:
$image->crop($width, $height, $x, $y)
- Extract
- Resize cropped area to exactly 400x400:
$image->resize(400, 400)
- Convert to WebP format at 85% quality:
$image->toWebp(85)
- Save to storage disk as
avatars/{userId}/400.webp - Return avatar path for database storage
Validation:
- Minimum dimensions: 400x400px
- If uploaded image is < 400x400 → reject with error
- If uploaded image is >= 400x400 → process and resize to 400x400
Purpose: Generate requested size from 400x400 master
Steps:
- Load master image from
avatars/{userId}/400.webp - Resize to {size}x{size} square (downscale from 400x400)
- Convert to WebP at 85% quality
- Save to
avatars/{userId}/{size}.webp - Return path to generated image
Common Sizes: 32, 48, 64, 96, 128, 192
Purpose: Remove all avatar versions for user
Steps:
- Get all files in
avatars/{userId}/directory - Delete each file:
400.webp(master)48.webp,96.webp, etc. (cached sizes)
- Delete directory:
avatars/{userId}/ - Return success
File: app/Http/Controllers/AvatarController.php (new)
Purpose: Handle avatar upload via API
Validation Rules:
$request->validate([
'file' => 'required|image|mimes:jpeg,jpg,png|max:2048', // 2MB
'crop_x' => 'required|integer',
'crop_y' => 'required|integer',
'crop_width' => 'required|integer',
'crop_height' => 'required|integer',
]);Additional Validation:
- Check image dimensions >= 400x400
- If too small, return error: "Image must be at least 400x400 pixels (yours is {width}x{height})"
Processing:
- Extract file and crop data from request
- Call
AvatarService::uploadOriginal($file, $cropData, auth()->id()) - Update authenticated user's
avatar_pathcolumn - Return success response with new avatar URL
Response (JSON):
{
"success": true,
"avatar_url": "/avatar/1/96",
"message": "Avatar uploaded successfully"
}Purpose: Remove custom avatar via API
Processing:
- Get authenticated user
- Call
AvatarService::delete(auth()->id()) - Set user's
avatar_pathtonull - Return success response
Response (JSON):
{
"success": true,
"message": "Avatar removed successfully"
}Purpose: Serve avatar at requested size (public endpoint)
Flow:
- Check if user has custom avatar (
avatar_pathis not null) - If no custom avatar:
- Generate Gravatar hash:
md5(strtolower(trim($user->email))) - Redirect to:
https://gravatar.com/avatar/{hash}?s={size}&d=identicon
- Generate Gravatar hash:
- If custom avatar exists:
- Check if requested size is already cached:
avatars/{userId}/{size}.webp - Cached → Serve from storage/CDN
- Not cached → Call
AvatarService::generateSize($userId, $size), then serve
- Check if requested size is already cached:
- Set response headers:
Content-Type: image/webpCache-Control: public, max-age=31536000(1 year)
Size Validation:
- Accept any positive integer size (common: 32, 48, 64, 96, 128, 192, 256)
- Default: 96px (if no size parameter provided)
API Routes (routes/api.php):
Route::middleware('auth')->group(function () {
Route::post('/profile/avatar/', [AvatarController::class, 'store'])->name('api.avatar.store');
Route::delete('/profile/avatar/', [AvatarController::class, 'destroy'])->name('api.avatar.destroy');
});Web Routes (routes/web.php):
Route::get('/avatar/{user}/{size?}/', [AvatarController::class, 'serve'])
->where('size', '[0-9]+')
->defaults('size', 96)
->name('avatar');Development Environment (.env):
FILESYSTEM_DISK=public
AVATAR_MIN_SIZE=400- Uses Laravel's
publicdisk - Path:
storage/app/public/avatars/ - Accessible via:
/storage/avatars/ - Free, fast, no cloud dependencies
Production Environment (.env - will be configured during deployment):
FILESYSTEM_DISK=s3
AVATAR_MIN_SIZE=400
# Digital Ocean Spaces Configuration
AWS_ACCESS_KEY_ID=your_spaces_key_here
AWS_SECRET_ACCESS_KEY=your_spaces_secret_here
AWS_DEFAULT_REGION=nyc3
AWS_BUCKET=codex-avatars-prod
AWS_ENDPOINT=https://nyc3.digitaloceanspaces.com
AWS_URL=https://codex-avatars-prod.nyc3.cdn.digitaloceanspaces.comNote: config/filesystems.php already has S3 disk configured, no changes needed.
- Create migration:
add_avatar_path_to_users_table.php - Add
avatar_pathnullable string column to users table - Run migration locally:
php artisan migrate - Update
User.php: ModifygetAvatarUrlAttribute()accessor - Update
User.php: Addavatar_pathto$fillablearray - Create
app/Services/AvatarService.php - Implement
AvatarService::uploadOriginal()method - Implement
AvatarService::generateSize()method - Implement
AvatarService::delete()method - Test image processing with Intervention Image (load, crop, resize, WebP conversion)
- Create
app/Http/Controllers/AvatarController.php - Implement
AvatarController::store()with validation - Implement
AvatarController::destroy() - Implement
AvatarController::serve()with dynamic size generation - Add API routes to
routes/api.php(upload, delete) - Add web route to
routes/web.php(serve endpoint) - Verify storage disk configuration in
.env - Test upload API endpoint with Postman/Insomnia (validate 400x400 min, 2MB max)
- Test delete API endpoint
- Test serve endpoint (cache generation, Gravatar fallback)
- Verify WebP conversion quality (85%)
Command:
npm install cropperjsImport CSS: Add to component or global stylesheet
import 'cropperjs/dist/cropper.css';File: resources/js/components/avatarUpload.js (new)
Export:
export default function avatarUpload() {
return {
// State
selectedFile: null,
previewUrl: null,
cropper: null,
uploading: false,
error: null,
// Methods
init() { /* ... */ },
selectFile(event) { /* ... */ },
initCropper() { /* ... */ },
upload() { /* ... */ },
remove() { /* ... */ },
closeModal() { /* ... */ },
}
}selectedFile: File object from input (null initially)previewUrl: Object URL for preview image (null initially)cropper: Cropper.js instance (null initially)uploading: Boolean flag for upload in progress (false initially)error: Error message string (null initially)
Purpose: Initialize component when Alpine loads
Actions:
- Set up any event listeners
- Initialize state
- Called automatically by Alpine.js
Purpose: Handle file selection from input
Steps:
- Get file from event:
event.target.files[0] - Clear previous error
- Client-side validation:
- Check file size <= 2MB
- If too large: Set error "Image must be under 2MB (yours is {size})"
- Return early if validation fails
- Create image to check dimensions:
const img = new Image(); img.onload = () => { if (img.width < 400 || img.height < 400) { this.error = `Image must be at least 400x400 pixels (yours is ${img.width}x${img.height})`; return; } this.selectedFile = file; this.previewUrl = URL.createObjectURL(file); this.$nextTick(() => this.initCropper()); }; img.src = URL.createObjectURL(file);
Purpose: Initialize Cropper.js on preview image
Configuration:
this.cropper = new Cropper(document.getElementById('avatar-preview'), {
aspectRatio: 1, // Square crop
viewMode: 1, // Restrict crop box to canvas
autoCropArea: 1, // Full area by default
minCropBoxWidth: 400,
minCropBoxHeight: 400,
responsive: true,
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
});Purpose: Upload cropped avatar to API
Steps:
- Set
uploading = true - Get crop data from Cropper.js:
const cropData = this.cropper.getData(); // Returns: { x, y, width, height, rotate, scaleX, scaleY }
- Create FormData:
const formData = new FormData(); formData.append('file', this.selectedFile); formData.append('crop_x', Math.round(cropData.x)); formData.append('crop_y', Math.round(cropData.y)); formData.append('crop_width', Math.round(cropData.width)); formData.append('crop_height', Math.round(cropData.height));
- POST to
/api/profile/avatar/:fetch('/api/profile/avatar/', { method: 'POST', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, }, body: formData, })
- Handle response:
- Success: Reload page (
window.location.reload()) - Error: Display error message, set
uploading = false
- Success: Reload page (
Purpose: Delete custom avatar, revert to Gravatar
Steps:
- Confirm with user:
if (!confirm('Remove your custom avatar and use Gravatar instead?')) return;
- DELETE to
/api/profile/avatar/:fetch('/api/profile/avatar/', { method: 'DELETE', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, }, })
- Handle response:
- Success: Reload page (
window.location.reload()) - Error: Display error message
- Success: Reload page (
Purpose: Clean up and close modal
Steps:
- Destroy cropper instance:
if (this.cropper) this.cropper.destroy() - Revoke object URL:
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl) - Reset state:
this.selectedFile = null; this.previewUrl = null; this.cropper = null; this.error = null;
- Dispatch close event:
this.$dispatch('close-edit-avatar')
File: resources/js/public.js
Import and register:
import avatarUpload from './components/avatarUpload.js';
// Register with Alpine
Alpine.data('avatarUpload', avatarUpload);Note: Add import before Alpine.start() call
- Run
npm install cropperjs - Verify Cropper.js installed in
package.json - Create
resources/js/components/avatarUpload.js - Import Cropper.js and CSS in component
- Implement component state properties
- Implement
init()method - Implement
selectFile()with client-side validation - Implement
initCropper()with 1:1 aspect ratio configuration - Implement
upload()with FormData and fetch API - Implement
remove()with confirmation - Implement
closeModal()with cleanup - Add component import to
resources/js/public.js - Register component with Alpine.data()
- Test file selection and validation (size, dimensions)
- Test Cropper.js initialization and crop area adjustment
- Test crop data extraction (x, y, width, height)
File: resources/views/components/modals/edit-avatar.blade.php (new)
Uses existing x-modal.dialog component pattern (rounded-3xl, Alpine.js driven).
Template:
<x-modal.dialog id="edit-avatar" title="Edit Avatar">
<div x-data="avatarUpload()" class="space-y-4">
{{-- Upload Section --}}
<div class="space-y-2">
<label for="avatar-upload" class="block text-sm font-medium text-gray-700">
Choose Image
</label>
<input
type="file"
id="avatar-upload"
accept="image/jpeg,image/png"
@change="selectFile($event)"
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-mblue-50 file:text-mblue-700
hover:file:bg-mblue-100"
>
<p class="text-xs text-gray-500">
JPG or PNG, minimum 400x400px, max 2MB
</p>
</div>
{{-- Preview Section (shown after file selected) --}}
<div x-show="previewUrl" class="space-y-2">
<label class="block text-sm font-medium text-gray-700">
Adjust Crop Area
</label>
<div class="max-w-md mx-auto">
<img
id="avatar-preview"
:src="previewUrl"
alt="Preview"
class="max-w-full"
>
</div>
</div>
{{-- Error Message --}}
<div
x-show="error"
x-text="error"
class="p-3 text-sm text-red-800 bg-red-50 rounded-lg"
role="alert"
></div>
{{-- Actions --}}
<div class="flex flex-wrap gap-2 pt-4">
<x-buttons.primary
@click="upload"
x-bind:disabled="uploading || !selectedFile"
class="flex-1"
>
<span x-text="uploading ? 'Uploading...' : 'Save Avatar'"></span>
</x-buttons.primary>
<x-buttons.secondary-danger
@click="remove"
x-bind:disabled="uploading"
>
Remove Avatar
</x-buttons.secondary-danger>
<x-buttons.secondary
@click="closeModal"
x-bind:disabled="uploading"
>
Cancel
</x-buttons.secondary>
</div>
{{-- Gravatar Link --}}
<div class="pt-2 text-center border-t">
<x-links.standard
href="https://gravatar.com/"
target="_blank"
class="inline-flex items-center gap-1 text-sm"
>
Edit via Gravatar instead
<x-ui.icon name="external-link" class="w-4 h-4" />
</x-links.standard>
</div>
</div>
</x-modal.dialog>Display in error div (x-show="error" x-text="error"):
- File too large: "Image must be under 2MB (yours is {size})"
- Dimensions too small: "Image must be at least 400x400 pixels (yours is {width}x{height})"
- Invalid format: "Please upload a JPG or PNG image"
- Upload failed: Server error message from API response
- Open: Triggered by
@open-edit-avatarevent from avatar click - Close: Triggered by Cancel button, X button, or successful upload
- Backdrop click: Closes modal (default behavior from
x-modal.dialog) - Escape key: Closes modal (default behavior from
x-modal.dialog)
File: resources/views/components/ui/user-avatar.blade.php
Location: In dropdown menu, after "Profile" link, before "Logout"
Implementation:
<a
href="#"
@click.prevent="$dispatch('open-edit-avatar')"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Edit Avatar
</a>- Works on desktop: Hover avatar → dropdown appears → click "Edit Avatar"
- Works on mobile: Tap avatar → dropdown appears → tap "Edit Avatar"
- Keyboard navigation: Tab to avatar → Enter → Arrow keys → Enter on "Edit Avatar"
File: resources/views/components/layouts/app.blade.php
Add before closing </body> tag:
@auth
<x-modals.edit-avatar />
@endauthPurpose: Makes modal globally available for authenticated users across all pages
Note: Modal is hidden by default, shown only when triggered by event
- Create
resources/views/components/modals/edit-avatar.blade.php - Implement modal structure using
x-modal.dialogcomponent - Add file input with accept attribute (jpeg, png)
- Add preview section with Cropper.js target image
- Add error message display area
- Add action buttons (Save, Remove, Cancel) using button components
- Add Gravatar link with external-link icon
- Style modal with Tailwind CSS (match app aesthetic)
- Update
resources/views/components/ui/user-avatar.blade.php - Add "Edit Avatar" link in dropdown menu
- Add event dispatch to open modal
- Update
resources/views/components/layouts/app.blade.php - Include modal component for authenticated users
- Test modal opens when clicking "Edit Avatar"
- Test file selection triggers preview
- Test Cropper.js displays in modal
- Test Save button uploads avatar
- Test Remove button deletes avatar
- Test Cancel button closes modal
- Test Gravatar link opens in new window
- Test mobile: tap avatar → edit link → modal works
All templates currently using {{ auth()->user()->avatar_url }} or {{ $user->avatar_url }} need to be updated to use the new route with srcset for retina displays.
Replace:
<img src="{{ auth()->user()->avatar_url }}" class="w-12 h-12 rounded-full">With:
<img
src="{{ route('avatar', ['user' => auth()->id(), 'size' => 48]) }}"
srcset="{{ route('avatar', ['user' => auth()->id(), 'size' => 96]) }} 2x"
alt="{{ auth()->user()->name }}"
class="w-12 h-12 rounded-full"
>Common Tailwind classes and their corresponding avatar sizes:
| Tailwind Class | Standard Size | Retina Size (@2x) |
|---|---|---|
w-8 h-8 |
32px | 64px |
w-10 h-10 |
40px | 80px |
w-12 h-12 |
48px | 96px |
w-16 h-16 |
64px | 128px |
w-24 h-24 |
96px | 192px |
File: resources/views/components/ui/user-avatar.blade.php
Current class: w-8 h-8 (32x32)
Update:
<img
src="{{ route('avatar', ['user' => auth()->id(), 'size' => 32]) }}"
srcset="{{ route('avatar', ['user' => auth()->id(), 'size' => 64]) }} 2x"
alt="{{ auth()->user()->name }}"
class="w-8 h-8 rounded-full"
>File: resources/views/components/navigation/user-profile.blade.php
Current class: w-8 h-8 (32x32)
Update:
<img
src="{{ route('avatar', ['user' => auth()->id(), 'size' => 32]) }}"
srcset="{{ route('avatar', ['user' => auth()->id(), 'size' => 64]) }} 2x"
alt="{{ auth()->user()->name }}"
class="w-8 h-8 rounded-full"
>File: resources/views/components/comments/user-avatar.blade.php
Current class: w-12 h-12 (48x48)
Update:
<img
src="{{ route('avatar', ['user' => $comment->user->id, 'size' => 48]) }}"
srcset="{{ route('avatar', ['user' => $comment->user->id, 'size' => 96]) }} 2x"
alt="{{ $comment->user->name }}"
class="w-12 h-12 rounded-full"
>Note: Variable may be $comment->user, auth()->user(), or just $user depending on context.
File: resources/views/components/users/active-users.blade.php
Current class: h-10 w-10 (40x40)
Update:
<img
src="{{ route('avatar', ['user' => $activity['user']->id, 'size' => 40]) }}"
srcset="{{ route('avatar', ['user' => $activity['user']->id, 'size' => 80]) }} 2x"
alt="{{ $activity['user']->name }}"
class="h-10 w-10 rounded-full"
>Search commands:
# Find all uses of avatar_url accessor
grep -r "avatar_url" resources/views/
# Find all uses of gravatar accessor
grep -r "gravatar" resources/views/
# Find all rounded-full images (likely avatars)
grep -r "rounded-full" resources/views/ | grep "<img"- Update
resources/views/components/ui/user-avatar.blade.php(32→64 @2x) - Update
resources/views/components/navigation/user-profile.blade.php(32→64 @2x) - Update
resources/views/components/comments/user-avatar.blade.php(48→96 @2x) - Update
resources/views/components/users/active-users.blade.php(40→80 @2x) - Search for additional
avatar_urlusage in views - Search for additional
gravatarusage in views - Update any additional avatar displays found
- Test all avatar displays render correctly
- Test retina displays load @2x images
- Verify browser network tab shows correct size requests
- Test Gravatar fallback for users without custom avatars
- Verify srcset attribute syntax is correct
- Test responsive behavior (mobile, tablet, desktop)
These steps should be performed by a human deployer, not the coding agent.
Steps:
- Log into Digital Ocean dashboard
- Navigate to Spaces
- Click "Create Space"
- Configuration:
- Name:
codex-avatars-prod - Region: Choose closest to users (e.g., NYC3, SFO3, AMS3)
- CDN: Enable (automatic, no extra cost)
- Files Listing: Private
- Name:
- Click "Create Space"
- Note the CDN endpoint URL (e.g.,
https://codex-avatars-prod.nyc3.cdn.digitaloceanspaces.com)
Steps:
- Navigate to API → Tokens/Keys → Spaces Keys
- Click "Generate New Key"
- Name: "Codex Production Avatars"
- Click "Generate Key"
- IMPORTANT: Save both values immediately (shown only once):
- Access Key ID (e.g.,
DO00ABC...) - Secret Access Key (e.g.,
XYZ123...)
- Access Key ID (e.g.,
- Store securely in password manager
Update production .env file (via Forge or SSH):
FILESYSTEM_DISK=s3
AVATAR_MIN_SIZE=400
# Digital Ocean Spaces Configuration
AWS_ACCESS_KEY_ID=DO00ABC...
AWS_SECRET_ACCESS_KEY=XYZ123...
AWS_DEFAULT_REGION=nyc3
AWS_BUCKET=codex-avatars-prod
AWS_ENDPOINT=https://nyc3.digitaloceanspaces.com
AWS_URL=https://codex-avatars-prod.nyc3.cdn.digitaloceanspaces.comReplace:
DO00ABC...→ Your actual Access Key IDXYZ123...→ Your actual Secret Access Keynyc3→ Your chosen regioncodex-avatars-prod→ Your bucket name
Via Forge:
- Push code to repository
- Trigger deployment via Forge dashboard
- Deployment script will run automatically
Via Git (Manual):
ssh forge@guac
cd /home/forge/m.academy
git pull origin mainssh forge@guac
cd /home/forge/m.academy
npm installThis installs: Cropper.js dependency
npm run buildBuilds: JavaScript bundle with avatarUpload component and Cropper.js
php artisan migrateAdds: avatar_path column to users table
Verify migration:
php artisan migrate:statusLook for: add_avatar_path_to_users_table in "Ran" list
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cacheOr use Forge: Click "Restart PHP-FPM" in site management
Check DO Spaces connection:
php artisan tinkerStorage::disk('s3')->put('test.txt', 'Hello World');
Storage::disk('s3')->exists('test.txt'); // Should return true
Storage::disk('s3')->delete('test.txt');If connection fails:
- Verify credentials in
.env - Check bucket name matches
- Verify region matches
- Ensure bucket is not restricted by IP
- Log into production site
- Click user avatar in top-right
- Click "Edit Avatar"
- Upload a test image (>400x400, <2MB)
- Crop and click "Save Avatar"
- Verify page reloads with new avatar
- Check Digital Ocean Spaces dashboard:
- File should exist:
avatars/{userId}/400.webp
- File should exist:
- Test dynamic sizes:
- Visit
/avatar/{userId}/48/ - Visit
/avatar/{userId}/96/ - Verify cached files created in Spaces
- Visit
- Test remove:
- Click avatar → "Edit Avatar" → "Remove Avatar"
- Verify reverts to Gravatar
Laravel logs:
tail -f storage/logs/laravel.logLook for:
- Any errors during upload
- Storage connection issues
- Image processing errors
Common issues:
- "Class 'Intervention\Image\ImageManager' not found": Run
composer install - "Unable to write to storage": Check storage permissions
- "S3 credentials invalid": Verify
.envconfiguration
Test CDN caching:
- Request avatar:
/avatar/1/96/ - Check response headers:
X-Cache: MISS(first request)
- Request again:
X-Cache: HIT(cached at CDN edge)
Verify WebP format:
curl -I https://m.academy/avatar/1/96/Should show: Content-Type: image/webp
-
Revert code:
git revert HEAD git push origin main
-
Rollback migration:
php artisan migrate:rollback --step=1
-
Clear caches:
php artisan optimize:clear
-
Previous avatar system (Gravatar) will work automatically since
avatar_pathcolumn will be null
The avatar system uses a store-once, serve-many architecture where uploads are processed to a single 400x400 WebP master image, and requested sizes are generated on-demand with CDN caching for optimal performance.
Format: WebP at 85% quality
Resolution: Exactly 400x400px (resized from upload)
Path: avatars/{userId}/400.webp
Processing Flow:
User uploads 800x800 JPG → Crop to selection → Resize to 400x400 → Convert to WebP (85%) → Store
User uploads 600x600 PNG → Crop to selection → Resize to 400x400 → Convert to WebP (85%) → Store
User uploads 400x400 JPG → Crop to selection → Keep at 400x400 → Convert to WebP (85%) → Store
On-demand creation: Requested sizes are generated from 400x400 master only when first requested
Caching strategy: Generated sizes are stored alongside master for subsequent requests
Common sizes:
- 32x32 (navigation icons)
- 48x48 (standard avatar)
- 64x64 (retina navigation @2x)
- 96x96 (retina standard @2x)
- 192x192 (large displays, retina large @2x)
Example:
First request: GET /avatar/1/48/
→ Check: avatars/1/48.webp exists? NO
→ Load avatars/1/400.webp
→ Resize to 48x48
→ Save as avatars/1/48.webp
→ Serve image
Second request: GET /avatar/1/48/
→ Check: avatars/1/48.webp exists? YES
→ Serve cached image (fast)
1. Browser: GET /avatar/1/96/
2. Route: /avatar/{user}/{size?}/
3. Controller: AvatarController::serve(user: 1, size: 96)
4. Check: User has avatar_path? YES
5. Check: avatars/1/96.webp exists?
→ YES: Serve from storage/CDN
→ NO: Generate from 400.webp, save, serve
6. Response: image/webp with cache headers
7. CDN: Cache at edge for global delivery
1. Browser: GET /avatar/1/96/
2. Route: /avatar/{user}/{size?}/
3. Controller: AvatarController::serve(user: 1, size: 96)
4. Check: User has avatar_path? NO
5. Generate Gravatar URL with size parameter
6. Response: 302 Redirect to Gravatar
7. Browser: Follows redirect to Gravatar CDN
1. User clicks avatar → "Edit Avatar"
2. Modal opens (Alpine.js component)
3. User selects file (JPG/PNG, max 2MB)
4. Client validates: >= 400x400, <= 2MB
5. Preview loads in Cropper.js canvas
6. User adjusts crop area (1:1 square)
7. User clicks "Save Avatar"
8. JavaScript gets crop coordinates (x, y, width, height)
9. Creates FormData with file + crop data
10. POST to /api/profile/avatar/
11. AvatarController::store() receives request
12. Validates: required, image, mimes:jpeg,jpg,png, max:2048
13. Validates: dimensions min:400,400
14. Calls AvatarService::uploadOriginal()
→ Load image with Intervention Image
→ Apply crop coordinates
→ Resize to 400x400
→ Convert to WebP at 85% quality
→ Save to storage disk
15. Updates user.avatar_path in database
16. Returns success JSON response
17. Client reloads page (new avatar appears)
- Disk:
public(Laravel's local public disk) - Path:
storage/app/public/avatars/ - Access: Via symlink
/storage/avatars/ - CDN: None
- Cost: Free
- Speed: Fast (local filesystem)
- Disk:
s3(S3-compatible via Laravel Flysystem) - Service: Digital Ocean Spaces
- Path:
s3://{bucket}/avatars/ - Access: Via HTTPS URL
- CDN: Spaces CDN (included, global edge caching)
- Cost: $5/month (250GB storage + 1TB transfer)
- Speed: Fast (CDN edge cache after first request)
User in Europe: GET /avatar/1/48/
→ Request to origin (DO Spaces NYC)
→ 96.webp doesn't exist
→ Generate from 400.webp
→ Save to Spaces
→ Serve to CDN edge (Amsterdam)
→ CDN caches locally
→ Response to user (MISS)
→ Time: ~200-300ms
User in Europe: GET /avatar/1/48/
→ Request to CDN edge (Amsterdam)
→ Image cached at edge
→ Serve from cache
→ Response to user (HIT)
→ Time: ~20-30ms
Table: users
New Column:
avatar_path VARCHAR(255) NULLExample values:
NULL→ No custom avatar, use Gravatar"avatars/1/400.webp"→ Custom avatar exists
public function getAvatarUrlAttribute(): string
{
if ($this->avatar_path) {
// Custom avatar: route to dynamic endpoint
return route('avatar', ['user' => $this->id, 'size' => 96]);
}
// Fallback: Gravatar
return 'https://www.gravatar.com/avatar/'
. md5(strtolower(trim($this->email)))
. '?s=96&d=identicon';
}Usage in templates:
{{ auth()->user()->avatar_url }}Generates:
- With custom avatar:
/avatar/1/96 - Without custom avatar:
https://gravatar.com/avatar/{hash}?s=96&d=identicon
Already installed: intervention/image: ^3.11 in composer.json
Processing steps:
- Load:
ImageManager::imagick()->read($file) - Crop:
$image->crop($width, $height, $x, $y) - Resize:
$image->resize(400, 400) - Format:
$image->toWebp(85) - Save:
Storage::disk('s3')->put($path, $image)
Why WebP:
- 25-35% smaller than JPEG at equivalent quality
- Supports transparency (unlike JPEG)
- Universal browser support (97%+ as of 2024)
- Fast encoding/decoding
Quality Settings:
- 85%: Optimal balance of quality and file size
- 400x400: Sufficient for downscaling to display sizes (32-192px)
Client-side cropping interface:
- Aspect ratio: 1:1 (square)
- Min crop area: 400x400px
- User controls: Zoom, pan, rotate crop box
- Output: Crop coordinates sent to server
Benefits:
- User sees exactly what will be cropped
- No surprises after upload
- Responsive preview
- Mobile-friendly touch controls
Client-side (UX, not security):
- File type: JPG, PNG only
- Max size: 2MB
- Min dimensions: 400x400px
Server-side (actual security):
$request->validate([
'file' => 'required|image|mimes:jpeg,jpg,png|max:2048',
'crop_x' => 'required|integer|min:0',
'crop_y' => 'required|integer|min:0',
'crop_width' => 'required|integer|min:400',
'crop_height' => 'required|integer|min:400',
]);Additional checks:
- Intervention Image validates image integrity
- Laravel's
mimesrule checks MIME type AND extension - WebP conversion strips metadata (EXIF, GPS)
Upload/Delete: Require authentication
Route::middleware('auth')->group(function () {
Route::post('/profile/avatar/', ...);
Route::delete('/profile/avatar/', ...);
});Serve: Public (avatars visible to all)
Route::get('/avatar/{user}/{size?}/', ...); // No authAuthorization: Users can only modify their own avatar
// In controller
$user = auth()->user(); // Not $request->user_idDevelopment (public disk):
- Files:
0644(readable by web server) - Directories:
0755
Production (DO Spaces):
- Bucket: Private (files not listable)
- Files: Served via signed URLs or public-read
- CDN: Caches only, no direct bucket access
Average upload time (400x400 image):
- Client validation: <10ms
- Upload to server: 100-500ms (depends on connection)
- Server processing: 50-150ms
- Intervention Image crop/resize: 20-50ms
- WebP encoding: 20-50ms
- Upload to DO Spaces: 10-50ms
- Total: 150-650ms
First request (cache MISS):
- Load 400.webp: 10-30ms (local) or 50-100ms (Spaces)
- Resize to requested size: 10-30ms
- Save cached size: 5-20ms
- Serve: 10-50ms
- Total: 35-230ms
Cached request (cache HIT):
- Serve from storage: 5-20ms (local)
- Serve from CDN edge: 10-40ms (global)
- Total: 5-40ms
Per user (example with common sizes):
- 400.webp: ~15-30KB
- 48.webp: ~2-4KB
- 96.webp: ~5-8KB
- 192.webp: ~10-15KB
- Total: ~32-57KB per user
For 10,000 users: ~320-570MB (well under 250GB Spaces limit)
-
database/migrations/XXXX_XX_XX_XXXXXX_add_avatar_path_to_users_table.php- Adds
avatar_pathnullable string column to users table - Stores path to uploaded avatar (e.g.,
avatars/1/400.webp)
- Adds
-
app/Services/AvatarService.php- Handles all image processing with Intervention Image
- Methods:
uploadOriginal(),generateSize(),delete() - Converts uploads to 400x400 WebP at 85% quality
-
app/Http/Controllers/AvatarController.php- API endpoints:
store()(upload),destroy()(delete) - Public endpoint:
serve()(dynamic size generation) - Validation and error handling
- API endpoints:
-
resources/js/components/avatarUpload.js- Alpine.js component for upload modal
- Integrates Cropper.js for client-side cropping
- Handles file selection, validation, upload, remove
-
resources/views/components/modals/edit-avatar.blade.php- Modal UI for avatar upload/edit
- File input, preview area, action buttons
- Gravatar link, error messages
-
app/Models/User.php- Modified
getAvatarUrlAttribute()accessor to check for custom avatar - Added
avatar_pathto$fillablearray
- Modified
-
routes/api.php- Added
POST /api/profile/avatar/(upload) - Added
DELETE /api/profile/avatar/(delete)
- Added
-
routes/web.php- Added
GET /avatar/{user}/{size?}/(serve endpoint) - Named route:
avatar
- Added
-
resources/views/components/ui/user-avatar.blade.php- Added "Edit Avatar" link in dropdown menu
- Updated to use route with srcset for retina support (32→64 @2x)
-
resources/views/components/navigation/user-profile.blade.php- Updated to use route with srcset for retina support (32→64 @2x)
-
resources/views/components/comments/user-avatar.blade.php- Updated to use route with srcset for retina support (48→96 @2x)
-
resources/views/components/users/active-users.blade.php- Updated to use route with srcset for retina support (40→80 @2x)
-
resources/views/components/layouts/app.blade.php- Included
<x-modals.edit-avatar />for authenticated users
- Included
resources/js/public.js- Imported
avatarUploadcomponent - Registered with Alpine.data()
- Imported
-
.env(local development)- Set
FILESYSTEM_DISK=public - Set
AVATAR_MIN_SIZE=400
- Set
-
.env(production - updated during deployment)- Set
FILESYSTEM_DISK=s3 - Added Digital Ocean Spaces credentials
- Set
-
package.json/package-lock.json- Added
cropperjsdependency
- Added
-
config/filesystems.php- S3 disk already configured, works with DO Spaces
-
composer.json- Intervention Image v3.11 already installed
-
config/database.php- No changes needed
- 400x400 minimum follows LinkedIn, Twitter, and Facebook standards
- Users familiar with avatar requirements on other platforms
- Reasonable size that most modern devices can easily capture
- Balances quality requirements with accessibility
- 400x400 source is perfect for avatar use cases (displayed at 32-96px typically)
- WebP format provides 25-35% smaller files than JPEG at equivalent quality
- On-demand size generation means storing only what's needed
- Example: 400.webp (~20KB) + common sizes (~20KB) = ~40KB total per user
- 10,000 users = ~400MB (well under DO Spaces limits)
- CDN edge caching: Avatars served from nearest edge location globally
- First request: ~100-200ms (generates and caches)
- Subsequent requests: ~20-40ms (served from CDN edge)
- Dynamic sizes: Any size can be requested without re-processing all avatars
- Quality: Always downscaling from 400x400 (never upscaling = always crisp)
- Local storage for dev: No cloud dependencies, free, fast iteration
- Production DO Spaces: Scalable, CDN included, $5/month
- Same codebase: Laravel's filesystem abstraction makes environment switching seamless
- No vendor lock-in: S3-compatible means easy migration to AWS, GCP, or other providers
- Seamless editing: Click avatar anywhere → Edit link → Modal (no navigation)
- Real-time preview: Cropper.js shows exact result before upload
- Mobile-friendly: Touch controls for crop adjustment
- Clear validation: Helpful error messages (size, dimensions, format)
- Instant gratification: Page reloads with new avatar immediately
- Easy removal: One-click revert to Gravatar
- Gravatar fallback: No custom avatar? Gravatar works automatically
- Gravatar editing: Link in modal for users who prefer Gravatar management
- No breaking changes: Existing templates continue to work (accessor handles both)
- Backwards compatible: Users without custom avatars unaffected
- srcset attributes: High-DPI displays automatically load 2x images
- No blurry avatars: 48px displays load 96px on retina (always crisp)
- Bandwidth optimized: Standard displays don't download unnecessary large images
- Future-proof: Any new display density supported via dynamic sizes
- Server-side validation: Client validation is UX, server validation is security
- Authentication required: Only logged-in users can upload/delete
- Authorization enforced: Users can only modify their own avatars
- Metadata stripping: WebP conversion removes EXIF data (GPS, camera info)
- Type validation: Laravel validates both MIME type and file extension
- Minimal infrastructure: Leverages existing Intervention Image library
- Clear separation: Service handles processing, Controller handles HTTP, Component handles UI
- Easy debugging: Each layer has single responsibility
- Scalable storage: DO Spaces handles growth, CDN handles traffic
- Cache strategy: Generated sizes cached, not regenerated
- Development: Free (local storage)
- Production: $5/month for 250GB + 1TB transfer (DO Spaces)
- CDN included: No extra cost for global edge caching
- Efficient compression: WebP reduces storage and bandwidth costs
- On-demand generation: Don't pay for storage of unused sizes
- Laravel best practices: Uses filesystem abstraction, service layer, accessors
- Modern image format: WebP is standard in 2024+
- Intervention Image v3: Latest version, actively maintained
- Alpine.js integration: Consistent with app's existing JavaScript architecture
- Cropper.js: Proven library (100K+ weekly downloads)
- Need larger avatars? Just request new size (e.g.,
/avatar/1/512/) - Want different quality? Change one constant in AvatarService
- Need to migrate storage? Change FILESYSTEM_DISK in .env
- Want to add filters? Extend AvatarService with new processing
- Need analytics? Add logging to serve() method