Laravel finally resizes images for you
Published July 26, 2026 · by Majd Ghithan, written with the help of AI
Every Laravel app I've built that takes an avatar ended the same way: composer require intervention/image, wire up a facade, write a little service class, and hope the next dev remembers it's there. As of Laravel 13.20 that whole ritual is gone. Image processing is first-party now — a real Illuminate\Image component with a clean, Laravel-shaped API.
It still uses Intervention Image under the hood, so you install that one dependency:
composer require intervention/image:^4.0
That's the only package. Everything after this is Laravel's own API.
You get an image the same way you get anything else
The nicest part is how many doors open into it. An uploaded file, a path, a URL, a storage disk, raw bytes, base64 — all one method call:
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
$image = $request->image('avatar'); // null if missing/invalid
$image = Image::fromPath('/path/to/photo.jpg');
$image = Image::fromUrl('https://example.com/photo.jpg');
$image = Image::fromStorage('uploads/photo.jpg', 's3');
$image = Storage::disk('s3')->image('uploads/photo.jpg');
$request->image('avatar') is the one you'll reach for most — it validates it's actually a decodable image and hands you null if it isn't, so you're not babysitting a broken upload.
The pipeline is immutable and lazy — that's the whole trick
Transformations don't run when you call them. They return a new image, and nothing actually happens until you ask for output. That means you can process a source once and branch it into a thumbnail and a display version without re-reading the file:
$photo = Image::fromStorage('uploads/photo.jpg')->orient();
$thumbnail = $photo->cover(300, 300)->quality(60)->toWebp();
$display = $photo->scale(width: 1600)->quality(80)->toWebp();
$thumbnail->storeAs('photos', 'photo-thumb.webp', disk: 's3');
$display->storeAs('photos', 'photo-display.webp', disk: 's3');
$photo is untouched. Each branch is its own instance.
Five ways to resize, and they actually mean different things
This is where people reach for the wrong one. The names matter:
$img = InterventionImage::make(
$request->file('avatar')
);
// crop to square by hand:
$img->fit(512, 512);
$img->encode('webp', 80);
Storage::disk('s3')->put(
$path,
(string) $img
);
$path = $request->image('avatar')
->orient() // respect EXIF
->cover(512, 512) // fill + crop
->optimize() // webp @ 70
->storePublicly(
'avatars', disk: 's3'
);
cover($w, $h)— resize and crop to fill exact dimensions. This is your avatar/thumbnail method.contain($w, $h, $bg)— fit the whole image inside, padding the gaps with a background color.scale($w, $h)— proportional resize that never upscales. Pass just a width or just a height.resize($w, $h)— forces exact dimensions, will distort. Rarely what you want.crop($w, $h, x, y)— cut a region at an offset.
The scale() "never upscales" rule is the one that saves you: loop a 900px original through [480, 960, 1440] and it stops at 900. No blurry stretched variants.
Format and quality without remembering encoder flags
$image->toWebp()->quality(80);
$image->optimize(); // webp @ quality 70 — the sane default
$image->optimize('avif', 60); // or say what you want
toWebp(), toJpg(), toPng(), toGif(), toAvif(), toBmp() are all there. optimize() is the "just give me a small modern file" shortcut.
The whole avatar handler is now one expression
public function update(Request $request)
{
$request->validate([
'avatar' => ['required', 'image', 'max:5120'],
]);
$path = $request->image('avatar')
->orient()
->cover(512, 512)
->optimize()
->storePublicly('avatars', disk: 's3');
$request->user()->update(['avatar_path' => $path]);
return back();
}
Responsive variants are a loop, and because scale() won't upscale, it's safe:
foreach ([480, 960, 1440] as $width) {
Image::fromStorage($path, 's3')
->scale(width: $width)
->toWebp()
->storeAs('photos/variants', "{$name}-{$width}w.webp", disk: 's3');
}
The one gotcha that will bite you
You cannot queue an Image instance — it's not serializable. If your resize is heavy, don't pass the image into a job. Store it first, pass the path, and re-open it inside the job:
That's the tradeoff for the lazy pipeline: it's an in-memory processing object, not a value you ship across a queue boundary.
Is it worth switching to?
If you already have Intervention wired up and working, there's no fire to put out. But for anything new, this is the better default: one dependency, an API that reads like the intent (cover, contain, scale instead of fit vs resize guesswork), storage disks built in, and optimize() doing the modern-format thing for you. It's the rare framework addition that deletes code instead of adding a layer.
