spatie/image
spatie/image provides an expressive, chainable PHP API for common image manipulation. Resize to fit a bounding box without altering aspect ratio, apply greyscale, adjust brightness and quality, rotate, sharpen, and save or overwrite images in just a few lines. Ideal for Laravel and PHP apps.
Install and verify the environment
composer require spatie/image
The package is a thin, expressive wrapper around GD or Imagick — it ships no binaries of its own. Before writing code, confirm you have:
php -m | grep -E 'exif|gd|imagick'
exif is required (since 1.5.3) — the package reads EXIF data on load to auto-orient photos taken on phones.gd or imagick. Imagick is the more capable driver (animated GIFs, better color-profile handling); GD is the default in most PHP builds and is fine for standard resize/crop/watermark work.Pick a driver once, at boot
// AppServiceProvider::boot()
use Spatie\Image\Image;
use Spatie\Image\Drivers\ImagickDriver;
use Spatie\Image\Drivers\GdDriver;
Image::useImageDriver(ImagickDriver::class);
Set it globally rather than per call, otherwise different parts of your app will silently produce slightly different output (different sharpening kernels, different WebP quality behavior).
First use case: an upload pipeline
use Spatie\Image\Image;
Image::load($request->file('photo')->getRealPath())
->width(1200) // fits inside 1200px, aspect ratio preserved
->quality(85)
->save(storage_path("app/public/photos/{$id}.jpg"));
Then look, in this order: the Image class itself (every manipulation is one fluent method), src/Drivers/ to see which operations each driver actually supports, and src/Enums/ for Fit, AlignPosition, Orientation and Unit. The README is a teaser; the enums are where the real flexibility lives.
Everything is a fluent chain terminated by save(), base64(), or save() with no argument (which overwrites in place). Build a small wrapper service so controllers never build chains inline:
final class Thumbnailer
{
public function make(string $source, string $target, int $width): void
{
Image::load($source)
->fit(Fit::Max, $width, $width) // only downscales, never upscales
->quality(80)
->save($target);
}
}
width()/height() alone mean "fit inside this box." For anything else, use fit() with an enum:
use Spatie\Image\Enums\Fit;
->fit(Fit::Contain, 800, 600) // letterbox-free fit inside the box
->fit(Fit::Cover, 800, 600) // fill box, cropping the overflow — the thumbnail default
->fit(Fit::Fill, 800, 600) // stretch, aspect ratio NOT preserved
->fit(Fit::FillMax, 800, 600) // scale up to cover, then crop
->fit(Fit::Max, 800, 600) // shrink only if larger
For avatar grids and card thumbnails, Fit::Cover plus a focal point is what you want. crop() accepts arguments to bias the crop rectangle; combine with AlignPosition to keep faces/subjects near the top rather than chopping heads off.
Generating 4–5 sizes inline during an HTTP upload will blow past PHP's max_execution_time on large files. Store the original, dispatch a job:
class GenerateImageVariants implements ShouldQueue
{
public function __construct(public string $path) {}
public function handle(): void
{
$sizes = ['thumb' => 200, 'card' => 600, 'hero' => 1600];
foreach ($sizes as $name => $px) {
Image::load($this->path)
->fit(Fit::Cover, $px, $px)
->format('webp')
->quality(82)
->save(str_replace('.jpg', "-{$name}.webp", $this->path));
}
}
}
Reload the image inside the loop rather than reusing one instance — chains mutate internal state, and reusing the object is a common source of "why is my 200px thumb 1600px wide."
Image::load() and save() want local filesystem paths. For a disk-backed file, round-trip through a temp file:
$tmp = tempnam(sys_get_temp_dir(), 'img');
file_put_contents($tmp, Storage::disk('s3')->get($key));
Image::load($tmp)->fit(Fit::Cover, 400, 400)->save();
Storage::disk('s3')->put("thumbs/{$key}", file_get_contents($tmp));
unlink($tmp);
For inline embedding in emails or JSON payloads, ->base64() returns a ready data:image/...;base64, string — no temp file needed on the write side.
Two watermark classes exist: ImageWatermark (a logo file, positioned with AlignPosition, with padding and optional relative sizing) and TextWatermark (needs a .ttf font path — there is no built-in font, and forgetting that is the #1 failure here). A ->text() manipulation arrived in 3.4.0 for simple captions.
Image::load($path)
->watermark(
ImageWatermark::create(public_path('logo.png'))
->position(AlignPosition::BottomRight)
->padding(20, 20)
)
->save();
->optimize() shells out to external binaries (jpegoptim, optipng, pngquant, svgo, etc.) via spatie/image-optimizer. It runs against files on disk, so it must come after save() or be chained into save() — and those binaries must exist on the container. If images come out the same byte size, the binaries are missing, not the code.
save() with no argument destroys the original. This is a feature (README shows it) and a footgun. In an upload flow, always pass a target path and keep the original untouched for re-processing later — you'll want it when you change your thumbnail dimensions in six months.
Format is inferred from the extension, but not always. ->save('/path/file.jpg') writes a JPEG; ->format('webp') overrides. Be explicit with ->format() when the destination extension disagrees with the source, particularly when converting PNG → JPEG (which flattens transparency to black unless you set a background fill).
Transparency + GD has a long history of bugs here (fixed incrementally in 3.3.6, 3.7.1, 3.7.4). If you need reliable alpha handling across PNG and WebP, use Imagick. If you're stuck on GD, pin a recent version — anything before 3.7.4 will bite you on palette images and canvas resizes.
Animated GIF is Imagick-only. GD will silently give you the first frame. If your app accepts GIFs, either force the Imagick driver or reject animated uploads at validation.
Memory is the silent killer. Both drivers decode the full image into memory before any manipulation. A 6000×4000 JPEG can consume 100MB+. Set memory_limit accordingly in the worker, and consider validating getimagesize() dimensions before load — a "bomb" upload will otherwise take down the queue worker, not just fail one job.
EXIF auto-orientation is now automatic (since 3.3.6). If you were previously calling ->orientation(90) based on manual EXIF inspection, remove that — you'll rotate twice. Related: 3.4.1/3.4.2 suppressed warnings from exif_read_data and imagecreatefromstring, so corrupted files degrade instead of spewing notices — but they can still throw, so wrap loads in try/catch around the driver's exception type.
Errors are driver exceptions, not Image exceptions. Image::load() on a missing or unreadable file throws from the underlying driver (\ImagickException or a GD/GdException). Catch broadly at the job boundary and mark the model's conversion as failed rather than letting the queue retry forever on a permanently corrupt file.
Enums became BackedEnum in 3.6.0. If you were passing integer constants for Fit, Orientation or Unit (from the pre-3.x Manipulations::FIT_* API), those are gone or deprecated. Grep for Manipulations:: when upgrading — it's the most common breakage point.
Quality does nothing on formats that don't support it. ->quality(25) is a no-op for PNG (use ->optimize() or a pngquant step) and behaves differently between GD and Imagick for WebP. There were genuine bugs here (fixed 3.3.6, 3.3.8), so verify output byte sizes rather than trusting the setting.
->apply() is the escape hatch. For transformations not exposed as methods — or to bundle an optimize() call with other manipulations (a bug fixed in 1.7.2) — you can take the Manipulations object and configure it directly. It's also the seam for custom driver-level operations.
Testing image output is awkward — lean on getimagesize(). Assert dimensions, MIME type and file existence rather than byte-for-byte comparison; pixel comparison needs pixelmatch (the package's own test dependency) and is brittle across GD/Imagick versions. If you must compare pixels, pin the driver class in the test.
Last release is dated in the future (2026-11-04). Treat the version constraint carefully: pin a specific minor you've tested (^3.8) rather than floating, and read CHANGELOG.md before bumping — the patch releases here are almost entirely image-correctness fixes, which means silent visual regressions are possible between minor versions.
How can I help you explore Laravel packages today?