isapp / laravel-imagetools
Laravel image tools: deterministic, query-driven image generation (vite-imagetools-like).
Requires
- php: ^8.2
- illuminate/bus: ^10.0|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/filesystem: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/view: ^10.0|^11.0|^12.0|^13.0
- nikic/php-parser: ^5
- spatie/image: ^3.9.6
- spatie/image-optimizer: ^1.10
- symfony/finder: ^6.4|^7.0|^8.0
Requires (Dev)
- ext-gd: *
- laravel/pint: ^1
- league/flysystem-aws-s3-v3: ^3.0
- orchestra/testbench: ^10.6
- phpunit/phpunit: ^11.5
- roave/security-advisories: dev-latest
Suggests
- spatie/laravel-medialibrary: Use ImageTools::media($media, '...') to make derivatives of a Media original (^11).
Provides
None
Conflicts
None
Replaces
None
README
Deterministic, query‑driven image generation for Laravel — inspired by vite-imagetools.
- Call it once in Blade/PHP and get a public URL:
{{ ImageTools::asset('public/images/hero.jpg?w=1200&h=630&fit=contain&format=webp&q=82') }}
- Files are written to your configured filesystem disk with stable names
(e.g.
hero--a1b2c3d4e5.webp), perfect for long‑lived CDN caching. - A tiny PHP manifest maps your canonical request to the stored file, so subsequent calls are instant.
Features
- 🔁 Deterministic filenames based on the source + sorted query options
- 🧩 Simple query API:
w,h,fit,q,format - 📦 One disk to rule them all — works with
public, S3/R2 or any Laravel disk - 🔎 Scanner command to pre‑generate all images referenced in your code
- 🧹 Clear command to remove generated files & the manifest
- ⏳ Deferred generation via a
queueflag or config — the page responds at once and shows the original (when it has a public URL) until a worker has made the derivative - 🚫 No upscaling by default — a
w/hrequest withoutfitlarger than the source returnsnullinstead of an enlarged file - 🎛️ Encode control — pick the image driver, set the optimizer chain, or skip it per query with
optimize=0 - 🖼️ spatie/laravel-medialibrary —
ImageTools::media($media, 'w=800')makes a derivative of a Media original - 🗑️ Prune command to delete generated files that the manifest no longer references
Requirements
- PHP 8.2+
- Laravel 10+ (works with 10/11/12)
- Image driver: Imagick (recommended) or GD for
spatie/image. When both are installed, spatie/image uses Imagick; setdriverto choose.
Installation
composer require isapp/laravel-imagetools
Auto‑discovery will register the service provider and the ImageTools facade.
Publish config (optional)
If you want to customize defaults, publish the config file:
php artisan vendor:publish --provider="Isapp\\ImageTools\\ServiceProvider"
Quick start
<img src="{{ ImageTools::asset('public/images/placeholder.jpg?w=640&q=75&format=webp') }}" width="640" height="360" alt="Placeholder" />
When the source is narrower than 640px,
asset()returnsnullandsrcis empty. See Larger than the source.
Pure PHP
use Isapp\\ImageTools\\Facades\\ImageTools; // A string URL, or null when the source is narrower than 640px (see "Larger than the source"). $url = ImageTools::asset('resource/images/placeholder.jpg?w=640&q=75&format=webp');
Source on another disk — read the original from any Laravel disk (e.g. S3),
mirroring Storage::disk() (see Reading the source from a disk):
ImageTools::disk('s3')->asset('assets/hero.jpg?w=1200&format=webp');
Also supported (and detected by the scanner):
{{ app(Isapp\ImageTools\ImageTools::class)->asset('resource/images/pic.jpg?w=800') }} {{ app('image-tools')->asset('resource/images/pic.jpg?w=800') }} {{ \Illuminate\Support\Facades\App::make('image-tools')->asset('resource/images/pic.jpg?w=800') }} {{ App::make(Isapp\ImageTools\ImageTools::class)->asset('resource/images/pic.jpg?w=800') }}
Configuration
All options live in config/image-tools.php (with inline comments). You can also control them via ENV:
IMAGE_TOOLS_DISK=public IMAGE_TOOLS_DRIVER=gd # gd | imagick | vips; defaults to the spatie/image choice IMAGE_TOOLS_ALLOW_UPSCALE=false IMAGE_TOOLS_MANIFEST_PATH=bootstrap/cache/image-tools.php IMAGE_TOOLS_BLADE_PATHS=resources/views,modules/*/resources/views IMAGE_TOOLS_PHP_PATHS=app,modules # Deferred generation (optional) IMAGE_TOOLS_QUEUE=true # queue every miss; defaults to false IMAGE_TOOLS_QUEUE_FALLBACK=original # original | none | derivative IMAGE_TOOLS_QUEUE_CONNECTION=redis # defaults to QUEUE_CONNECTION IMAGE_TOOLS_QUEUE_NAME=images # defaults to "default" IMAGE_TOOLS_QUEUE_UNIQUE_FOR=3600
Key options:
disk— Laravel filesystem disk where processed files are written and served from (public,s3,r2, …).put_options— options that each upload passes to the disk (putFileAs()), default[]. For example, on an S3 or R2 disk:['CacheControl' => 'public, max-age=31536000, immutable'].driver— the spatie/image driver:gd,imagick,vips, or the class name of a spatie/imageImageDriver.null(the default) lets spatie/image choose: Imagick when the extension is loaded, else GD. Set it when yourqvalues are tuned for one driver.optimizers— the optimizer chain that runs after each encode, in the format of the spatie/laravel-medialibraryimage_optimizersconfig (optimizer class => arguments).null(the default) runs the spatie/image-optimizer default chain;[]runs none. See Optimizers.allow_upscale—falseby default: a resize byworhlarger than the source stores no file, andasset()returnsnull. See Larger than the source.manifest_path— Path to the PHP manifest file that stores the mapping (relative paths resolve from the project base path).blade_paths— Directories with Blade templates to scan for usages.php_paths— Additional PHP directories to scan (controllers, services, etc.).queue,queue_fallback— generate misses in a queued job, and choose what the first render gets. See Deferred (queued) generation.
The request is canonicalized: query keys are sorted before hashing, so
?h=630&w=1200equals?w=1200&h=630.
Zero‑downtime deploys (Forge, Envoyer, Deployer, Vapor): the manifest is written at runtime when
asset()generates an image on demand.bootstrap/cacheis per‑release, so those entries are lost on the next deploy (the next miss adopts the file that is still on the disk, without an encode). If you rely on on‑demand generation, point the manifest at the sharedstoragedirectory, e.g.IMAGE_TOOLS_MANIFEST_PATH=storage/app/image-tools.php. If you only pre‑generate at build time withimagetools:generate, the default is fine.
Query options
| Key | Type | Description |
|---|---|---|
w |
int |
Target width (px). Without fit and greater than the source width, no file is stored — see Larger than the source. |
h |
int |
Target height (px). Without w or fit and greater than the source height, no file is stored. |
fit |
enum |
Geometry mode from Spatie\Image\Enums\Fit (e.g. Contain, Fill, Max, …). Requires w and h. |
q |
int |
Output quality (1..100). |
format |
enum |
Output format: jpeg, png, gif, webp, avif. |
lossless |
bool |
Lossless WebP: 1, true, on or yes switch it on. Takes effect when the output is a WebP and the driver is Imagick; otherwise the ordinary encode runs. A switch that is on is part of the canonical name, so the lossless variant is its own file; a switch that is off resolves to the same file as the plain call. |
optimize |
bool |
On by default. 0, false, off or no skip the optimizer chain, so the file is the encoder's output at q. A switch that is off is part of the canonical name (its own file); a switch that is on resolves to the same file as the plain call. |
queueis a control flag, not a transform — see below. It is excluded from the canonical name, so?w=800and?w=800&queue=1resolve to the same file.
Optimizers
After each encode, an optimizer chain rewrites the file. By default it is the
spatie/image-optimizer chain. That chain
encodes a WebP again with cwebp -q 90 and an AVIF with cq-level=23, whatever q
the query asks for. A lossy WebP or AVIF can then grow larger than the encoder's output.
- Skip the chain for one request with
optimize=0:ImageTools::asset('public/images/hero.jpg?w=1400&format=avif&q=75&optimize=0');
- Or set your own chain for every request in
config/image-tools.php. The optimizers run in the given order;[]runs none:'optimizers' => [ Spatie\ImageOptimizer\Optimizers\Jpegoptim::class => ['-m85', '--strip-all', '--all-progressive'], Spatie\ImageOptimizer\Optimizers\Pngquant::class => ['--force'], ],
A lossless WebP (lossless=1) always uses its own lossless cwebp chain. driver and
optimizers are not part of the file name. After you change one of them, run
php artisan imagetools:regenerate --all to encode the existing entries again.
Larger than the source
A resize by w or h does not enlarge a source that is smaller than the request.
An enlarged file holds no more detail than the source, and it is much heavier. For
example, ?w=3840 on a 1280px source stores no file. The manifest records the
request with 'path' => null, and asset() returns null. Later calls read that
entry and do not load the source again.
wcounts when it is greater than the source width.hcounts only withoutw, when it is greater than the source height. The size is measured after EXIF rotation. GD applies that rotation only whenext-exifandext-fileinfoare loaded.wequal to the source width is not larger: it stores a file at the source size.fitis not affected. For example,fit=crop&w=1200&h=630still produces 1200×630 from a smaller source.- Set
allow_upscaletotrue(IMAGE_TOOLS_ALLOW_UPSCALE=true) to enlarge as version 1.3 and earlier did.
{{ null }} prints nothing, but the text around it still prints. A srcset needs a
check before each candidate:
@php $srcset = collect([ 768 => ImageTools::asset('public/images/hero.jpg?w=768&format=webp'), 1536 => ImageTools::asset('public/images/hero.jpg?w=1536&format=webp'), 3840 => ImageTools::asset('public/images/hero.jpg?w=3840&format=webp'), ])->filter()->map(fn ($url, $width) => "{$url} {$width}w")->implode(', '); @endphp <img src="{{ ImageTools::asset('public/images/hero.jpg?format=webp') }}" srcset="{{ $srcset }}" sizes="100vw" alt="Hero" />
The src request has no w, so it is never larger than the source, and asset()
never returns null for it. The package does not add a candidate at the source
width: for a 1280px source, the list above keeps only 768w. Add widths that match
your sources if you need more.
Upgrading from 1.3 or earlier: existing entries keep their enlarged files, because the key and the filename do not change. Two commands apply the rule:
php artisan imagetools:regenerate --allrebuilds every entry with a recorded source. An entry larger than the source gets'path' => null, and its enlarged file is deleted. The command lists each such entry and counts it aslarger than the source. Entries without a recorded source are skipped.php artisan imagetools:generatedeletes the files that the manifest references and the manifest itself. Then it builds every request it finds in the code by the new rule. A build pipeline that runs it applies the rule on the next deploy.
The enlarged file is deleted even when the manifest no longer holds its entry, for
example a per‑release bootstrap/cache. Pages cached before the run (full‑page
cache, CDN HTML) still point to the deleted files, so clear those caches after it.
When opcache does not check timestamps (opcache.validate_timestamps=0), reload
PHP‑FPM too: the web processes keep their compiled copy of the manifest. Restart
long‑lived processes as well (php artisan queue:restart, php artisan octane:reload):
they keep the manifest in memory and would print URLs to the deleted files. When
several hosts keep their own manifest but share one output disk, run the command on
each host: the files it deletes are the ones the other manifests still point to. A
later change to allow_upscale needs the same steps.
A null entry stays null when you replace its source with a larger image: the
key does not change, so nothing reads the source again. Run
imagetools:regenerate --all after you replace a source.
Deferred (queued) generation
On a page with many new images — especially a responsive srcset with several
widths — generating them all in the first request is slow, and the request can
time out. Queued mode moves the encode to a worker, so the page responds at once.
Turn it on for every call in the config:
IMAGE_TOOLS_QUEUE=true
A queue flag in the query overrides the config for one call, in both directions:
<img src="{{ ImageTools::asset('public/images/hero.jpg?w=1200&format=webp&queue=1') }}"> {{-- Generated in the request, even when the config queues: --}} <img src="{{ ImageTools::asset('public/images/logo.png?w=200&queue=0') }}">
When the image has not been generated yet:
-
A
GenerateImageJobis dispatched. Make sure a worker runs (php artisan queue:work, or Horizon). -
asset()returns thequeue_fallbackvalue for this render:Value asset()returnsoriginal(default)The public URL of the unprocessed source. A disk source gets the URL of its disk. A local file gets a URL only when it is under public/; any other file gets''.none''. The first visitor sees no image.derivativeThe URL of the derivative. It returns 404 until the worker is done (the behaviour of queue=1in 1.4 and earlier). -
A render after the worker has run gets the derivative.
-
All widths of one source share one original URL, so the browser downloads it once.
The job is unique per derivative (ShouldBeUnique, keyed on the canonical
seed). While it is pending, later renders queue nothing and get the fallback
again. This requires a cache store that supports atomic locks (file, redis,
database, memcached, …).
Things to know:
- A connection with the
syncdriver runs a job in the request. A queued miss on such a connection is generated in the request, andasset()returns the real result. In tests withQUEUE_CONNECTION=sync,Bus::fake()therefore sees no dispatch. - When the dispatch fails (for example, the queue backend is down),
asset()logs a warning and generates in the request. The page is slow, but it does not fail. - When a job cannot generate the image (for example, the source is missing), it logs a warning. The next render queues it again.
- The query is checked in the worker, not in the request. A query that fails
validation (
w=640px,fitwithouth), or an encode that takes longer than the worker's--timeout, fails the job. Each later render queues it again, and the failures show infailed_jobs, not on the page. Give the worker a--timeoutthat fits your largest source. - The web processes and the workers must read one manifest file. On a miss,
asset()reads the file again when it changed, so a worker's entry reaches PHP-FPM (also withopcache.validate_timestamps=0) and long-lived processes. When the workers run on another host with their own manifest, the web never sees their entries: the page shows the original with no end, and each render queues the image again. Putmanifest_pathon shared storage, or setqueue_fallbacktoderivative. - Output that is rendered once keeps the fallback: a queued mail or
notification, and HTML in a full‑page or CDN cache. Use
queue=0for those calls, or pre‑generate the images. - For a request larger than the source,
asset()cannot know this before the worker reads the source. Withoriginal, the first render shows the original. The worker stores no file, and later renders returnnull. - Inside
<picture>, a<source type="image/avif">ortype="image/webp"gets the original URL too. The browser picks the<source>by itstype, receives a JPEG or PNG, and shows it (checked in Chromium). A format that browsers cannot show, such as HEIC or TIFF, shows as a broken image until the worker is done. originalputs the URL of the source into the HTML: the host, the bucket, the directories and the file name. For a source that is readable but linked nowhere, it links the full‑size file, with its metadata (EXIF, GPS). Usenoneorderivativefor private sources and user uploads.- A private disk (for example, a private S3 bucket), or a disk without a
url(such as Laravel'slocaldisk), gives a URL that does not show the image (403 or 404). Withoriginal, such an image is broken until the worker is done. - A path with a
..segment gets no original URL (''), because a browser would resolve it to the parent directory. The worker can still make the derivative. - A job checks the manifest before it encodes. When the entry exists already (another job, or a web process that saw an old copy), the job ends at once.
Configuration (all optional — see config/image-tools.php):
queue— queue every miss (defaultfalse).queue_fallback—original(default),noneorderivative; see the table above.queue_connection— falls back toQUEUE_CONNECTION.queue_name— queue to dispatch on (default"default").unique_for— seconds the uniqueness lock is held (default3600).
Pre-generating with
php artisan imagetools:generateignores thequeueflag and produces the same files, so you can warm everything at build time instead.
Reading the source from a disk (e.g. S3)
By default the source image is read locally, relative to base_path(). To
read the original from a configured Laravel filesystem disk instead — for example
an S3 bucket — scope the call with disk(), mirroring Storage::disk():
use Isapp\ImageTools\Facades\ImageTools; // Reads s3://<bucket>/assets/hero.jpg, processes it, writes the result to the // configured output disk (image-tools.disk). ImageTools::disk('s3')->asset('assets/hero.jpg?w=1200&format=webp');
<img src="{{ ImageTools::disk('s3')->asset('assets/hero.jpg?w=800') }}">
- The original is streamed to a temporary local file (the image driver loads from a path), processed, and the temp copy is removed.
- The source disk participates in the identity: the same path read from different disks produces distinct files and manifest keys (no collisions).
disk()returns a scoped copy — it does not mutate the shared instance.- The output disk is still
config('image-tools.disk');disk()only changes where the source is read from.
The scanner command (
imagetools:generate) finds thedisk('…')form when the disk name is a literal string:ImageTools::disk('s3')->asset('…')andapp('image-tools')->disk('s3')->asset('…'). It does not find a disk from a variable (disk($name)) or a path built at runtime. Those images are generated on demand, or in the queue.
spatie/laravel-medialibrary
ImageTools::media() makes a derivative of the original file of a
medialibrary Media:
<img src="{{ ImageTools::media($media, 'fit=crop&w=1400&h=600&format=avif&q=75&optimize=0') }}">
- The call is the same as
ImageTools::disk($media->disk)->asset($media->getPathRelativeToRoot() . '?' . $query): the same query options, the same return values, the same file. - The manifest entry also records the Media key as
media_id. The key is not part of the canonical name, soasset()andmedia()for the same file share one entry. An entry thatasset()wrote first has nomedia_id. - A queued miss carries the key to the job, and
imagetools:regeneratekeeps it. - The package does not depend on medialibrary. Any object with a
diskattribute and the methodsgetPathRelativeToRoot()andgetKey()works; another object throws anInvalidArgumentException. - The scanner does not find
media()calls: those images are generated on demand, or in the queue.
Commands
Pre‑generate from code (CI‑friendly)
Scans your codebase and generates images for discovered usages.
php artisan imagetools:generate
The scanner looks into config('image-tools.blade_paths') and config('image-tools.php_paths') and detects:
ImageTools::asset('…')- Container‑resolved calls (e.g.
app(ImageTools::class)->asset('…'),app('image-tools')->asset('…'),App::make(...)->asset('…')) - The same calls scoped to a source disk with a literal name (e.g.
ImageTools::disk('s3')->asset('…'),app('image-tools')->disk('s3')->asset('…'))
Clear generated files
Removes the manifest file, then deletes every file it referenced. The manifest is read
and removed under its write lock; the .lock file stays in place. Each file is then
deleted under the same lock, after the command reads the manifest again: a request
that missed in the meantime and adopted a file keeps it.
php artisan imagetools:clear
Regenerate broken files
Walks the manifest and rebuilds every entry whose stored file is missing or empty — the shape a killed optimizer leaves behind. Each entry is rebuilt from the source recorded next to it, so the key and the filename stay the same.
php artisan imagetools:regenerate
| Option | Effect |
|---|---|
--all |
Regenerate every entry, not only the broken ones. |
--dry-run |
Report what would be regenerated and write nothing. |
The manifest is rewritten entry by entry and is never deleted, so the site keeps
serving while the command runs. The exception is an entry that becomes
larger than the source: its enlarged file is deleted, and
pages cached before the run still point to it. An entry that cannot be rebuilt keeps its current
value and is reported: the command exits with a non‑zero status when anything
failed. Entries written before the source was recorded are skipped — run
imagetools:generate to rebuild those from the code.
An entry with 'path' => null is a request larger than the source.
It has no file by design, so it counts as healthy. --all rebuilds it by the current
allow_upscale value.
A rebuild that finds the request larger than the source prints
No file [<key>]: the request is larger than the source. The summary counts it as
larger than the source, not as regenerated. The exit status stays zero. A
--dry-run rebuilds nothing, so it cannot find these entries.
Each write takes a lock on the manifest and starts from the file as it is on disk, so an entry that a web request or a queue worker adds while the command runs is kept. A long‑lived process no longer writes its old copy of the manifest back over the command's changes. When the lock cannot be taken, the write goes ahead without it and a warning is logged.
Prune orphan files
Deletes orphans: generated files on the output disk that no entry of the manifest references. Orphans stay behind, for example, after a manifest was lost or replaced: the new manifest knows only the files that requests asked for since then.
A changed query is not an orphan case. The entry of the old query stays in the manifest and still references its file, so prune keeps that file.
php artisan imagetools:prune --dry-run # list the orphans, delete nothing
php artisan imagetools:prune
| Option | Effect |
|---|---|
--dry-run |
List the orphans and delete nothing. More than half of orphans gives a warning, not a refusal. |
--force |
Run also with a manifest under bootstrap/cache, or when more than half of the files are orphans. |
- Only files directly in
image-tools/with a name that ImageTools makes (<name>--<10 hex>.<ext>) count. Other files are not touched. - A file modified in the last hour is kept. A new file can still be waiting for its
manifest entry. A file whose disk gives no modification time is kept too, and the
summary counts it as
kept with no time. - Each delete takes the manifest lock and reads the manifest again first. A file that a request adopted in the meantime, or that changed since the listing, is skipped. Unlike a write, the command never runs without the lock.
- A delete that fails, a listing that fails and a lock that cannot be taken are logged as a warning and printed. The command goes on with the next file, and it exits with a non‑zero status when anything failed.
The command refuses to run, and deletes nothing, in these cases:
- The manifest is empty. Every file would count as an orphan. Use
imagetools:clearto delete every generated file. - The manifest is under
bootstrap/cache(the defaultmanifest_path). With a zero‑downtime deploy, each release has its own manifest. The files of the release before it, which still serves or can come back after a rollback, would count as orphans. Move the manifest to shared storage (IMAGE_TOOLS_MANIFEST_PATH=storage/app/image-tools.php), or pass--force. - More than half of the files are orphans. This usually means a different
manifest from the one the web and the workers use. Check it, then pass
--force.
Before you run it:
- Pages cached before the run (a full‑page cache, CDN HTML) can still point to the deleted files. Clear those caches after it.
- The command reads only the default manifest. Do not run it when several hosts with their own manifest, or extra manifests loaded with
loadManifest($path), share one output disk: their files count as orphans.
What gets written
- A processed file on the configured disk, under
image-tools/<name>--<hash>.<ext>. - A lock file next to the manifest (
image-tools.php.lock). Writers take turns on it; it stays in place and holds no data. - A PHP manifest (by default
bootstrap/cache/image-tools.php) with entries like:return [ 'resource/images/hero.jpg?h=630&w=1200&fit=contain&format=webp&q=82' => [ 'path' => 'image-tools/hero--a1b2c3d4e5.webp', 'disk' => 'public', // The source this file was built from, used by imagetools:regenerate. 'source' => 'resource/images/hero.jpg?w=1200&h=630&fit=contain&format=webp&q=82', 'source_disk' => null, ], // A request larger than the source: no file, and asset() returns null. 'resource/images/icon.png?w=3840' => [ 'path' => null, 'disk' => null, 'source' => 'resource/images/icon.png?w=3840', 'source_disk' => null, ], // A media() call: the entry also records the Media key. 's3:42/slide.jpg?format=avif&w=1400' => [ 'path' => 'image-tools/slide--f6e5d4c3b2.avif', 'disk' => 'public', 'source' => '42/slide.jpg?w=1400&format=avif', 'source_disk' => 's3', 'media_id' => 42, ], ];
When the manifest has no entry for a request but the file at its name is on the disk (for example, a lost manifest), the file is adopted: the entry is written again and nothing is encoded. The source is still loaded, because the size check needs it. A disk that cannot answer the check (for example S3 credentials without the right to list, which turn a missing key into a 403) logs a warning, and the image is encoded.
The check of the stored file runs while the manifest lock is held, so a slow disk
holds up other writers. Give an S3 or R2 disk an HTTP timeout, for example
'http' => ['connect_timeout' => 5, 'timeout' => 30] in config/filesystems.php.
Tips
- Deterministic names → long CDN cache is safe; change options or the source to bust the cache.
- Disks: for S3/R2 configure a public bucket or use signed URLs as needed.
- Quality/Formats:
webp/avifusually win; measure before/after.
Troubleshooting
Class ...\ImageTools not found— ensure the package is installed and auto‑discovered; runcomposer dump-autoload.ImagickException/ missing extension — install and enable Imagick (preferred) or GD for your PHP runtime.asset()returns''and the page renders — the image could not be generated in the request. For a query that is not valid, or a driver or encoder error,asset()reports the exception through Laravel's exception handler and logs the warningthe image could not be generatedwith the path and the disk (Laravel does not report aValidationException). A failed upload logs its own warning. A missing source gives''with no log.generate(), the queued job and the commands still throw.- A WebP or AVIF is larger than expected — the default optimizer chain encodes it again at its own quality. Add
optimize=0to the query, or setoptimizers. See Optimizers. width(): Argument #1 must be of type int— pass numeric values in the query (w=640, notw=640px).fitrequireswandh— when usingfit, provide both dimensions.- No URL / 404 — check the configured
diskhas a URL generator (php artisan storage:linkforpublicdisk). - Empty
src/asset()returnsnull— the request asks for aworhlarger than the source. See Larger than the source: leave the candidate out, ask for a smaller size, or setallow_upscale. In queued mode, the first render gets thequeue_fallbackvalue for such a request, and the renders after the worker has run getnull. - Empty
srcon the first render in queued mode —queue_fallbackisnone, or it isoriginaland the source has no public URL (a local file outsidepublic/). See Deferred (queued) generation.
Testing
composer test
The tests draw their image fixtures with GD, so development needs ext-gd.
The suite includes an S3 integration test (PHPUnit group s3) that runs
against a real S3‑compatible endpoint to verify uploads, URL generation and the
clear command. It is skipped unless AWS_ENDPOINT + AWS_BUCKET are set, so
the default run needs no infrastructure. To run it locally against MinIO:
# MinIO's own images are no longer public; this is Chainguard's MinIO build. docker run -d -p 9000:9000 -e MINIO_ROOT_USER=minio \ -e MINIO_ROOT_PASSWORD=minio12345 cgr.dev/chainguard/minio:latest server /data aws --endpoint-url http://127.0.0.1:9000 s3 mb s3://test # create the bucket AWS_ENDPOINT=http://127.0.0.1:9000 AWS_BUCKET=test \ AWS_ACCESS_KEY_ID=minio AWS_SECRET_ACCESS_KEY=minio12345 \ AWS_USE_PATH_STYLE_ENDPOINT=true vendor/bin/phpunit --group s3
CI runs this automatically in a dedicated MinIO job.
Versioning
This package follows Semantic Versioning. See CHANGELOG.md for release notes.
Security
If you discover a security issue, please email contact@isapp.be instead of opening a public issue.
Contributing
Contributions are welcome! If you have suggestions for improvements, new features, or find any issues, feel free to submit a pull request or open an issue in this repository.
Thank you for helping make this package better for the community!
License
This project is open-sourced software licensed under the MIT License.
You are free to use, modify, and distribute it in your projects, as long as you comply with the terms of the license.
Credits
Built by ISAPP. Uses the excellent spatie/image.
Check out our software development services at isapp.be.