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
- 🔗 HasMedia Trait - Attaching Files to Models
- 🖼️ Defining Conversions - Resize, WebP, Thumbnail
- ⚙️ Intervention Image 3.x as the Driver
- 📁 Custom Disk and Path Strategy
- 🔄 Generating Conversions in Queue Jobs
- 🌐 Responsive Images - srcset Without Extra Work
- 🚀 Senior Twist: WebP with JPEG Fallback - Serving the Right Format
- ✅ Summary
📦 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::Containfor 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
HasMediainterface +InteractsWithMediatrait to any model; define collections inregisterMediaCollections()and conversions inregisterMediaConversions() - 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' => trueand run a dedicatedmedia-conversionsqueue worker; exposehasGeneratedConversion()in API responses so clients know when to render - Use
->withResponsiveImages()on hero/featured image collections to get srcset for free - Generate both
webpandjpgconversions; use<picture>on the frontend or Nginx Accept header negotiation to serve the right format - Use a custom
PathGeneratorto 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!