zenstruck/image
Zenstruck Image is a lightweight PHP library for object-oriented image manipulation. It offers a clean, fluent API for common tasks like resizing, cropping, rotating, and converting images with minimal setup and readable code.
zenstruck/image is a tiny, framework-agnostic wrapper around an image file that defers actual manipulation until you ask for the result. It ships no resizing engine of its own — you bring GD, Imagick, Intervention Image, or Imagine and plug it in through a transformer. Install with:
composer require zenstruck/image
Where to look first. Read src/Image.php and the Transformer contract before anything else. The public surface is intentionally small: a static factory (Image::from(...)), a transform() method that returns a new lazy Image, and accessors to materialise the result (save(), getPathname(), getSplFileInfo()). The bundled transformers live under src/Transformer/ and show the expected return shape (an SplFileInfo or path pointing at the transformed file).
First use case — avatar thumbnailing in a controller:
use Zenstruck\Image;
use Zenstruck\Image\Transformer\InterventionImageTransformer;
// UploadedFile extends Symfony's UploadedFile, which extends SplFileInfo
$image = Image::from($request->file('avatar'))
->withTransformer(new InterventionImageTransformer()); // or set once globally
$thumb = $image->transform(
fn (\SplFileInfo $file) => $file, // your resize/crop call here
['width' => 200, 'height' => 200]
);
// Still lazy — do the work now and persist it:
$thumb->save(storage_path('app/public/avatars/'.$filename));
If you only remember one thing: constructing an Image does nothing; the work happens when you save or read the path. That is the whole design.
Wrap once, transform many times. Keep the source Image as the entry point and derive variants from it. Because transformations are lazy, you can build a small pipeline in a service and only pay the cost for the variants the request actually needs (e.g. skip the 1920px hero on mobile).
$source = Image::from($path);
$thumb = $source->transform($resize, ['w' => 200]);
$preview = $source->transform($resize, ['w' => 800]);
Implement the transformer to centralise your image library. The package's real value is the seam it gives you. Write one transformer per library (Intervention, Imagine, raw GD) and reuse it everywhere; swapping libraries later is a one-line change.
final class GdTransformer implements \Zenstruck\Image\Transformer
{
public function transform(\SplFileInfo $image, callable $transformation, array $options = []): \SplFileInfo
{
// load $image, apply $transformation/options, write to a temp file, return it
}
}
Compose with Laravel storage. Resolve the transformed file, then hand the path to the Storage facade. This keeps the library out of your storage layer and keeps Storage out of your transformation logic.
Storage::disk('s3')->putFileAs(
'avatars',
$image->transform($resize, ['w' => 200])->getSplFileInfo(),
$name
);
In queued jobs, pass paths — not Image instances. An Image is a wrapper over a live filesystem path (often a PHP upload temp file). Serialising it is a foot-gun; serialise the source path, rebuild the Image inside the job, transform, store, and let the wrapper's temp output be cleaned up.
Cache by source hash + transformation key. The package does not cache for you. A cheap pattern is sha1_file($source).'_'.md5(serialize($options)).'.jpg' as the output filename; if it exists, skip the transform entirely.
Testing with fakes. In Laravel feature tests, UploadedFile::fake()->image('x.jpg', 800, 600) produces a real GD image on disk and slots straight into Image::from() — no fixtures needed.
Use temporary() for request-scoped outputs. When you don't want to pick a destination (e.g. streaming a response), ask the wrapper for a temp-backed Image and point the response at its pathname; this is the idiomatic "I just need a throwaway file" flow.
UploadedFile::getPathname() points at a temp file PHP deletes at end of request. Materialise or copy the image within the request; a queued job that receives the raw temp path will fail. Store the original first, then transform from the stored file.save()/getPathname(). Wrap the consumption point in try/catch, not the transform() call, and log the source path — the stack trace alone won't tell you which file died.memory_limit for the job/request or downscale in two passes. Imagick is gentler but can hit policy.xml limits on shared hosts.-auto-orient/Imagick::setImageOrientation) before your resize; do it explicitly in a transformer step.SplFileInfo::getExtension() is attacker-controlled. Validate with finfo/getimagesize() before handing the file to GD, or you're feeding arbitrary bytes to a decoder.clearstatcache(true, $path) before reading metadata back.SplFileInfo/path) — returning true, null, or a resource will surface as a confusing type error deep in the wrapper. Validate in your own transformer.temporary() into a directory you prune.How can I help you explore Laravel packages today?