php artisan boost:add-skill isapp/laravel-imagetools
Save this content to: AGENTS.md
# AGENTS.md
Guidance for AI coding agents working on **`isapp/laravel-imagetools`**.
## What this is
A Laravel package for **deterministic, query‑driven image generation** (inspired by
vite‑imagetools). `ImageTools::asset('path/to/img.jpg?w=1200&format=webp')` produces a
hash‑named derivative on a configured filesystem disk and records a `path?query → file`
mapping in a PHP manifest, so later calls are cheap. Generation can happen on demand,
ahead of time (scanner command), or on a queue.
## Setup
- PHP **^8.2** with `ext-gd` (the tests draw their fixtures with GD). Install: `composer install`.
- Dependencies live in `vendor/` — never assume an API; read the installed source.
## Commands
- **Tests:** `composer test` (PHPUnit via Orchestra Testbench).
- Single group: `vendor/bin/phpunit --group s3`.
- The `s3` group exercises a real S3 endpoint and **skips** unless `AWS_ENDPOINT` +
`AWS_BUCKET` are set — see the README "Testing" section for the local MinIO recipe.
- **Lint:** `composer lint` (Laravel Pint). Check‑only: `vendor/bin/pint --test`.
## Layout
- `src/ImageTools.php` — the orchestrator (`asset()`, `generate()`, `disk()`, queued
dispatch). Depends on three injected collaborators in `src/Support/`:
- `Support/Manifest.php` — the PHP manifest state + persistence (load/has/get/put/putIfAbsent/
clear/refresh/whileLocked; writes take the `<manifest>.lock` lock).
- `Support/PathResolver.php` — canonical `seed()` and the deterministic `storedFile()`.
- `Support/SourceReader.php` — resolves a source to a local path (local or disk→temp), and
gives its public URL (`publicUrl()`, the `original` fallback of a queued miss).
Wiring lives in `src/ServiceProvider.php` (`Manifest` is bound with its config path; the
rest auto‑resolve).
- `src/Jobs/GenerateImageJob.php` — queued generation (`ShouldQueue` + `ShouldBeUnique`).
- `src/Commands/GenerateImagesCommand.php` — scans Blade/PHP (nikic/php-parser) and
pre‑generates; `src/Commands/ClearGeneratedImagesCommand.php` — removes generated files
and the manifest; `src/Commands/RegenerateImagesCommand.php` — rebuilds entries whose
stored file is missing or empty, from the source each entry records;
`src/Commands/PruneImagesCommand.php` — deletes generated files that no entry of the default
manifest references.
- `src/Facades/ImageTools.php`, `src/ServiceProvider.php`, `config/image-tools.php`.
- `tests/` — Feature + Unit (Testbench `TestCase`).
## Conventions
- PSR‑4 `Isapp\ImageTools\` → `src/`. `declare(strict_types=1)` in every PHP file.
- Code style is **Laravel Pint** (`pint.json`); run `composer lint` before pushing.
- **Conventional Commits.** Branch off `main` — never commit to `main` directly. PRs are
**squash‑merged**.
- CI must stay green: matrix (PHP 8.2–8.4 × Laravel 12/13), Pint, the Imagick-driver job, and
the MinIO S3 job.
## Invariants — do not break
- The manifest key **and** the generated filename both derive from one canonical "seed":
the query reduced to the supported keys (`w, h, q, fit, format, lossless, optimize`), sorted.
`lossless` is kept only when on and `optimize` only when off (as `0`), so a switch at its
default never changes a filename. `asset()`
(read path) and `generate()` (write path) MUST use the same derivation — both go through
`PathResolver::seed()` / `storedFile()`. Divergence causes cache misses and broken URLs.
- `queue` is a **control flag**, deliberately excluded from the seed, so it never affects
the filename or manifest key.
- The **source disk** (set via `disk()`) is folded into the seed so the same path read from
different disks never collides; it is used only as a key/hash input, never parsed as a path.
- Default (non‑queued, local‑source) behaviour stays fully **synchronous**; only the `queue` flag
or the `queue` config defers generation. A queued miss dispatches through
`GenerateImageJob::dispatch()` (a `PendingDispatch`), never `Bus::dispatch()`: only a
`PendingDispatch` takes the `ShouldBeUnique` lock, which is the one "pending" state. The job's unique id is a hash (fixed key length), and a failed push
releases the lock. A `sync` connection and a failed dispatch generate in the request. A job
returns before it encodes when its entry exists already (`ImageTools::has()`).
- On a manifest miss, `asset()` calls `Manifest::refresh()` once. It is read only — never a write.
`load()` records no signature, because its `require` can return an older opcache copy: the
first `refresh()` compares the file size with the text `put()` would write for the copy in
memory, and later calls compare the file's signature (inode, mtime, size).
- A manifest entry records the `source` and `source_disk` it was built from. `imagetools:regenerate`
rebuilds from those recorded values — the seed in the key is never parsed back into a path and a
disk. The command also never deletes the manifest or an entry.
- A resize by `w` or `h` never enlarges the source unless `allow_upscale` is on. Such a request
gets an entry with `path => null` and no file, and `asset()` returns `null` for it. `fit` is
never affected. `allow_upscale` is not part of the seed. Every reader of an entry (`asset()`,
`imagetools:clear`, `imagetools:regenerate`) must handle a null `path`.
- `Manifest::put()` writes under a lock (`<manifest>.lock`) and starts from the file on disk, then
changes one key; `Manifest::clear()` reads and deletes under the same lock. Never write an
in‑memory copy of a whole manifest back: a queue worker holds the `image-tools` singleton between
jobs, and its copy can be old. The lock must never make a write fail — it falls back to a
read‑only handle, then to no lock with a warning.
- A clean-up delete of an old file (`recordOversize()`) comes after the manifest write and never
fails the request. A refused delete is logged as a warning, both when the disk throws and when
`delete()` returns `false` (a disk with `'throw' => false`, the Laravel default).
- `driver`, `optimizers`, `put_options` and `media_id` are never part of the seed. `media()` is
`disk($media->disk)->asset(...)` plus a recorded `media_id`; every entry write goes through
`ImageTools::entry()`, and the job and `imagetools:regenerate` carry the id along.
- On a miss, `generate()` adopts a non-empty file at the stored name only through
`Manifest::putIfAbsent()`: the file check and the write share the lock, and only when the file
has no entry. A first check runs without the lock, so a real miss never holds it during disk
calls. The source size check runs first. An entry that exists is always encoded again. A disk
that cannot answer the check (`FilesystemException`) means "not stored": log, then encode.
- `asset()` catches a `Throwable` from the in-request `generate()`, reports it, logs a warning with
the path and the disk (Laravel does not report a `ValidationException`) and returns `''`.
`generate()`, the job and the commands still throw.
- `GenerateImageJob::$mediaId` is a plain property with a default, not a promoted one: a payload
queued by an older version has no `mediaId`, and `unserialize()` never runs the constructor.
- Every delete of a generated file by a command runs inside `whileLocked()`, after the manifest is
read again, and skips a file that an entry references again. This holds for `imagetools:prune`
and `imagetools:clear`. Clear falls back to an unlocked delete only when the lock cannot be taken.
- `imagetools:prune` reads only the default manifest. It refuses an empty manifest (always), a
manifest under `bootstrap/cache` and more than half of the files as orphans (both lifted by
`--force`; a dry run only warns on the second).
- `Manifest::whileLocked()` is the one lock user without the no-lock fallback: it throws instead,
because it guards a delete. Code inside it (and inside `putIfAbsent()`'s condition) never writes
the manifest: a second `flock` in the same process waits for ever. `imagetools:prune` deletes a
file only inside `whileLocked()`, after it reads the manifest again, and keeps files modified in
the last hour (an upload comes before its entry write).
## Where to read more
- [README.md](README.md) — usage, query options, queued generation, config, troubleshooting.
- [CHANGELOG.md](CHANGELOG.md) — notable changes.
Guidance for AI coding agents working on isapp/laravel-imagetools.
A Laravel package for deterministic, query‑driven image generation (inspired by
vite‑imagetools). ImageTools::asset('path/to/img.jpg?w=1200&format=webp') produces a
hash‑named derivative on a configured filesystem disk and records a path?query → file
mapping in a PHP manifest, so later calls are cheap. Generation can happen on demand,
ahead of time (scanner command), or on a queue.
ext-gd (the tests draw their fixtures with GD). Install: composer install.vendor/ — never assume an API; read the installed source.composer test (PHPUnit via Orchestra Testbench).
vendor/bin/phpunit --group s3.s3 group exercises a real S3 endpoint and skips unless AWS_ENDPOINT +
AWS_BUCKET are set — see the README "Testing" section for the local MinIO recipe.composer lint (Laravel Pint). Check‑only: vendor/bin/pint --test.src/ImageTools.php — the orchestrator (asset(), generate(), disk(), queued
dispatch). Depends on three injected collaborators in src/Support/:
Support/Manifest.php — the PHP manifest state + persistence (load/has/get/put/putIfAbsent/
clear/refresh/whileLocked; writes take the <manifest>.lock lock).Support/PathResolver.php — canonical seed() and the deterministic storedFile().Support/SourceReader.php — resolves a source to a local path (local or disk→temp), and
gives its public URL (publicUrl(), the original fallback of a queued miss).
Wiring lives in src/ServiceProvider.php (Manifest is bound with its config path; the
rest auto‑resolve).src/Jobs/GenerateImageJob.php — queued generation (ShouldQueue + ShouldBeUnique).src/Commands/GenerateImagesCommand.php — scans Blade/PHP (nikic/php-parser) and
pre‑generates; src/Commands/ClearGeneratedImagesCommand.php — removes generated files
and the manifest; src/Commands/RegenerateImagesCommand.php — rebuilds entries whose
stored file is missing or empty, from the source each entry records;
src/Commands/PruneImagesCommand.php — deletes generated files that no entry of the default
manifest references.src/Facades/ImageTools.php, src/ServiceProvider.php, config/image-tools.php.tests/ — Feature + Unit (Testbench TestCase).Isapp\ImageTools\ → src/. declare(strict_types=1) in every PHP file.pint.json); run composer lint before pushing.main — never commit to main directly. PRs are
squash‑merged.w, h, q, fit, format, lossless, optimize), sorted.
lossless is kept only when on and optimize only when off (as 0), so a switch at its
default never changes a filename. asset()
(read path) and generate() (write path) MUST use the same derivation — both go through
PathResolver::seed() / storedFile(). Divergence causes cache misses and broken URLs.queue is a control flag, deliberately excluded from the seed, so it never affects
the filename or manifest key.disk()) is folded into the seed so the same path read from
different disks never collides; it is used only as a key/hash input, never parsed as a path.queue flag
or the queue config defers generation. A queued miss dispatches through
GenerateImageJob::dispatch() (a PendingDispatch), never Bus::dispatch(): only a
PendingDispatch takes the ShouldBeUnique lock, which is the one "pending" state. The job's unique id is a hash (fixed key length), and a failed push
releases the lock. A sync connection and a failed dispatch generate in the request. A job
returns before it encodes when its entry exists already (ImageTools::has()).asset() calls Manifest::refresh() once. It is read only — never a write.
load() records no signature, because its require can return an older opcache copy: the
first refresh() compares the file size with the text put() would write for the copy in
memory, and later calls compare the file's signature (inode, mtime, size).source and source_disk it was built from. imagetools:regenerate
rebuilds from those recorded values — the seed in the key is never parsed back into a path and a
disk. The command also never deletes the manifest or an entry.w or h never enlarges the source unless allow_upscale is on. Such a request
gets an entry with path => null and no file, and asset() returns null for it. fit is
never affected. allow_upscale is not part of the seed. Every reader of an entry (asset(),
imagetools:clear, imagetools:regenerate) must handle a null path.Manifest::put() writes under a lock (<manifest>.lock) and starts from the file on disk, then
changes one key; Manifest::clear() reads and deletes under the same lock. Never write an
in‑memory copy of a whole manifest back: a queue worker holds the image-tools singleton between
jobs, and its copy can be old. The lock must never make a write fail — it falls back to a
read‑only handle, then to no lock with a warning.recordOversize()) comes after the manifest write and never
fails the request. A refused delete is logged as a warning, both when the disk throws and when
delete() returns false (a disk with 'throw' => false, the Laravel default).driver, optimizers, put_options and media_id are never part of the seed. media() is
disk($media->disk)->asset(...) plus a recorded media_id; every entry write goes through
ImageTools::entry(), and the job and imagetools:regenerate carry the id along.generate() adopts a non-empty file at the stored name only through
Manifest::putIfAbsent(): the file check and the write share the lock, and only when the file
has no entry. A first check runs without the lock, so a real miss never holds it during disk
calls. The source size check runs first. An entry that exists is always encoded again. A disk
that cannot answer the check (FilesystemException) means "not stored": log, then encode.asset() catches a Throwable from the in-request generate(), reports it, logs a warning with
the path and the disk (Laravel does not report a ValidationException) and returns ''.
generate(), the job and the commands still throw.GenerateImageJob::$mediaId is a plain property with a default, not a promoted one: a payload
queued by an older version has no mediaId, and unserialize() never runs the constructor.whileLocked(), after the manifest is
read again, and skips a file that an entry references again. This holds for imagetools:prune
and imagetools:clear. Clear falls back to an unlocked delete only when the lock cannot be taken.imagetools:prune reads only the default manifest. It refuses an empty manifest (always), a
manifest under bootstrap/cache and more than half of the files as orphans (both lifted by
--force; a dry run only warns on the second).Manifest::whileLocked() is the one lock user without the no-lock fallback: it throws instead,
because it guards a delete. Code inside it (and inside putIfAbsent()'s condition) never writes
the manifest: a second flock in the same process waits for ever. imagetools:prune deletes a
file only inside whileLocked(), after it reads the manifest again, and keeps files modified in
the last hour (an upload comes before its entry write).How can I help you explore Laravel packages today?