Image Optimization in Laravel: WebP, Responsive Images, and Automated Conversion

Images are one of the most common performance bottlenecks in Laravel applications. Storing originals is easy. Resizing on the fly is expensive. Delivering WebP to modern browsers while falling back to JPEG for older ones is the kind of thing that takes a week to build from scratch. spatie/laravel-medialibrary solves the storage and transformation layer, and with Intervention Image 3.x as the driver, you get a modern, type-safe PHP image processing API. This article covers the full pipeline: upload, conversion, optimization, and serving.

📋 Table of Contents

📦 Installation and Configuration

composer require spatie/laravel-medialibrary intervention/image-laravel
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate

Install Intervention Image Laravel adapter:

composer require intervention/image-laravel
php artisan vendor:publish --provider="Intervention\Image\Laravel\ServiceProvider"

Configure Intervention Image in config/image.php - set the driver:

// config/image.php
return [
    'driver' => \Intervention\Image\Drivers\Gd\Driver::class,
    // Or use Imagick for better quality:
    // 'driver' => \Intervention\Image\Drivers\Imagick\Driver::class,
];

In config/media-library.php, configure the disk and queue:

// config/media-library.php
return [
    'disk_name'                      => env('MEDIA_DISK', 'public'),
    'max_file_size'                  => 1024 * 1024 * 10, // 10 MB
    'queue_connection_name'          => env('QUEUE_CONNECTION', 'redis'),
    'queue_name'                     => 'media-conversions',
    'queue_conversions_by_default'   => env('QUEUE_MEDIA_CONVERSIONS', true),
    'media_model'                    => Spatie\MediaLibrary\MediaCollections\Models\Media::class,
    'image_driver'                   => Intervention\Image\Laravel\Facades\Image::class,
    'path_generator'                 => null, // We'll override this
    'ffmpeg_path'                    => env('FFMPEG_PATH', '/usr/bin/ffmpeg'),
    'ffprobe_path'                   => env('FFPROBE_PATH', '/usr/bin/ffprobe'),
];

🔗 HasMedia Trait - Attaching Files to Models

Any Eloquent model can have media. Add HasMedia interface and InteractsWithMedia trait:

// app/Models/Post.php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;

class Post extends Model implements HasMedia
{
    use InteractsWithMedia;

    protected $fillable = ['title', 'content', 'status'];

    protected function casts(): array
    {
        return [
            'published_at' => 'datetime',
        ];
    }

    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('featured-image')
            ->singleFile()                          // Only one featured image per post
            ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
            ->withResponsiveImages();               // Auto-generate srcset variants

        $this->addMediaCollection('gallery')
            ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
    }

    public function registerMediaConversions(Media $media = null): void
    {
        $this->addMediaConversion('thumb')
            ->width(400)
            ->height(300)
            ->sharpen(10)
            ->format('webp')
            ->performOnCollections('featured-image', 'gallery');

        $this->addMediaConversion('hero')
            ->width(1200)
            ->height(630)
            ->format('webp')
            ->performOnCollections('featured-image');

        $this->addMediaConversion('og-image')
            ->width(1200)
            ->height(630)
            ->format('jpg')
            ->quality(85)
            ->performOnCollections('featured-image');
    }
}

Uploading a file to a collection:

// app/Http/Controllers/Api/v1/PostController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Api\ApiController;
use App\Http\Requests\PostStoreRequest;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends ApiController
{
    public function store(PostStoreRequest $request): PostResource
    {
        $post = Post::create($request->validated());

        if ($request->hasFile('featured_image')) {
            $post
                ->addMediaFromRequest('featured_image')
                ->usingName($request->validated('title'))
                ->toMediaCollection('featured-image');
        }

        return new PostResource($post->load('media'));
    }

    public function addGalleryImage(Request $request, Post $post): PostResource
    {
        $request->validate([
            'image' => ['required', 'image', 'max:10240'],
        ]);

        $post
            ->addMediaFromRequest('image')
            ->toMediaCollection('gallery');

        return new PostResource($post->load('media'));
    }
}

Uploading from URL or path (useful for imports):

// From URL
$post->addMediaFromUrl('https://example.com/image.jpg')
    ->usingName('Imported Image')
    ->toMediaCollection('gallery');

// From local path
$post->addMedia('/tmp/uploaded-image.jpg')
    ->preservingOriginal()
    ->toMediaCollection('gallery');

