Skip to content

Instantly share code, notes, and snippets.

@markshust
Created November 19, 2025 18:44
Show Gist options
  • Select an option

  • Save markshust/ee5939a2248d3d66f93618e677acecd9 to your computer and use it in GitHub Desktop.

Select an option

Save markshust/ee5939a2248d3d66f93618e677acecd9 to your computer and use it in GitHub Desktop.
requirements for custom user avatar uploads

User Avatar Upload

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.

Feature Description

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.

Requirements Summary

Core Requirements

  • 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

User Experience

  • 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)

Technical Requirements

  • 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

Phases

Phase 1: Database & Backend Foundation

Database Schema

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)

User Model Updates

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',
];

Avatar Service

File: app/Services/AvatarService.php (new)

This service handles all image processing using Intervention Image v3 (already installed).

Method: uploadOriginal($file, $cropData, $userId)

Purpose: Process uploaded file and store as 400x400 WebP

Steps:

  1. Validate minimum dimensions (400x400)
  2. Load uploaded image with Intervention Image
  3. Apply crop coordinates from Cropper.js:
    • Extract x, y, width, height from $cropData
    • Crop image: $image->crop($width, $height, $x, $y)
  4. Resize cropped area to exactly 400x400:
    • $image->resize(400, 400)
  5. Convert to WebP format at 85% quality:
    • $image->toWebp(85)
  6. Save to storage disk as avatars/{userId}/400.webp
  7. 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

Method: generateSize($userId, $size)

Purpose: Generate requested size from 400x400 master

Steps:

  1. Load master image from avatars/{userId}/400.webp
  2. Resize to {size}x{size} square (downscale from 400x400)
  3. Convert to WebP at 85% quality
  4. Save to avatars/{userId}/{size}.webp
  5. Return path to generated image

Common Sizes: 32, 48, 64, 96, 128, 192

Method: delete($userId)

Purpose: Remove all avatar versions for user

Steps:

  1. Get all files in avatars/{userId}/ directory
  2. Delete each file:
    • 400.webp (master)
    • 48.webp, 96.webp, etc. (cached sizes)
  3. Delete directory: avatars/{userId}/
  4. Return success

Avatar Controller

File: app/Http/Controllers/AvatarController.php (new)

Method: store(Request $request)

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:

  1. Extract file and crop data from request
  2. Call AvatarService::uploadOriginal($file, $cropData, auth()->id())
  3. Update authenticated user's avatar_path column
  4. Return success response with new avatar URL

Response (JSON):

{
    "success": true,
    "avatar_url": "/avatar/1/96",
    "message": "Avatar uploaded successfully"
}

Method: destroy(Request $request)

Purpose: Remove custom avatar via API

Processing:

  1. Get authenticated user
  2. Call AvatarService::delete(auth()->id())
  3. Set user's avatar_path to null
  4. Return success response

Response (JSON):

{
    "success": true,
    "message": "Avatar removed successfully"
}

Method: serve(User $user, int $size = 96)

Purpose: Serve avatar at requested size (public endpoint)

Flow:

  1. Check if user has custom avatar (avatar_path is not null)
  2. If no custom avatar:
    • Generate Gravatar hash: md5(strtolower(trim($user->email)))
    • Redirect to: https://gravatar.com/avatar/{hash}?s={size}&d=identicon
  3. 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
  4. Set response headers:
    • Content-Type: image/webp
    • Cache-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)

Routes

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');

Storage Configuration

Development Environment (.env):

FILESYSTEM_DISK=public
AVATAR_MIN_SIZE=400
  • Uses Laravel's public disk
  • 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.com

Note: config/filesystems.php already has S3 disk configured, no changes needed.

Phase 1 Checklist

  • Create migration: add_avatar_path_to_users_table.php
  • Add avatar_path nullable string column to users table
  • Run migration locally: php artisan migrate
  • Update User.php: Modify getAvatarUrlAttribute() accessor
  • Update User.php: Add avatar_path to $fillable array
  • 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%)

