spatie/image-optimizer
Optimize PNG, JPG, WEBP, AVIF, SVG and GIF images in PHP by running them through a chain of tools like JpegOptim, Optipng, Pngquant and SVGO. It auto-detects installed binaries and overwrites files in place with smaller versions.
Install the package and, just as importantly, install the binaries — the PHP package is only a thin orchestrator around CLI tools, so a composer require alone does nothing.
composer require spatie/image-optimizer
For a Docker-based Laravel app, bake the binaries into the image (this is the step that breaks most first deployments):
RUN apt-get update && apt-get install -y --no-install-recommends \
jpegoptim optipng pngquant gifsicle webp libavif-bin \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# svgo is not packaged; install via node or npm
RUN npm install -g svgo
Where to look first:
OptimizerChainFactory::create() — the entry point; it detects which binaries exist on the machine and builds the chain. It is the only class most projects touch.src/Optimizers/* — one small class per binary (Jpegoptim, Optipng, Pngquant, Svgo, Gifsicle, Cwebp, Avifenc). Read one (Jpegoptim) and you understand them all: binaryName(), canHandle(Image), getCommand().Spatie\ImageOptimizer\Image — the tiny value object with path() and mime() used for dispatching.First use case, run it right after an upload is stored:
use Spatie\ImageOptimizer\OptimizerChainFactory;
$path = $request->file('photo')->store('uploads', 'local');
OptimizerChainFactory::create()->optimize(storage_path('app/'.$path));
The file is overwritten in place with a smaller version. There is no return value — measure savings yourself with filesize() before and after.
In-place vs. "write elsewhere". The default optimize($path) mutates the file. Passing a second argument keeps the original untouched and writes the result to a new path — useful when you need both the pristine upload (for reprocessing) and the optimized derivative:
$chain->optimize($originalPath, $optimizedPath);
Never optimize a remote path. The package shells out to local binaries; Storage::disk('s3')->url() or a stream wrapper will not work. Download to a temp file, optimize, push back:
$disk = Storage::disk('s3');
$tmp = tempnam(sys_get_temp_dir(), 'img');
file_put_contents($tmp, $disk->get($key));
OptimizerChainFactory::create()
->setTimeout(30)
->optimize($tmp);
$disk->put($key, file_get_contents($tmp));
unlink($tmp);
Always do this in a queued job, not in the request. Optimization is CPU-bound and can take multiple seconds per image (cwebp alone runs 10 analysis passes). A ShouldQueue job keeps upload responses fast:
class OptimizeImageJob implements ShouldQueue
{
public int $timeout = 120;
public function __construct(public string $path) {}
public function handle(): void
{
if (! is_file($this->path)) {
return;
}
OptimizerChainFactory::create()
->setTimeout(20)
->throws(fn (Throwable $e) => report($e))
->optimize($this->path);
}
}
Configure explicit failure handling. By default failures are swallowed, which means a missing binary silently ships unoptimized images. In staging/CI, fail loudly:
$chain->throws()->optimize($path); // abort on first failure
$chain->throws(false)->optimize($path); // explicit "log and continue"
$chain->throws(function (Throwable $e, Optimizer $optimizer, Image $image) {
report($e); // return to continue, throw to abort
})->optimize($path);
Attach a logger when you don't trust the environment. The chain logs which optimizers ran, the exact commands, and their output — the single fastest way to diagnose "why is this PNG still 2 MB":
$chain->useLogger(Log::getLogger())->optimize($path);
Build a custom chain when defaults don't match your brand. For a photography site you may want JPEG quality 92 and no PNG quantization; for a marketplace thumbnail pipeline the aggressive defaults are perfect:
use Spatie\ImageOptimizer\OptimizerChain;
use Spatie\ImageOptimizer\Optimizers\{Jpegoptim, Optipng};
$chain = (new OptimizerChain)
->addOptimizer(new Jpegoptim(['-m92', '--strip-all', '--all-progressive']))
->addOptimizer(new Optipng(['-i0', '-o2']));
Bind one configured chain in a service provider and inject it, rather than calling the factory in ten places:
$this->app->singleton(OptimizerChain::class, fn () => OptimizerChainFactory::create()
->setTimeout(20)
->throws(...));
Optimizers without a binary. If you route images through an external optimization API (TinyPNG, Cloudflare, an internal service), extend BaseSelfHandlingOptimizer and implement canHandle() + handle(); the chain delegates instead of building a process command. Implement it once and it composes with the bundled optimizers in the same chain.
Custom binary optimizers. Implement Spatie\ImageOptimizer\Optimizers\Optimizer and return the full command from getCommand(). Reuse the pattern from the shipped classes to keep escaping consistent.
Testing. Don't assert on byte sizes (they vary by binary version and platform). Assert instead that (a) the file still exists and is a valid image, and (b) your job was dispatched. For unit tests, swap in a fake OptimizerChain — the chain has no final methods, so a no-op subclass or a mocked Optimizer is enough.
OptimizerChainFactory::create() returns a chain that does nothing and throws nothing. Add a deploy-time smoke test (which jpegoptim pngquant optipng) or a health check that runs the chain on a fixture and asserts the file got smaller.exec/proc_open may be disabled on shared hosting and is not permitted by default in many serverless runtimes (Laravel Vapor). Test in the target environment, not locally.pngquant quantization is lossy, JPEG is recompressed at 85, WebP at q90, AVIF at cq-level 23. Do not run this on source assets you might need to re-export from — keep originals, optimize derivatives only.--strip-all). That's desirable for privacy but also destroys orientation/GPS/ICC data and any copyright fields. If you rely on EXIF rotation, orient the image before optimizing.--disable=cleanupIDs, --disable=removeViewBox); npm install -g svgo today ships svgo 3, where those flags no longer exist and the command can fail or behave differently. Pin an svgo version in your image, or wrap SVGO in your own optimizer with a v3-compatible config. Also, SVGO can and does break hand-authored SVGs — spot-check complex artwork.cwebp/avifenc decode and re-encode the file, which re-quantizes lossy content and can flatten animated WebP frames. Verify animated assets survive the trip, or exclude them via canHandle().OptimizerChainFactory::create() is the correct class name. The README's "custom optimizer" example imports a non-existent ImageOptimizerFactory — a copy-paste trap.setTimeout(10) applies per optimizer in the chain, not to the whole run, so budget accordingly against your queue $timeout.rename() into place, or write to a new key and update the DB pointer.optimized_at or a job uniqueness lock.chown-consistent storage directories and no sudo-installed binaries in /usr/local/sbin.Optimizer objects. Anything you dislike about the defaults — quality, strip behaviour, ordering — is overridable by constructing the chain yourself, without forking the package.How can I help you explore Laravel packages today?