// From base64 string
$post->addMediaFromBase64($base64String)
    ->usingFileName('profile.jpg')
    ->toMediaCollection('gallery');

🖼️ Defining Conversions - Resize, WebP, Thumbnail

Conversions are defined in registerMediaConversions() on the model. Each conversion generates a separate file on disk when the media is added (or when you run the artisan command).

// app/Models/Product.php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\MediaLibrary\Conversions\Conversion;

class Product extends Model implements HasMedia
{
    use InteractsWithMedia;

    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('product-images')
            ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
            ->withResponsiveImages();
    }

    public function registerMediaConversions(Media $media = null): void
    {
        // Small thumbnail for grid views
        $this->addMediaConversion('thumb')
            ->width(300)
            ->height(300)
            ->fit(\Spatie\Image\Enums\Fit::Crop)
            ->format('webp')
            ->quality(80)
            ->performOnCollections('product-images');

        // Medium for product listings
        $this->addMediaConversion('medium')
            ->width(600)
            ->height(600)
            ->fit(\Spatie\Image\Enums\Fit::Contain)
            ->background('ffffff')
            ->format('webp')
            ->quality(85)
            ->performOnCollections('product-images');

        // Large for product detail page
        $this->addMediaConversion('large')
            ->width(1200)
            ->height(1200)
            ->fit(\Spatie\Image\Enums\Fit::Contain)
            ->background('ffffff')
            ->format('webp')
            ->quality(90)
            ->performOnCollections('product-images');

        // JPEG fallback for email/OG tags
        $this->addMediaConversion('og')
            ->width(1200)
            ->height(630)
            ->fit(\Spatie\Image\Enums\Fit::Crop)
            ->format('jpg')
            ->quality(85)
            ->performOnCollections('product-images');
    }
}

Accessing converted images:

$product = Product::find(1);

// Original
$product->getFirstMediaUrl('product-images');

// Specific conversion
$product->getFirstMediaUrl('product-images', 'thumb');
$product->getFirstMediaUrl('product-images', 'medium');

// Get the Media model to access all conversions
$media = $product->getFirstMedia('product-images');
$media->getUrl('thumb');
$media->getUrl('large');
$media->getPath('thumb'); // Disk path, not URL

// Check if conversion is generated
$media->hasGeneratedConversion('thumb'); // bool

Regenerating conversions after changing conversion definitions:

# Regenerate all conversions for all models
php artisan media-library:regenerate

# For a specific model
php artisan media-library:regenerate --model="\App\Models\Product"

# For a specific media ID
php artisan media-library:regenerate --ids=1,2,3

⚙️ Intervention Image 3.x as the Driver

Intervention Image 3.x introduced a new driver architecture with separate GD and Imagick drivers. The API is clean, chainable, and fully typed.

The medialibrary package uses Intervention Image internally for conversions. You can also use it directly for custom manipulation before adding to the library:

// app/Services/ImageProcessingService.php
<?php

declare(strict_types=1);

namespace App\Services;

use Intervention\Image\Laravel\Facades\Image;

class ImageProcessingService
{
    public function processAvatar(string $sourcePath, string $targetPath): void
    {
        Image::read($sourcePath)
            ->cover(400, 400)                    // Crop to square, centered
            ->sharpen(5)
            ->toWebp(quality: 90)
            ->save($targetPath);
    }

    public function addWatermark(string $sourcePath, string $watermarkPath, string $targetPath): void
    {
        $image     = Image::read($sourcePath);
        $watermark = Image::read($watermarkPath)->scale(width: 200);

        $image
            ->place($watermark, 'bottom-right', offsetX: 15, offsetY: 15)
            ->toJpeg(quality: 85)
            ->save($targetPath);
    }

    public function generatePlaceholder(int $width, int $height, string $color = 'e5e7eb'): string
    {
        return Image::create($width, $height)
            ->fill($color)
            ->toJpeg()
            ->toDataUri();
    }
}

Custom conversion using Intervention Image directly:

// app/Models/User.php
use Spatie\MediaLibrary\Conversions\Conversion;

public function registerMediaConversions(Media $media = null): void
{
    $this->addMediaConversion('avatar')
        ->width(200)
        ->height(200)
        ->fit(\Spatie\Image\Enums\Fit::Crop)
        ->format('webp')
        ->quality(85)
        ->performOnCollections('avatars');
}

📁 Custom Disk and Path Strategy