Phase 2: Frontend - Image Cropping

Install Cropper.js

Command:

npm install cropperjs

Import CSS: Add to component or global stylesheet

import 'cropperjs/dist/cropper.css';

Alpine.js Component

File: resources/js/components/avatarUpload.js (new)

Component Structure

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() { /* ... */ },
    }
}

State Properties

  • 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)

Method: init()

Purpose: Initialize component when Alpine loads

Actions:

  • Set up any event listeners
  • Initialize state
  • Called automatically by Alpine.js

Method: selectFile(event)

Purpose: Handle file selection from input

Steps:

  1. Get file from event: event.target.files[0]
  2. Clear previous error
  3. 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
  4. 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);

Method: initCropper()

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,
});

Method: upload()

Purpose: Upload cropped avatar to API

Steps:

  1. Set uploading = true
  2. Get crop data from Cropper.js:
    const cropData = this.cropper.getData();
    // Returns: { x, y, width, height, rotate, scaleX, scaleY }
  3. 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));
  4. POST to /api/profile/avatar/:
    fetch('/api/profile/avatar/', {
        method: 'POST',
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        },
        body: formData,
    })
  5. Handle response:
    • Success: Reload page (window.location.reload())
    • Error: Display error message, set uploading = false

Method: remove()

Purpose: Delete custom avatar, revert to Gravatar

Steps:

  1. Confirm with user:
    if (!confirm('Remove your custom avatar and use Gravatar instead?')) return;
  2. DELETE to /api/profile/avatar/:
    fetch('/api/profile/avatar/', {
        method: 'DELETE',
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        },
    })
  3. Handle response:
    • Success: Reload page (window.location.reload())
    • Error: Display error message

Method: closeModal()

Purpose: Clean up and close modal

Steps:

  1. Destroy cropper instance: if (this.cropper) this.cropper.destroy()
  2. Revoke object URL: if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
  3. Reset state:
    this.selectedFile = null;
    this.previewUrl = null;
    this.cropper = null;
    this.error = null;
  4. Dispatch close event: this.$dispatch('close-edit-avatar')

Component Registration

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

Phase 2 Checklist

  • 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)

Phase 3: UI Components

Avatar Edit Modal

File: resources/views/components/modals/edit-avatar.blade.php (new)

Modal Structure

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>

Validation Messages

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

Modal Behavior

  • Open: Triggered by @open-edit-avatar event 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)

User Avatar Component Update

File: resources/views/components/ui/user-avatar.blade.php

Add "Edit Avatar" Link

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>

Accessibility

  • 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"

Include Modal in Layout

File: resources/views/components/layouts/app.blade.php

Add before closing </body> tag:

@auth
    <x-modals.edit-avatar />
@endauth

Purpose: Makes modal globally available for authenticated users across all pages

Note: Modal is hidden by default, shown only when triggered by event

Phase 3 Checklist

  • Create resources/views/components/modals/edit-avatar.blade.php
  • Implement modal structure using x-modal.dialog component
  • 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

Phase 4: Template Updates for Retina Support

Update Avatar Displays

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.

Standard Pattern

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"
>

Size Mappings

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

Files to Update

1. Navigation - User Avatar (Dropdown)

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"
>

2. Mobile Navigation - User Profile

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"
>

3. Comments - User Avatar

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.

4. Admin Dashboard - Active Users

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 for Additional Locations

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"

Phase 4 Checklist

  • 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_url usage in views
  • Search for additional gravatar usage 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)

Deployment Steps

These steps should be performed by a human deployer, not the coding agent.

Production Preparation

1. Create Digital Ocean Spaces Bucket

