DEV Community

Cover image for Laravel's New Image API Doesn't Replace Intervention or Spatie Media Library
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Laravel's New Image API Doesn't Replace Intervention or Spatie Media Library

Originally published at hafiz.dev


Laravel 13.20 landed on 14 July with a first-party image API, and within a week the takes were everywhere. Laravel replaces Intervention Image. Drop your Spatie dependency. One less package in your composer.json.

Here's the first line of the installation section in the official docs:

composer require intervention/image:^4.0
Enter fullscreen mode Exit fullscreen mode

You cannot use Laravel's image manipulation without installing Intervention Image, short of writing your own driver from scratch. The GD and Imagick drivers are both backed by it. Illuminate\Image\Drivers\InterventionDriver is the only driver that ships. Laravel didn't replace Intervention, it adopted it and put a facade in front.

So the comparison people are reaching for doesn't hold. What you actually have to decide is which layer of a four-layer stack your particular job belongs at, and that's a more useful question anyway.

The stack, honestly

Four layers, and most of the confusion comes from treating them as competitors when three of them sit on top of each other.

At the bottom are the PHP extensions, GD and Imagick, which do the pixel work. Above them sit the manipulation libraries: intervention/image and spatie/image, each wrapping the extensions in a sane API. Above those is Illuminate\Image, Laravel's new fluent wrapper, which drives Intervention. And off to one side is spatie/laravel-medialibrary, which builds on spatie/image and does something categorically different: attaching files to Eloquent models.

View the interactive diagram on hafiz.dev

Read that diagram and the question answers itself. If your job is "transform this image right now", you want the middle of the stack. If your job is "this Product has five photos, each needing a thumbnail and a web-sized version", you want Media Library, and Laravel 13.20 changed nothing for you.

What 13.20 actually replaces

Not a package. A class you wrote yourself.

Nearly every Laravel app that handles uploads has one. It's called ImageService or ImageManager or HandlesImageUploads, it lives in app/Services, and it's about forty lines of Intervention calls glued to a Storage::put(). Most of them were written against Intervention v3, so they look something like this:

class ImageService
{
    public function storeAvatar(UploadedFile $file): string
    {
        $manager = new ImageManager(new Driver);

        $image = $manager->read($file->getRealPath())
            ->cover(400, 400);

        $path = 'avatars/'.Str::uuid().'.webp';

        Storage::disk('public')->put($path, (string) $image->toWebp(80));

        return $path;
    }
}
Enter fullscreen mode Exit fullscreen mode

That whole class is now one chain in your controller:

$path = $request->image('avatar')
    ->cover(400, 400)
    ->toWebp()
    ->quality(80)
    ->storePublicly('avatars', 'public');
Enter fullscreen mode Exit fullscreen mode

$request->image() returns an Illuminate\Image\Image or null when the field is absent, so validate the upload first as you normally would. Every transformation returns a new instance, nothing executes until you ask for output, and the storage methods generate a unique filename and hand back the path exactly like store() does for ordinary uploads.

Deleting a service class is a small win, but it's a real one, and it's the kind of decision I've written about before in the service, action, or job decision tree. A wrapper that exists only to make a package feel Laravel-native stops earning its keep the moment the framework ships the same thing.

What it doesn't touch: Spatie Media Library

Media Library is at v11.23.2 and past 44 million installs, and none of what it does overlaps with the new API.

It associates files with Eloquent models. It registers named conversions on the model that generate automatically on upload and queue themselves by default. It generates responsive image variants along with the srcset markup to serve them. It handles multiple collections per model, per-collection disks, ordering, and a media-library:regenerate command for when you change a conversion and need to reprocess everything you've already stored.

class Product extends Model implements HasMedia
{
    use InteractsWithMedia;

    public function registerMediaConversions(?Media $media = null): void
    {
        $this->addMediaConversion('thumb')->width(368)->height(232)->sharpen(10);
        $this->addMediaConversion('web')->width(1200)->format('webp')->quality(80);
    }
}
Enter fullscreen mode Exit fullscreen mode