By default, media is stored at {model_type}/{model_id}/{media_id}/{filename}. You can customize this with a PathGenerator:

// app/Media/CustomPathGenerator.php
<?php

declare(strict_types=1);

namespace App\Media;

use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;

class CustomPathGenerator implements PathGenerator
{
    public function getPath(Media $media): string
    {
        // Organize by collection and date for easier management
        $date = $media->created_at->format('Y/m');
        return "{$media->collection_name}/{$date}/{$media->id}/";
    }

    public function getPathForConversions(Media $media): string
    {
        $date = $media->created_at->format('Y/m');
        return "{$media->collection_name}/{$date}/{$media->id}/conversions/";
    }

    public function getPathForResponsiveImages(Media $media): string
    {
        $date = $media->created_at->format('Y/m');
        return "{$media->collection_name}/{$date}/{$media->id}/responsive/";
    }
}

Register in config/media-library.php:

'path_generator' => \App\Media\CustomPathGenerator::class,

Using S3 for production:

// config/filesystems.php
's3' => [
    'driver'   => 's3',
    'key'      => env('AWS_ACCESS_KEY_ID'),
    'secret'   => env('AWS_SECRET_ACCESS_KEY'),
    'region'   => env('AWS_DEFAULT_REGION'),
    'bucket'   => env('AWS_BUCKET'),
    'url'      => env('AWS_URL'),
    'endpoint' => env('AWS_ENDPOINT'),
    'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
],
// .env
MEDIA_DISK=s3

With the s3 disk, all media files and conversions are stored in S3 and URLs are public CDN URLs automatically.

🔄 Generating Conversions in Queue Jobs

With 'queue_conversions_by_default' => true, conversions are generated asynchronously by a queue worker. This means after upload, the original is immediately available but conversions take a moment.

The queue job is Spatie\MediaLibrary\Conversions\Jobs\PerformConversions. Run a dedicated worker for it:

php artisan queue:work redis --queue=media-conversions

Checking conversion status from the API:

// app/Http/Resources/MediaResource.php
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class MediaResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'          => $this->id,
            'name'        => $this->name,
            'file_name'   => $this->file_name,
            'mime_type'   => $this->mime_type,
            'size'        => $this->size,
            'original'    => $this->getUrl(),
            'conversions' => [
                'thumb'  => $this->hasGeneratedConversion('thumb')
                    ? $this->getUrl('thumb')
                    : null,
                'medium' => $this->hasGeneratedConversion('medium')
                    ? $this->getUrl('medium')
                    : null,
                'large'  => $this->hasGeneratedConversion('large')
                    ? $this->getUrl('large')
                    : null,
            ],
            'conversions_ready' => $this->hasGeneratedConversion('thumb')
                && $this->hasGeneratedConversion('medium'),
        ];
    }
}

Manually dispatching conversions:

// Force immediate conversion generation (bypass the queue)
$media->performConversions();

// Or dispatch to a specific queue
\Spatie\MediaLibrary\Conversions\Jobs\PerformConversions::dispatch(
    $media,
    $media->model->getMediaConversions()
)->onQueue('high-priority');

🌐 Responsive Images - srcset Without Extra Work

Adding ->withResponsiveImages() to a collection instructs the library to generate multiple size variants for srcset. Browsers then download the appropriate size based on the viewport and device pixel ratio.

// In registerMediaCollections()
$this->addMediaCollection('featured-image')
    ->withResponsiveImages();

This generates variants at widths like 340, 510, 680, 1020, 1360, 2040... pixels (based on the original dimensions). They're stored alongside the original and conversions.

Using responsive images in Blade:

{{-- resources/views/posts/show.blade.php --}}
@if($post->hasMedia('featured-image'))
    {{ $post->getFirstMedia('featured-image')->img('hero', ['class' => 'w-full', 'alt' => $post->title]) }}
@endif

This generates:

<img
    src="https://cdn.example.com/featured-image/2026/03/1/hero.webp"
    srcset="
        https://cdn.example.com/featured-image/2026/03/1/responsive/hero___340.jpg 340w,
        https://cdn.example.com/featured-image/2026/03/1/responsive/hero___510.jpg 510w,
        https://cdn.example.com/featured-image/2026/03/1/responsive/hero___680.jpg 680w
    "
    sizes="(max-width: 768px) 100vw, 1200px"
    class="w-full"
    alt="My Post Title"
>

In API responses (for React/Vue frontends):