Steps:

  1. Log into Digital Ocean dashboard
  2. Navigate to Spaces
  3. Click "Create Space"
  4. Configuration:
    • Name: codex-avatars-prod
    • Region: Choose closest to users (e.g., NYC3, SFO3, AMS3)
    • CDN: Enable (automatic, no extra cost)
    • Files Listing: Private
  5. Click "Create Space"
  6. Note the CDN endpoint URL (e.g., https://codex-avatars-prod.nyc3.cdn.digitaloceanspaces.com)

2. Create Spaces API Key

Steps:

  1. Navigate to API → Tokens/Keys → Spaces Keys
  2. Click "Generate New Key"
  3. Name: "Codex Production Avatars"
  4. Click "Generate Key"
  5. IMPORTANT: Save both values immediately (shown only once):
    • Access Key ID (e.g., DO00ABC...)
    • Secret Access Key (e.g., XYZ123...)
  6. Store securely in password manager

3. Configure Production Environment

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.com

Replace:

  • DO00ABC... → Your actual Access Key ID
  • XYZ123... → Your actual Secret Access Key
  • nyc3 → Your chosen region
  • codex-avatars-prod → Your bucket name

Deployment Process

4. Deploy Code to Production

Via Forge:

  1. Push code to repository
  2. Trigger deployment via Forge dashboard
  3. Deployment script will run automatically

Via Git (Manual):

ssh forge@guac
cd /home/forge/m.academy
git pull origin main

5. Install NPM Dependencies

ssh forge@guac
cd /home/forge/m.academy
npm install

This installs: Cropper.js dependency

6. Build Frontend Assets

npm run build

Builds: JavaScript bundle with avatarUpload component and Cropper.js

7. Run Database Migration

php artisan migrate

Adds: avatar_path column to users table

Verify migration:

php artisan migrate:status

Look for: add_avatar_path_to_users_table in "Ran" list

8. Restart Services

php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache

Or use Forge: Click "Restart PHP-FPM" in site management

Post-Deployment Testing

9. Verify Production Setup

Check DO Spaces connection:

php artisan tinker
Storage::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

10. Test Avatar Upload Flow

  1. Log into production site
  2. Click user avatar in top-right
  3. Click "Edit Avatar"
  4. Upload a test image (>400x400, <2MB)
  5. Crop and click "Save Avatar"
  6. Verify page reloads with new avatar
  7. Check Digital Ocean Spaces dashboard:
    • File should exist: avatars/{userId}/400.webp
  8. Test dynamic sizes:
    • Visit /avatar/{userId}/48/
    • Visit /avatar/{userId}/96/
    • Verify cached files created in Spaces
  9. Test remove:
    • Click avatar → "Edit Avatar" → "Remove Avatar"
    • Verify reverts to Gravatar

11. Monitor Logs

Laravel logs:

tail -f storage/logs/laravel.log

Look 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 .env configuration

12. Performance Check

Test CDN caching:

  1. Request avatar: /avatar/1/96/
  2. Check response headers:
    • X-Cache: MISS (first request)
  3. 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

Rollback Plan (If Issues Occur)

If Critical Issues

  1. Revert code:

    git revert HEAD
    git push origin main
  2. Rollback migration:

    php artisan migrate:rollback --step=1
  3. Clear caches:

    php artisan optimize:clear
  4. Previous avatar system (Gravatar) will work automatically since avatar_path column will be null


Technical Architecture Summary

Overview

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.

Storage Architecture

Master Storage

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

Dynamic Size Generation

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)

Request Flow

With Custom Avatar

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

Without Custom Avatar (Gravatar Fallback)

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

Upload Workflow

Client-Side (Browser)

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/

Server-Side (Laravel)

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)

Environment-Specific Storage

Development

  • 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)

Production

  • 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)

CDN Caching Strategy

First Request (MISS)

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

Subsequent Requests (HIT)

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

Data Model

Database Schema

Table: users

New Column:

avatar_path VARCHAR(255) NULL

Example values:

  • NULL → No custom avatar, use Gravatar
  • "avatars/1/400.webp" → Custom avatar exists

User Model Accessor

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

Image Processing Pipeline

Intervention Image v3

Already installed: intervention/image: ^3.11 in composer.json