Try to rebuild that on top of Illuminate\Image and you're writing a media table, a conversions registry, a queue pipeline, a URL generator, and a regeneration command. That's not a weekend. Media Library is a different product that happens to also resize images.

The one honest thing to say is that some apps pulled in Media Library purely to resize an avatar and never touched a conversion or a collection. If that's you, the new API is a genuine simplification. If you have registerMediaConversions anywhere in your codebase, it isn't.

When you still drop to Intervention directly

The Laravel API covers resize, scale, cover, contain, crop, orient, rotate, blur, grayscale, sharpen, both flips, seven output formats, quality, and optimize. That's the common set, and for most apps it's everything.

What it doesn't expose is most of what makes Intervention v4 interesting:

  • Text and fonts. No text() method, no font files, no wrapping, alignment, or stroke.
  • Composition. No insert() or place(), so no watermarks and no layering one image over another.
  • Drawing. No rectangles, circles, lines, or polygons.
  • Animation. No frame-by-frame work on animated GIFs.
  • Colorspaces and ICC profiles. Nothing for print-accurate color.

Need any of those and you use Intervention directly, which you already have installed. There's no conflict in doing both: the fluent API for uploads, the library for the one endpoint that stamps a watermark.

There is a middle path worth knowing about. You can register a custom transformation as a value object and teach a driver how to apply it:

use App\Images\Transformations\Pixelate;
use Illuminate\Support\Facades\Image;
use Intervention\Image\Interfaces\ImageInterface;

Image::transformUsing('gd', Pixelate::class, function (ImageInterface $image, Pixelate $transformation) {
    return $image->pixelate($transformation->size);
});
Enter fullscreen mode Exit fullscreen mode

Your transformation class implements Illuminate\Contracts\Image\Transformation and carries its own arguments. After registering the handler in a service provider, ->transform(new Pixelate(12)) drops into any pipeline. That's how you get watermarking into the fluent chain without abandoning it.

The Cloudflare driver that never shipped

If you've read about a Cloudflare driver for this API, complete with Image::pruneOrphaned('cloudflare') on a schedule and validation rules to keep BMP files away from it, that's real code but it isn't in Laravel.

It was in the pull request during review and got pulled before merge. The PR is explicit that the Cloudflare-specific driver and support were removed to keep the first version focused on local processing through GD and Imagick via Intervention. The shipped config/images.php supports two drivers. Setting IMAGE_DRIVER=cloudflare gets you an exception, not remote processing.

Some of the write-ups circulating were based on the PR description rather than the released code, which is an easy mistake to make when a feature changes shape during review. Check php artisan config:publish images and read what's actually in the file. While you're in there, the full command list lives in the Laravel Artisan Commands reference.

The interesting part is what the removal left behind. Custom drivers are a documented extension point:

use Illuminate\Contracts\Image\Driver;
use Illuminate\Image\ImagePipeline;

class VipsDriver implements Driver
{
    public function process(string $contents, ImagePipeline $pipeline): string
    {
        // Apply the pipeline's transformations and output options...

        return $contents;
    }

