intervention/image
Intervention Image is a PHP image manipulation library with a simple API for resizing, cropping, filters, and format conversion using GD or Imagick. It integrates smoothly with Laravel and is ideal for handling uploads and dynamic image processing.
1. Install and pick a driver
composer require intervention/image
You also need an image extension compiled in: ext-gd (everywhere, weaker) or ext-imagick (ImageMagick + more formats/colorspace control). Version 4 requires PHP 8.3+, version 3 requires PHP 8.1+, and v4 adds libvips as a third driver. Verify what you actually have before writing any code:
php -m | grep -Ei 'gd|imagick|vips'
php -r "print_r(gd_info());"
2. Laravel projects: install the framework bridge instead of using the library raw.
composer require intervention/image-laravel
php artisan vendor:publish --tag=image # config/image.php -> pick 'gd' or 'imagick'
That gives you the Image facade and container bindings; the config file is the first place to look when behaviour differs between local and production.
3. Where to look first
| Class | Why you care |
|---|---|
Intervention\Image\ImageManager |
Entry point. ImageManager::gd(), ::imagick(), ::vips(). Stateless — create once. |
Intervention\Image\Image |
The fluent API you'll spend 95% of your time in. |
Intervention\Image\Drivers\{Gd,Imagick}\* |
Concrete modifiers/encoders/decoders. This is where driver-specific gaps live. |
Intervention\Image\Encoders\* |
JpegEncoder, WebpEncoder, PngEncoder, AvifEncoder, JxlEncoder, … |
4. First real use case — normalise an upload
use Intervention\Image\ImageManager;
$manager = ImageManager::gd(); // or ::imagick(), ::vips()
$image = $manager->read($request->file('photo')); // path, string, UploadedFile, GDImage, Imagick, PSR-7 stream/response (4.4.0+)
$image->scaleDown(width: 1600) // never upscale
->toWebp(quality: 82)
->save(storage_path('app/public/uploads/'.$name.'.webp'));
That one chain — read, constrain, re-encode, save — is the heart of ~80% of production usage. Re-encoding also strips EXIF and neutralises malformed/polyglot files, so treat it as a security step, not just a size optimisation.
ImageManager carries no request state but does cache driver instances and config. Don't new it in every controller.
// AppServiceProvider
$this->app->singleton(ImageManager::class, fn () => ImageManager::imagick(
autoOrientation: true, // v3.7+ config API
decodeAnimation: false, // skip GIF frame decoding unless you need it
));
Disabling animation decoding is a meaningful memory win if you only ever serve static thumbnails.
$manager->read('storage/foo.jpg'); // path
$manager->read($binaryString); // raw bytes
$manager->read($request->file('avatar')); // UploadedFile
$manager->read($gdImageResourceOrObject); // GDImage (v3.6+)
$manager->read($psr7StreamOrResponse); // PSR-7 (v4.4+)
$manager->read($encodedImage); // EncodedImage (v3.8+)
The EncodedImage case matters for queued pipelines: encode once on the web node, hand the EncodedImage to the job, decode it there.
They are not interchangeable, and picking wrong is the #1 source of "the image looks stretched" tickets.
$image->resize(300, 200); // exact box, will distort aspect ratio
$image->scale(width: 300); // proportional, one dimension
$image->scaleDown(height: 300);// proportional AND never enlarges
$image->cover(300, 200); // fill box, crop overflow (thumbnails/grids)
$image->contain(300, 200); // fit inside box, letterbox (product shots)
$image->pad(300, 200); // fit inside box, pad to exact size
Reach for cover() for avatar/thumbnail grids, contain() or pad() when nothing may be cropped. Use scaleDown()/coverDown() anywhere user input determines the target size, or you'll be upscaling 200px avatars to blurry 4000px hero images.
Modifiers apply sequentially to the canvas, so order changes output and cost.
$image->trim() // drop borders first
->cover(1200, 630)
->sharpen(8) // sharpen after downscale, never before
->place($logo, 'bottom-right', 24, 24, 70)
->text('© ACME', 24, 24, fn ($font) => $font
->file(resource_path('fonts/Inter-Regular.ttf'))
->size(28)
->color('#ffffff'));
Crop before resize (cheaper on fewer pixels), sharpen last, overlays after all geometry. Text drawn after a resize keeps the font size you specified — it does not scale with the image, which is usually what you want for watermarks.
encode() when you need optionsThe shorthands (toJpeg(80), toWebp(82), toPng(), toAvif(), toGif(), toJxl()) cover the common case. For anything else, construct the encoder — this is also the clearest way to express intent in review:
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\Encoders\PngEncoder;
$image->encode(new JpegEncoder(quality: 82, progressive: true));
$image->encode(new PngEncoder(strip: true)); // remove metadata
$image->toWebp(quality: 78);
strip on the encoder is the correct place to drop EXIF/ICC — not a post-processing step. Note that unknown options passed to encode() are filtered out per format (v3.6.1+), so a shared options array won't blow up on WebP.
final class ResponsiveImage
{
public function __construct(private ImageManager $manager) {}
/** @return array<string,string> width => relative path */
public function generate(string $sourcePath, string $slug, array $widths = [400, 800, 1600]): array
{
$out = [];
foreach ($widths as $w) {
$target = "derived/{$slug}-{$w}.webp";
$absolute = storage_path("app/public/{$target}");
if (! is_file($absolute)) {
$this->manager->read($sourcePath)
->scaleDown(width: $w)
->toWebp(quality: 80)
->save($absolute);
}
$out[$w] = $target;
}
return $out;
}
}
Filename derived from content hash, existence check as cache, scaleDown so small originals don't get upscaled.
Decoding a 12MP JPEG costs ~50 MB in GD and considerably more in Imagick. Never do a batch of them in a web request.
dispatch(new GenerateThumbnails($mediaId))->onQueue('images');
In the job, raise the limit explicitly and let the worker die rather than the whole node:
ini_set('memory_limit', '512M');
Run image workers on a separate queue so a memory spike doesn't starve mail/notification jobs.
If a transformation repeats across the codebase, encapsulate it:
use Intervention\Image\Interfaces\ModifierInterface;
use Intervention\Image\Interfaces\ImageInterface;
final class SquareThumb implements ModifierInterface
{
public function __construct(private int $size = 256) {}
public function apply(ImageInterface $image): ImageInterface
{
return $image->cover($this->size, $this->size);
}
}
$image->modify(new SquareThumb(320));
Custom EncoderInterface/DecoderInterface implementations slot in the same way, and v3.11.4+ relaxes the namespace constraint, so your drivers can live in App\Image\....
Don't assert on pixel-perfect output across GD and Imagick — they genuinely differ. Assert on dimensions, MIME, and byte non-emptiness:
$image = ImageManager::gd()->read(__DIR__.'/fixtures/landscape.jpg');
$result = $image->cover(200, 200);
$this->assertSame(200, $result->width());
$this->assertSame(200, $result->height());
$this->assertSame('image/webp', $result->toWebp()->mimeType());
Keep 3–4 fixture files under 20 KB (one landscape, one portrait, one with alpha, one animated GIF) and use var_dump() freely — __debugInfo() (v3.10+) prints {width, height} for Image and the channel values for color objects, which beats dumping a resource.
Memory is the failure mode, not CPU. GD holds an uncompressed bitmap: roughly width × height × 4 bytes, plus overhead. A 6000×4000 input is ~96 MB before any modifier allocates a second canvas. Reject oversized uploads before read() using the cheap header read, and set an explicit ceiling:
[$w, $h] = getimagesize($tmpPath) ?: [0, 0];
abort_if($w * $h > 40_000_000, 422, 'Image too large');
This is also your decompression-bomb guard — a 300 KB PNG can decode to gigabytes.
save() uses filesystem paths, not disks. In Laravel, $image->save('uploads/x.jpg') writes relative to the process CWD, which will not be what you expect. Always pass an absolute path — Storage::path($relative) — or use Storage::put($path, $image->toJpeg(80)->toString()).
Transparency dies on JPEG. Flatten explicitly or you'll get black backgrounds:
$image->fill('#ffffff') // or place on a canvas
->toJpeg(85);
Same trap when contain()/pad() letterbox with a default background on an alpha image.
EXIF orientation. autoOrientation: true is the default, but if you've disabled it — or you're decoding raw GDImage/Imagick objects, which carry no EXIF — you must call ->orientate() yourself. Phone uploads appearing sideways in production is almost always this.
Driver parity is not a thing. GD has no real colour-profile handling, weaker filters, and limited font rendering; Imagick handles CMYK/ICC, YCbCr (converted to sRGB since 4.1.5), and multi-frame animation properly. If you accept print-ready or camera-original files, use Imagick and say so in your composer/platform requirements. Check capability at boot rather than discovering it in production:
$manager->driver()->version(); // v3.11+
$manager->driver()->supports('image/avif'); // format detection (v3.6+)
Animated GIF/WebP will bite you. Decoding all frames is expensive and coalescing is subtle (there were several fixes across 4.2.x/4.3.x). If you only need a still thumbnail, disable animation decoding in the manager config and take the first frame.
Don't re-encode to re-save. EncodedImage holds a temp stream (v3.9+), and saving the same instance twice was broken before v3.11.2. If you need two destinations, either stay on a current release or encode twice — it's cheap relative to a bug.
Font paths must be absolute. Relative font paths resolved differently across GD versions (patched repeatedly in 3.11.5/3.11.6). Always base_path()/resource_path() the TTF.
resize() with two dimensions distorts. If you're computing dimensions yourself and only need a cap, use scaleDown(). If you need an exact box with the subject intact, that's cover().
Chained ->text() does not wrap by default in older 3.x and non-Latin wrapping had fixes (3.11.3). For long captions, measure and break lines yourself rather than trusting automatic wrapping.
SVG is not supported. Intervention is raster-only. Reject SVGs at validation rather than letting read() throw a DecoderException and surfacing a 500.
Catch the right exception. v4 introduced a proper hierarchy (decoder/encoder/analyzer exceptions, file-not-found, etc.). Catch Intervention\Image\Exceptions\... specifically — a bare catch (\Throwable) around image code hides genuine driver misconfiguration.
read() is lazy-ish, but not free. Validation like "is this actually an image" still costs a decode. Validate MIME/size at the request layer, and re-encode — not re-validate — at the storage layer.
Upgrade note: v4 is a deliberate API cleanup (PHP 8.3+, revised method signatures, new exception tree) and v3 is still widely deployed. Pin the major in composer.json and read image.intervention.io/v4 before a jump — the differences are mostly naming and error handling, but they will surface in tests that asserted on exception messages.
How can I help you explore Laravel packages today?