Processing steps:

  1. Load: ImageManager::imagick()->read($file)
  2. Crop: $image->crop($width, $height, $x, $y)
  3. Resize: $image->resize(400, 400)
  4. Format: $image->toWebp(85)
  5. 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)

Cropper.js

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

Security Considerations

Upload Validation

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 mimes rule checks MIME type AND extension
  • WebP conversion strips metadata (EXIF, GPS)

Authentication

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 auth

Authorization: Users can only modify their own avatar

// In controller
$user = auth()->user(); // Not $request->user_id

Storage Permissions

Development (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

Performance Characteristics

Upload Performance

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

Serve Performance

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

Storage Requirements

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)


Files Created/Modified Summary

New Files (5)

Backend (3)

  1. database/migrations/XXXX_XX_XX_XXXXXX_add_avatar_path_to_users_table.php

    • Adds avatar_path nullable string column to users table
    • Stores path to uploaded avatar (e.g., avatars/1/400.webp)
  2. app/Services/AvatarService.php

    • Handles all image processing with Intervention Image
    • Methods: uploadOriginal(), generateSize(), delete()
    • Converts uploads to 400x400 WebP at 85% quality
  3. app/Http/Controllers/AvatarController.php

    • API endpoints: store() (upload), destroy() (delete)
    • Public endpoint: serve() (dynamic size generation)
    • Validation and error handling

Frontend (2)

  1. resources/js/components/avatarUpload.js

    • Alpine.js component for upload modal
    • Integrates Cropper.js for client-side cropping
    • Handles file selection, validation, upload, remove
  2. resources/views/components/modals/edit-avatar.blade.php

    • Modal UI for avatar upload/edit
    • File input, preview area, action buttons
    • Gravatar link, error messages

Modified Files (9)

Backend (3)

  1. app/Models/User.php

    • Modified getAvatarUrlAttribute() accessor to check for custom avatar
    • Added avatar_path to $fillable array
  2. routes/api.php

    • Added POST /api/profile/avatar/ (upload)
    • Added DELETE /api/profile/avatar/ (delete)
  3. routes/web.php

    • Added GET /avatar/{user}/{size?}/ (serve endpoint)
    • Named route: avatar

Frontend Templates (5)

  1. 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)
  2. resources/views/components/navigation/user-profile.blade.php

    • Updated to use route with srcset for retina support (32→64 @2x)
  3. resources/views/components/comments/user-avatar.blade.php

    • Updated to use route with srcset for retina support (48→96 @2x)
  4. resources/views/components/users/active-users.blade.php

    • Updated to use route with srcset for retina support (40→80 @2x)
  5. resources/views/components/layouts/app.blade.php

    • Included <x-modals.edit-avatar /> for authenticated users

Frontend JavaScript (1)

  1. resources/js/public.js
    • Imported avatarUpload component
    • Registered with Alpine.data()

Configuration Files (3)

  1. .env (local development)

    • Set FILESYSTEM_DISK=public
    • Set AVATAR_MIN_SIZE=400
  2. .env (production - updated during deployment)

    • Set FILESYSTEM_DISK=s3
    • Added Digital Ocean Spaces credentials
  3. package.json / package-lock.json

    • Added cropperjs dependency

No Changes Required (3)

  1. config/filesystems.php

    • S3 disk already configured, works with DO Spaces
  2. composer.json

    • Intervention Image v3.11 already installed
  3. config/database.php

    • No changes needed

Key Advantages

1. Industry Standard Compliance

  • 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

2. Optimal Storage Efficiency

  • 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)

3. Performance Excellence

  • 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)

4. Development Workflow Benefits

  • 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

5. User Experience

  • 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

6. Graceful Degradation

  • 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

7. Retina Display Support

  • 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

8. Security & Privacy

  • 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

9. Maintenance & Scalability

  • 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

10. Cost Effectiveness

  • 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

11. Technical Excellence

  • 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)

12. Flexibility for Future Growth

  • 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment