laravel-image-variants maintained by fruitcake
Laravel Image Variants
Resize, crop and convert images on the fly — without pre-rendering
Ask for a variant in a template and you get a URL back. Nothing is generated at that point, and nothing needs to have been generated ahead of time:
<img src="{{ Variants::url('img/bg.jpg', 'hero') }}" alt="">
<img src="/storage/variants/hero/9a8c70ca57/bg.jpg?src=img/bg.jpg&scale=1600&quality=80" alt="">
The first request for that URL reaches PHP, which generates the image and
writes it to exactly the path the browser asked for — below the storage:link
symlink. Every request after that is answered by the web server straight off
disk. PHP is never involved again, so there is no route to hit, no cache to
consult, and no per-request overhead once an image exists.
That is the whole idea: on-demand generation with the serving cost of a static file.
Requirements
- PHP 8.3+
- Laravel 13
ext-fileinfoext-gdorext-imagick, plus intervention/image (pulled in as a dependency)
Image work is done through Laravel's own Illuminate\Image layer, so the driver
is whatever config/images.php says (gd by default).
WebP and AVIF need a PHP build compiled for them. Both are permitted by the default
output_formats, but asking for a format this build cannot encode raises anImageException, which the controller answers with a 404 — a puzzling one to debug. Check withphp -r 'var_dump(function_exists("imagewebp"), function_exists("imageavif"));'before relying on either.
Install
composer require fruitcake/laravel-image-variants
The package registers itself. It needs the public storage symlink, so if you have not already:
php artisan storage:link
Optionally publish the config:
php artisan vendor:publish --tag=image-variants-config
Your web server must serve existing files before handing off to PHP. The stock Laravel nginx config (
try_files $uri $uri/ /index.php?$query_string) and the shippedpublic/.htaccessboth do this already. If yours routes everything toindex.phpunconditionally, every request will regenerate and the point of the package is lost.
Cache headers
The one response that comes from PHP carries
Cache-Control: public, max-age=31536000, immutable. Every response after that
comes from the web server, which sends whatever it is configured to send —
usually just Last-Modified, so browsers revalidate on each visit.
Since variant URLs change whenever their content does, it is worth saying so once in the server config:
location ^~ /storage/variants/ {
expires max;
add_header Cache-Control "public, immutable";
}
<LocationMatch "^/storage/variants/">
Header set Cache-Control "public, max-age=31536000, immutable"
</LocationMatch>
Where images come from
Sources are read from a filesystem disk, and every $src you pass is
relative to it. The default is Laravel's own public disk —
storage/app/public, where uploads normally land:
'source' => [
'disk' => 'public',
'prefix' => null,
],
Variants::url('uploads/avatar.png', 'thumb'); // storage/app/public/uploads/avatar.png
Any disk works, remote ones included — sources are read through Flysystem, so an
s3 disk needs no special handling. Only the first request for a variant
reads the source; everything after that is served off local disk, so a remote
source costs one read per variant, ever.
To serve images committed under public/ instead, define a disk for them in
config/filesystems.php and name it here:
// config/filesystems.php
'assets' => ['driver' => 'local', 'root' => public_path()],
// config/image-variants.php
'source' => ['disk' => 'assets', 'prefix' => null],
Confining sources to a subdirectory
prefix narrows a disk to one directory without needing a disk of its own.
$src is then relative to the prefix, and nothing outside it is addressable:
'source' => ['disk' => 'public', 'prefix' => 'uploads'],
Variants::url('avatar.png', 'thumb'); // storage/app/public/uploads/avatar.png
Sources that do not resolve
A $src that escapes its root, does not exist, or is not one of
source_formats throws VariantException and returns 404 — it is a bad URL.
A source that is not configured — no disk set, or a disk missing from
filesystems.disks — throws VariantConfigurationException, which is
deliberately not caught by the controller. A broken deployment should
surface as a 500 in your error tracker, not as images that quietly never appear.
Usage
The facade
use Fruitcake\ImageVariants\Facades\Variants;
// A named preset from the config
Variants::url('img/bg.jpg', 'hero');
// /storage/variants/hero/9a8c70ca57/bg.jpg?src=img/bg.jpg&scale=1600&quality=80
// Ad-hoc operations, converting to webp
Variants::url('img/bg.jpg', ['cover' => [600, 500], 'quality' => 80], 'webp');
// /storage/variants/custom/b754ff4bfa/bg.webp?src=img/bg.jpg&cover=600,500&quality=80
// A preset, a nicer filename, and one operation added on top
Variants::url('img/bg.jpg', 'thumb', 'webp', 'team-photo', ['grayscale' => true]);
// /storage/variants/thumb/cb20a26822/team-photo.webp?src=img/bg.jpg&cover=100,100&grayscale=1&quality=80
The signature is the same throughout:
Variants::url(
string $src, // relative to the configured source disk
string|array $preset = 'custom', // a preset name, or operations
?string $format = null, // output extension; defaults to the source's
?string $name = null, // filename to serve as; defaults to the source's
array $operations = [], // merged over the preset
);
Variants::make(...) takes the same arguments and returns the Variant object
instead, which also exposes ->url(), ->path(), ->hash(), ->format(),
->dimensions() and ->query(), plus the readonly $preset, $operations,
$src and $name it was built from.
Responsive images
Variants::srcset('img/bg.jpg', [640, 1024], 'webp');
// /storage/variants/custom/cf6b9805e5/bg.webp?src=img/bg.jpg&scale=640 640w,
// /storage/variants/custom/e27357f48a/bg.webp?src=img/bg.jpg&scale=1024 1024w
srcset() adds a scale operation per width. It can be combined with a preset,
as long as that preset is not itself a resize — see One resize at a time below.
In Blade you rarely need to call it yourself; the <x-variant> component below
assembles the whole tag.
Dimensions, without generating anything
A browser that knows an image's aspect ratio up front reserves the space and
does not shift the page when the image lands. dimensions() works out what a
variant will be:
Variants::dimensions('img/bg.jpg', 'thumb'); // ['width' => 100, 'height' => 100]
Most presets cost nothing to measure. cover, contain and crop state their
output dimensions outright, so a thumb is answered from the operations alone
and the source is never opened. Only scale and a one-sided resize need the
source's aspect ratio; a local source is then measured from its file header, and
a remote one is fetched once and remembered for dimensions.ttl seconds.
It returns null rather than guessing when the answer genuinely depends on
decoding the image — an orient, whose result depends on EXIF this has not
read, or a rotate off the square, which lands on a bounding box the encoder
rounds its own way. In Blade, the component below handles that for you.
Blade
Everything a good image tag needs comes from the same description, so there is a component that writes the whole thing:
<x-variant src="img/bg.jpg" preset="thumb" alt="Team photo" />
<img src="/storage/variants/thumb/2de1510004/bg.png?src=img/bg.jpg&cover=100,100&quality=80"
width="100" height="100" alt="Team photo">
Give it widths and it builds the srcset too, using the largest as the src that
anything ignoring srcset falls back to:
<x-variant
src="img/bg.jpg"
:widths="[640, 1024, 1600]"
sizes="(max-width: 900px) 100vw, 900px"
format="webp"
alt="Our office"
/>
<img src="…/bg.webp?src=img/bg.jpg&scale=1600"
srcset="…scale=640 640w, …scale=1024 1024w, …scale=1600 1600w"
sizes="(max-width: 900px) 100vw, 900px"
width="1600" height="1067" alt="Our office">
| Prop | Meaning |
|---|---|
src |
Source path, relative to the configured disk. |
preset |
A preset name, or :preset="['cover' => [60, 40]]" for ad-hoc operations. |
format |
Output extension; defaults to the source's. |
name |
Filename to serve as; defaults to the source's. |
:operations |
Merged over the preset. |
:widths |
Builds a srcset over these widths. |
sizes |
The sizes attribute. Only emitted alongside a srcset. |
width and height are worked out and added, and left off entirely rather than
guessed when the variant cannot be measured without generating it. alt is
always emitted so the tag is valid — pass a real one for any image that carries
meaning, since an empty alt tells a screen reader the image is decorative.
Anything else you put on the tag (class, loading, decoding,
fetchpriority, …) passes straight through.
Rename it, or turn it off, if variant is a name your application already uses:
'blade' => ['component' => 'variant'], // null leaves it unregistered
For a URL somewhere that is not an <img> — an og:image, a CSS background, a
<source> — use the facade directly, as above.
Twig
If your app renders Twig, register the bundled extension with your Twig environment:
$twig->addExtension($container->make(\Fruitcake\ImageVariants\Twig\VariantExtension::class));
{{ variant('img/bg.jpg', 'hero') }}
{{ variant('content/team/photo.jpg', 'photo', 'webp', team.key) }}
{{ variant('img/bg.jpg', {cover: [600, 500], quality: 80}, 'webp') }}
{{ srcset('img/bg.jpg', [640, 1024, 1600], 'webp') }}
{% set size = variant_size('img/bg.jpg', 'photo') %}
<img src="{{ variant('img/bg.jpg', 'photo') }}"
{% if size %}width="{{ size.width }}" height="{{ size.height }}"{% endif %}
alt="">
variant_size rather than dimensions, because Twig functions share one global
namespace with whatever else the application has registered.
Operations
Operations are named after the methods on Illuminate\Image\Image and can be
written either as PHP arrays (in presets and calls) or as query parameters (in
the URL). Both forms normalise to the same thing.
| Operation | Arguments | Example |
|---|---|---|
orient |
— | orient=1 |
rotate |
angle 0–359, optional background |
rotate=90,ffffff |
flip |
v or h |
flip=v |
crop |
width, height, optional x, y | crop=300,200,50,25 |
cover |
width, height (both required) | cover=600,500 |
contain |
width, height, optional background | contain=600,500,fff |
resize |
width and/or height | resize=800,600 |
scale |
width and/or height | scale=800 |
grayscale |
— | grayscale=1 |
blur |
0–100, default 5 |
blur=10 |
sharpen |
0–100, default 10 |
sharpen=15 |
quality |
1–100 |
quality=80 |
A few rules the grammar enforces:
- Fixed order. A query string carries no ordering, so operations are applied in the order of the table above rather than the order they appear in the URL.
- One resize at a time.
cover,contain,resizeandscaleare mutually exclusive; combining them is a mistake rather than a pipeline, and throws. - Omitted dimensions.
scale=800scales to a width of 800;scale=,600scales to a height of 600.coverandcontainneed both. - Backgrounds are bare hex (
fffffforfff, no#), or the literaldominantto sample the image's own average colour. - Turning an operation off. Passing
falseornulldrops it, which is how a preset's value gets removed rather than replaced:Variants::url('img/bg.jpg', 'thumb', operations: ['quality' => false]). - Anything outside the grammar — an unknown operation, a value out of range, a
dimension over
max_dimension— throwsInvalidArgumentExceptionwhen you build the URL, and returns a 404 when it arrives over HTTP.
Presets
Presets are named operation sets. The name becomes a path segment, so it is worth keeping them readable.
'presets' => [
'thumb' => ['cover' => [100, 100], 'quality' => 80],
'photo' => ['scale' => [800, null], 'quality' => 80],
'hero' => ['scale' => [1600, null], 'quality' => 80],
],
Anything in $operations is merged over the preset, so a preset can be reused
with one value changed. Editing a preset changes the hash of every URL using it,
so variants built from the old definition are simply never requested again —
they are not served stale, and there is no cache to bust. (They do stay on disk
until cleaned up; see below.)
How the URL works
/storage/variants/{preset}/{hash}/{name}?src=…&…operations…
└──── route prefix ────┘ └── what gets hashed ──┘
- The three path segments decide where the file lands on disk, which is why they
have to match the public path:
storage/variants/...in the URL isstorage_path('app/public/variants')/...on disk, viastorage:link. - The query is what the first request reconstructs the work from. Web servers ignore the query string when serving static files, so it costs nothing after generation.
- The
hashis an HMAC keyed withAPP_KEY, computed over the preset, the normalised operations, the source and the name. It is not a cache key — it is what makes the endpoint safe. Without the key nobody can compute a URL that the controller will act on, so the endpoint cannot be used to fill your disk with a million sizes of the same image, and no separate signature parameter is needed. Nothing is read from or written to disk until the hash checks out.
The controller is a second line of defence behind that: sources are resolved
inside the configured disk (and prefix) and rejected if they escape it, output
formats are limited to output_formats, and dimensions to max_dimension.
APP_KEY must be set. Without it the digest would be plain SHA-256 over public
inputs — computable by anyone, turning the endpoint into an open resize service —
so building a URL throws instead.
Guard the input, not just the output
max_dimension bounds what a URL may ask for. It says nothing about what
answering costs: decoding takes roughly 4 bytes per pixel whatever the file size,
so a solid-colour 10000×10000 PNG is ~300KB on disk and ~380MB decoded, and
asking it for a 60×40 thumbnail still pays that in full.
max_source_megapixels (default 24) bounds the source itself, read from the
image header without decoding. This matters most with the default source disk,
which is where uploads land: without it, anyone who can upload an avatar can
exhaust a worker on every variant of it. Lower it if memory_limit is tight;
set it to 0 to accept anything.
Rotating APP_KEY invalidates every previously generated URL. Existing files
stay on disk (until cleaned up) but are never requested again, and new URLs
regenerate under a new hash.
Concurrency
Generating a variant is serialised per URL. Without that, a burst of requests arriving for the same uncached image would each decode, resize and encode it at the same time — a page of cold images costs what it should, but one cold URL under load costs N times over.
The first request in takes a lock and generates; the rest wait, then find the
file already there and serve it. A request that waits longer than lock.wait
gives up and generates anyway, on the grounds that a slow image beats a broken
one. Writes go to a temporary file and are renamed into place regardless, so
nothing can ever serve a half-written image even if the lock is off.
'cache_store' => null, // null = default store
'lock' => [
'enabled' => true,
'ttl' => 30, // how long the lock survives a worker that dies holding it
'wait' => 10, // how long a queued request waits before generating anyway
],
Every cache store Laravel ships supports locking, including null (as a no-op).
A custom store that does not will raise VariantConfigurationException rather
than fail obscurely; set lock.enabled to false if that is deliberate.
Cleaning up
Variants accumulate: a preset you edited last year, a page you deleted, a size nobody requests any more. Nothing in the cache is irreplaceable — a deleted variant is regenerated by the next request for it, at the cost of one slow response — so sweeping it is safe.
# Delete variants not written to in the last 30 days (config default)
php artisan image-variants:cleanup
# Pick the age yourself
php artisan image-variants:cleanup --days=90
# See what would go, without deleting anything
php artisan image-variants:cleanup --days=90 --dry-run
# List each file as it goes
php artisan image-variants:cleanup -v
Only files below the configured cache directory are touched; sources are never
read. Directories emptied by the sweep are removed, the cache root itself is
kept, and any .tmp files left behind by an interrupted generation get swept up
with everything else.
Because generated files are written once and never rewritten, "last written" is
effectively "created". If your filesystem records access times (many are mounted
relatime or noatime and do not), --accessed ages files by when they were
last read instead, which prunes what is genuinely unused rather than what is
merely old:
php artisan image-variants:cleanup --accessed --days=90
Schedule it in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('image-variants:cleanup')->weekly();
The default age lives in the config:
'cleanup' => [
'days' => 30,
],
There is deliberately no "clear everything" command, because there is nothing to
clear: an edited preset moves its URLs to a new hash rather than leaving stale
ones behind, so the old files are simply never asked for again and age out on
their own. --days=0 is not that command either — it deletes what is older than
now, so anything written in the current second survives. If you really want the
cache gone, delete the directory:
rm -rf storage/app/public/variants
Configuration
| Key | Default | What it does |
|---|---|---|
route.prefix |
storage/variants |
Where the endpoint is mounted. Must line up with a publicly reachable path to cache. |
route.middleware |
[] |
Middleware for the endpoint. Deliberately empty — see below. |
source.disk |
public |
The filesystem disk sources are read from. |
source.prefix |
null |
An optional directory within that disk to confine sources to. |
cache |
storage_path('app/public/variants') |
Where generated variants are written. Always a local path — see above. |
max_dimension |
4000 |
Upper bound on any width, height or offset a URL may ask for. |
max_source_megapixels |
24 |
Largest source that will be decoded. 0 disables the check. |
hash_length |
10 |
Characters of the HMAC kept in the URL. |
source_formats |
jpg, jpeg, png, gif, webp, avif, bmp | What may be read. |
output_formats |
jpg, jpeg, png, gif, webp, avif | What may be written. |
presets |
thumb, photo, hero | Named operation sets. |
blade.component |
variant |
Name the <x-variant> component registers under. null to skip it. |
cache_store |
null |
Cache store for generation locks and source measurements. null uses the default. |
lock.enabled |
true |
Serialise generation of the same variant across workers. |
lock.ttl |
30 |
Seconds a lock survives a worker that dies holding it. |
lock.wait |
10 |
Seconds a queued request waits before generating anyway. |
dimensions.ttl |
86400 |
Seconds a source's measurements are remembered. 0 disables caching. |
cleanup.days |
30 |
Default age for image-variants:cleanup. |
The route is registered outside any middleware group on purpose. Session
middleware in particular would attach a Set-Cookie and a private
Cache-Control to the one response a real visitor gets from PHP, undoing the
long-lived caching the whole scheme exists for.
If you point source.disk at a private disk, note that generated variants still
land in cache and are public once generated — the source stays unreachable,
the variant does not. Put the endpoint behind route.middleware if that matters.
AI agents
The package ships an Agent Skill for
Laravel Boost, at
resources/boost/skills/image-variants-development/SKILL.md. If your application
uses Boost, it is installed with:
php artisan boost:install
# or, for an application that already has Boost
php artisan boost:update --discover
The agent then loads it on demand when a task involves images, which saves it inferring the operation grammar from the source — and, more usefully, stops it hand-assembling variant URLs that will never match the HMAC.
Running the Test Suite
composer install
composer test # PHPUnit
composer analyse # PHPStan, level 8
License and attribution
Laravel Image Variants is open-sourced software licensed under the MIT license.