Search by

isapp / laravel-imagetools

andrii-trush

Laravel image tools: deterministic, query-driven image generation (vite-imagetools-like).

Package info

github.com/isap-ou/laravel-imagetools

pkg:composer/isapp/laravel-imagetools

Statistics

Installs: 694

Dependents: 0

Suggesters: 0

Stars: 6

Open Issues: 2

1.6.0 2026-10-21 14:15 UTC

README

Deterministic, query‑driven image generation for Laravel — inspired by vite-imagetools.

Laravel Image Tools by ISAPP

Packagist Tests PHP License

  • 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 queue flag 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/h request without fit larger than the source returns null instead 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; set driver to 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() returns null and src is 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/image ImageDriver. null (the default) lets spatie/image choose: Imagick when the extension is loaded, else GD. Set it when your q values are tuned for one driver.
  • optimizers — the optimizer chain that runs after each encode, in the format of the spatie/laravel-medialibrary image_optimizers config (optimizer class => arguments). null (the default) runs the spatie/image-optimizer default chain; [] runs none. See Optimizers.
  • allow_upscale — false by default: a resize by w or h larger than the source stores no file, and asset() returns null. 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=1200 equals ?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/cache is 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 shared storage directory, e.g. IMAGE_TOOLS_MANIFEST_PATH=storage/app/image-tools.php. If you only pre‑generate at build time with imagetools: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.

queue is a control flag, not a transform — see below. It is excluded from the canonical name, so ?w=800 and ?w=800&queue=1 resolve 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.

  • w counts when it is greater than the source width. h counts only without w, when it is greater than the source height. The size is measured after EXIF rotation. GD applies that rotation only when ext-exif and ext-fileinfo are loaded.
  • w equal to the source width is not larger: it stores a file at the source size.
  • fit is not affected. For example, fit=crop&w=1200&h=630 still produces 1200×630 from a smaller source.
  • Set allow_upscale to true (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 --all rebuilds 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 as larger than the source. Entries without a recorded source are skipped.
  • php artisan imagetools:generate deletes 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 GenerateImageJob is dispatched. Make sure a worker runs (php artisan queue:work, or Horizon).

  • asset() returns the queue_fallback value for this render:

    Value asset() returns
    original (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.
    derivative The URL of the derivative. It returns 404 until the worker is done (the behaviour of queue=1 in 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 sync driver runs a job in the request. A queued miss on such a connection is generated in the request, and asset() returns the real result. In tests with QUEUE_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, fit without h), or an encode that takes longer than the worker's --timeout, fails the job. Each later render queues it again, and the failures show in failed_jobs, not on the page. Give the worker a --timeout that 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 with opcache.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. Put manifest_path on shared storage, or set queue_fallback to derivative.
  • Output that is rendered once keeps the fallback: a queued mail or notification, and HTML in a full‑page or CDN cache. Use queue=0 for those calls, or pre‑generate the images.
  • For a request larger than the source, asset() cannot know this before the worker reads the source. With original, the first render shows the original. The worker stores no file, and later renders return null.
  • Inside <picture>, a <source type="image/avif"> or type="image/webp" gets the original URL too. The browser picks the <source> by its type, 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.
  • original puts 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). Use none or derivative for private sources and user uploads.
  • A private disk (for example, a private S3 bucket), or a disk without a url (such as Laravel's local disk), gives a URL that does not show the image (403 or 404). With original, 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 (default false).
  • queue_fallback — original (default), none or derivative; see the table above.
  • queue_connection — falls back to QUEUE_CONNECTION.
  • queue_name — queue to dispatch on (default "default").
  • unique_for — seconds the uniqueness lock is held (default 3600).

Pre-generating with php artisan imagetools:generate ignores the queue flag 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 the disk('…') form when the disk name is a literal string: ImageTools::disk('s3')->asset('…') and app('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, so asset() and media() for the same file share one entry. An entry that asset() wrote first has no media_id.
  • A queued miss carries the key to the job, and imagetools:regenerate keeps it.
  • The package does not depend on medialibrary. Any object with a disk attribute and the methods getPathRelativeToRoot() and getKey() works; another object throws an InvalidArgumentException.
  • 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:clear to delete every generated file.
  • The manifest is under bootstrap/cache (the default manifest_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/avif usually win; measure before/after.

Troubleshooting

  • Class ...\ImageTools not found — ensure the package is installed and auto‑discovered; run composer 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 warning the image could not be generated with the path and the disk (Laravel does not report a ValidationException). 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=0 to the query, or set optimizers. See Optimizers.
  • width(): Argument #1 must be of type int — pass numeric values in the query (w=640, not w=640px).
  • fit requires w and h — when using fit, provide both dimensions.
  • No URL / 404 — check the configured disk has a URL generator (php artisan storage:link for public disk).
  • Empty src / asset() returns null — the request asks for a w or h larger than the source. See Larger than the source: leave the candidate out, ask for a smaller size, or set allow_upscale. In queued mode, the first render gets the queue_fallback value for such a request, and the renders after the worker has run get null.
  • Empty src on the first render in queued mode — queue_fallback is none, or it is original and the source has no public URL (a local file outside public/). 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.