// app/Http/Resources/PostResource.php
'featured_image' => $this->getFirstMedia('featured-image') ? [
    'url'      => $this->getFirstMediaUrl('featured-image', 'hero'),
    'thumb'    => $this->getFirstMediaUrl('featured-image', 'thumb'),
    'srcset'   => $this->getFirstMedia('featured-image')->getSrcset('hero'),
    'alt'      => $this->title,
] : null,

🚀 Senior Twist: WebP with JPEG Fallback - Serving the Right Format

WebP offers 25-35% smaller file sizes than JPEG at the same visual quality. But some older browsers (and some email clients) don't support WebP. The solution: generate both formats and serve the right one.

Strategy 1: Generate both conversions and choose in code:

// In registerMediaConversions()
$this->addMediaConversion('hero-webp')
    ->width(1200)
    ->height(630)
    ->format('webp')
    ->quality(85)
    ->performOnCollections('featured-image');

$this->addMediaConversion('hero-jpg')
    ->width(1200)
    ->height(630)
    ->format('jpg')
    ->quality(85)
    ->performOnCollections('featured-image');
// In API resource - let the client pick
'featured_image' => [
    'webp' => $this->getFirstMediaUrl('featured-image', 'hero-webp'),
    'jpg'  => $this->getFirstMediaUrl('featured-image', 'hero-jpg'),
],

The React/Vue frontend uses the <picture> element:

<picture>
    <source type="image/webp" :srcset="post.featured_image.webp" />
    <img :src="post.featured_image.jpg" :alt="post.title" />
</picture>

Strategy 2: Serve via Nginx with Accept header negotiation:

# nginx.conf
location ~* \.(jpg|jpeg|png)$ {
    add_header Vary Accept;

    # Check if WebP version exists and browser accepts WebP
    if ($http_accept ~* "webp") {
        rewrite ^(.*)\.(jpg|jpeg|png)$ $1.webp break;
        add_header Content-Type image/webp;
    }
}

This requires your conversions to be named with the same filename but .webp extension - which medialibrary does by default when you use ->format('webp').

Strategy 3: Return the right format from the controller based on Accept header:

// app/Http/Controllers/Api/v1/MediaController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Api\ApiController;
use App\Models\Post;
use Illuminate\Http\Request;

class MediaController extends ApiController
{
    public function featuredImage(Request $request, Post $post): \Illuminate\Http\JsonResponse
    {
        $acceptsWebP = str_contains($request->header('Accept', ''), 'image/webp');

        $conversion = $acceptsWebP ? 'hero-webp' : 'hero-jpg';

        return $this->success('Image URL', [
            'url'    => $post->getFirstMediaUrl('featured-image', $conversion),
            'format' => $acceptsWebP ? 'webp' : 'jpg',
        ]);
    }
}

Image optimization tips:

  • Use ->quality(80) for WebP - 80 is visually lossless for most images
  • Use ->quality(85) for JPEG - 85 is the sweet spot for quality vs size
  • Use ->sharpen(5) after resizing - resizing softens images, a light sharpen compensates
  • Use ->fit(Fit::Crop) for thumbnails where dimensions must be exact, Fit::Contain for product images to avoid cropping
  • Enable ->withResponsiveImages() only for hero/featured images - generating 8 srcset variants for each gallery image multiplies storage costs

✅ Summary

  • Install spatie/laravel-medialibrary + intervention/image-laravel; configure GD or Imagick driver based on server capabilities
  • Add HasMedia interface + InteractsWithMedia trait to any model; define collections in registerMediaCollections() and conversions in registerMediaConversions()
  • Use ->singleFile() for profile pictures and featured images; omit it for gallery collections
  • Define named conversions per collection (thumb, medium, large, og) with explicit format and quality - don't rely on defaults
  • Set 'queue_conversions_by_default' => true and run a dedicated media-conversions queue worker; expose hasGeneratedConversion() in API responses so clients know when to render
  • Use ->withResponsiveImages() on hero/featured image collections to get srcset for free
  • Generate both webp and jpg conversions; use <picture> on the frontend or Nginx Accept header negotiation to serve the right format
  • Use a custom PathGenerator to organize media by date instead of the default model-type/model-id structure

Follow me on LinkedIn for more Laravel tips! Are you serving WebP in production? What's your image optimization pipeline? Let me know in the comments below!

Comments (0)
Leave a comment

© 2026 All rights reserved.