    public function transformUsing(string $transformation, callable $callback): static
    {
        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register that with Image::extend('vips', fn () => new VipsDriver) and switch per image with ->using('vips') or globally with IMAGE_DRIVER=vips.

That example name isn't arbitrary. libvips is a fast, low-memory image library that outperforms both GD and Imagick, and Intervention already maintains an official driver for it in Intervention/image-driver-vips. Spatie Media Library already accepts vips as an image_driver value too. Laravel's InterventionDriver only exposes gd and imagick, so you can't reach vips through IMAGE_DRIVER today. But the hard part is done. A Laravel driver for it is a thin adapter over a maintained package, not a from-scratch build, and it's the most obvious gap in the ecosystem right now.

Three things the docs don't make obvious

The queueing advice contradicts the API. The introduction tells you to move heavy processing to a queued job, which is correct advice. But Image instances cannot be serialized, and passing one to a job throws an ImageException. You store first and pass the path, then rebuild with Image::fromStorage() inside the job. Worth knowing before you dispatch, and if you're pushing real volume through, the patterns in processing 10,000 queued tasks without breaking apply here too.

Processing is lazy and cached, which cuts both ways. Nothing runs until you call toBytes(), width(), store(), or cast to string. After that first output the result is reused, so calling width() then store() doesn't process twice. The trap is the other direction: because instances are immutable, branching off a base image to make three variants means three separate pipelines, each doing its own decode.

Storage failures return false, they don't throw. store(), storeAs(), and the public variants return false if the image couldn't be stored. If you assign the result straight to a database column you'll write a boolean into a string field and find out later. Check it.

The decision, in one table

Your job Reach for
Resize or convert an upload in a controller Illuminate\Image
Generate a few variants from one source Illuminate\Image, one pipeline each
Model has attached files with named sizes Spatie Media Library
Responsive images with srcset markup Spatie Media Library
Watermarks, text, drawing, animated GIFs Intervention Image directly
Same, but inside the fluent chain Custom Transformation class
Large volumes, memory pressure Custom driver over libvips

Memory and CPU are the constraint that decides most of this in production. If you're processing anything sizeable in the request cycle you'll hit it, and the same advice applies as with any big upload path, which I've covered in handling large file uploads without crashing your server.

My take

Use it. For the resize-an-upload case it's cleaner than what you have, it's one less abstraction you maintain, and the immutable pipeline is a better design than the mutable managers both underlying libraries expose.

The criticism raised during review deserves an airing, though. A reviewer argued that bolting image manipulation onto Request, a class already at critical mass, adds bloat, and that image processing is a resource hog that shouldn't be quite this reachable. Both points are fair. $request->image('avatar')->cover(400, 400)->store('avatars') is a very short path to doing something expensive inside a web request, and the docs' warning about queueing is one sentence in an introduction most people skim.

My rule: if the source is user-supplied and unbounded in size, it goes to a queue regardless of how convenient the one-liner looks. The API being easy doesn't make the work cheap.

The part I'd actually watch is the driver contract. A fluent, Laravel-native API where the engine underneath is swappable is more interesting than the transformation methods, because it means the ceiling on this component isn't set by what GD can do. That's also why the Cloudflare removal reads as deliberate rather than a retreat. Ship the extension point, let the drivers arrive later.

FAQ

Does Laravel 13.20 mean I can remove Intervention Image?

No, the opposite. You have to install intervention/image:^4.0 for the GD and Imagick drivers to work. It isn't a hard requirement in the framework's own dependencies, but it's required in practice for anything the shipped drivers do.

Should I replace Spatie Media Library with the new API?

Only if you never used what Media Library is for. If your codebase has registerMediaConversions, media collections, or responsive images, you'd be rebuilding a mature package by hand. If you pulled it in just to crop an avatar, switching is a reasonable simplification.

Can I pass an image instance to a queued job?

No. Image instances aren't serializable and attempting it throws an ImageException. Store the image first, pass the path, and rebuild inside the job with Image::fromStorage().

Is there a Cloudflare driver?

Not in the released framework. It existed in the pull request and was removed before merge so the first version could focus on GD and Imagick. Only gd and imagick are supported out of the box, though the driver contract is public if you want to build your own.

How do I add a watermark with the new API?

Not with the built-in methods, since there's no composition or text support. Either use Intervention Image directly for that operation, or write a custom Transformation class and register a handler with Image::transformUsing() so it works inside the fluent chain.

Wrapping up

The question worth asking isn't which of these three to pick. It's how far up the stack your problem lives. Transforming bytes right now is the bottom, attaching media to models is the top, and 13.20 filled a gap in the middle that most of us had been filling with a service class of our own.

If you're picking between the three for an existing app, start by grepping for registerMediaConversions. If it's there, nothing changes for you. If it isn't, and you've got a hand-rolled image wrapper somewhere in app/Services, you can probably delete it this afternoon. That's also the moment to revisit whether your upload path belongs in the request at all, something I went through in detail while building a full-stack upload system with Vue and S3.

Top comments (0)