This file provides guidance to AI coding tools when working with code in this repository.
php artisan boost:add-skill keyagency/statamic-asset-usage
Save this content to: AGENTS.md
# AGENTS.md
This file provides guidance to AI coding tools when working with code in this repository.
## What this is
A Statamic 6 Control Panel addon (`keyagency/statamic-asset-usage`) that shows where every asset is used and which assets nothing references. PHP 8.2+, single free edition. Content is read through Statamic's repositories, never by scanning `content/`, which is what makes it work on eloquent-driver sites. Don't replace that with filesystem reads.
## Commands
```bash
vendor/bin/phpunit # run all tests
vendor/bin/phpunit --filter testName # run a single test
vendor/bin/phpunit tests/Unit/ReferenceExtractorTest.php
vendor/bin/pint # apply code style (laravel preset)
vendor/bin/pint --test # check code style (CI gate)
npm run build # build CP assets (vite → resources/dist)
npm run dev # vite dev server
```
Tests run on an in-memory sqlite via Orchestra Testbench + `Statamic\Testing\AddonTestCase`. `TestCase` turns Statamic Pro on because the permission tests need more than one user.
Blueprints, fieldsets and addon settings are written to real files under `resource_path()`, which Testbench does not reset between tests, so `TestCase::setUp()` deletes `resources/{addons,blueprints,fieldsets}`. Don't drop that: a blueprint left behind by one test silently replaces another test's, and the failure surfaces somewhere unrelated. `makeBlueprint()` and `fakeAddon()` are the helpers for creating them; `fakeAddon()` registers an addon through the `Manifest` because `Addon::all()` is memoised. Saving any item without an index builds one, and that includes the user `superUser()` saves, so a test about the state before the first build has to delete the index after its setup (`ToolsPageTest::superUserWithoutIndex()`). Note that `entryBlueprints()` is blinked per collection, so create a blueprint before the first entry that would resolve it. CI runs PHPUnit on PHP 8.2–8.5 (plus a `--prefer-lowest` job) and Pint once against current Pint.
## Architecture
The pipeline is **extract → index → display**.
- **`Usage\ReferenceExtractor`** walks one item's data array and returns `assetId => [dotted field paths]`. It is deliberately **generic, not blueprint-driven**: every format Statamic uses is recognisable from the string itself (`asset::container::path`, `statamic://asset::…`), and the one exception, the bare container-relative path an `assets` field stores, is resolved against the list of paths that actually exist. That's why nav trees and form submissions, which have no useful blueprint, work with the same code. See `src/Fieldtypes/UpdatesReferences.php` in core for the formats.
- **`Usage\Containers`** holds the two lookup tables the extractor needs: every known asset path (`path => [container handles]`, from `$container->queryAssets()->pluck('path')`) and the URL prefixes that point at a container. Building it lists every asset in every container, so build it once per scan and pass it around. **Don't switch this back to `$container->files()`:** on the eloquent driver that listing returns the container root only, because its recursive lookup is `folder LIKE '/%'` while nested assets store `folder` without a leading slash, so every asset in a folder would disappear from the overview. `ContainerPathsTest` guards it.
- **`Usage\Items`** turns everything scannable into `Item`s, through the repository facades only. Besides content that is collection and taxonomy cascades, addon settings and the `default` values in blueprints and fieldsets, which are the places an asset is referenced without ever being saved onto a single item. Static `from*()` factories are shared with the event listener so a full build and an incremental patch describe an item identically.
- **`Usage\BlueprintDefaults`** pulls the `default` values out of a blueprint or fieldset, keyed by dotted field handle. It walks the raw `contents()` rather than the `Fields` objects on purpose: a default can sit inside a grid, a group or a replicator set, and each of those stores its fields in a shape only that fieldtype knows about.
- **`Usage\UsageIndex`** keeps `assetId => Usage[]` **and** a reverse `itemKey => assetIds` map. The reverse map is the whole reason an incremental update is possible; don't drop it.
- **`Usage\IndexStore`** persists to `storage/statamic/asset-usage/index.json`, outside site content, so it is driver-agnostic and stays out of git-integration commits. Writes go through a `Cache::lock` and a temp-file rename. `index()` is built once per request and shared (the asset browser asks once per row), so treat what it returns as read-only; changes go through `mutate()` or `write()`, which clear it. `isStale()` compares every setting an index depends on (the container set, `scanned_types`, `scan_urls` and `include_working_copies`) against config, so a config change surfaces as an out-of-date index instead of quietly wrong results. Add the setting to `settingsMeta()` when you introduce a new one that changes what gets indexed.
- **`Usage\Unused`** owns the safety rails (usable index, zero usages, `ignore` patterns, `minimum_age_in_days`). The CP controller and the CLI both go through it so they can't drift on what they're willing to delete. It is handed an `$unusableReason` because an empty index and one that was never built look identical from inside: read the second as "nothing is used" and every asset on the site becomes deletable. Anything counting unused assets for itself has to ask `hasUsageData()` first.
### Runs
- **`Support\Run`** tracks a long run (the index build, a full analysis) in `storage/statamic/asset-usage/{index,analysis}-run.json`: its id, its progress and a sign of life on every beat. A file of its own, so a beat never rewrites the index or the analysis, and a cache clear during a deploy doesn't lose it.
- `IndexStore::isBuilding()` and `AnalysisStore::isAnalyzing()` need the state flag **and** a live run. A run that died (no queue worker, out of memory, a worker timeout) stops counting after `Run::STALL_MINUTES` and shows as a failure with its reason instead. Before this, a dead build said "checking" for days. Don't go back to the flag alone.
- Starting a run replaces the current one; a run that is no longer current stops on its next beat without writing its results. A stop is a request when the run is busy (`Run::stop()` sets `stopping_at`): the run notices on its next beat, saves what it has and ends itself through `isOver()`, so the page reloads only once the results are in. A run that isn't busy (queued, or quiet for `ACTIVE_SECONDS`) ends at once and the endpoint puts the state back. Stopping a rebuild puts the previous index back (`cancelBuilding()`, or no index at all when there was none); stopping an analysis keeps the records made so far.
- On the sync queue `Runs::dispatch()` runs the job after the response, so the page can poll the progress and stop it. Without PHP-FPM (`php-cgi`, mod_php, MAMP) the browser still waits for that response until the run is done, so the page (`support/runs.js`) follows a run by polling from the click on and never reads the start request's answer, which can date from before the run. The first-save build in `ItemUsageUpdater` stays inline on purpose: what follows the save expects the index to be there. Tests that need the job queued set `queue.default` to a real connection before `Queue::fake()`.
- `items_scanned` is written by a full build only (`mutate()` leaves it alone): it is the estimate for the next build's progress, held at 99% until the build is done.
### Compression
A second pipeline next to the usage one: **analyse → preview → replace**, all under `src/Compression/`.
- **`Compressor`** does one image the way the `key-asset-convert` skill does: `scaleDown(max)`, `setResolution(dpi)`, re-encode in the same format (decoding already applies the EXIF rotation, so don't call `orient()` again), pngquant for PNGs when the binary is found. `findPngquant()` also looks in `PNGQUANT_DIRECTORIES` after the PATH: a web server's PATH is often shorter than a terminal's (MAMP, PHP-FPM), and since finding pngquant is part of the `fingerprint()`, a binary only one side finds makes the CP and the CLI each see the other's analysis as out of date. Its image manager comes from **`ImageManagers::glide()`**, which asks Glide for the manager it renders with, so `statamic.assets.image_manipulation.driver` applies whatever it is ("gd", "imagick", a class name, an array). Don't construct a GD or Imagick manager yourself. Statamic 6 allows Intervention v3 and v4, so images are decoded through `ImageManagers::decode()` (`decodeBinary()` or `read()`), the driver is read through the v3 method or the v4 property, and `ImageManagers::encoder()` passes an encoder only the options its constructor has (v3.9 has no `strip`). `Analyzer` checks the type and the memory needed from the file's header before it reads the whole file, because running out of memory is a fatal nothing can catch. Intervention sets only the numbers on Imagick and keeps the source's unit, so the compressor sets pixels per inch explicitly (`imagick_writes_the_dpi_per_inch`). The memory guard applies to GD only; other drivers allocate outside `memory_limit`.
- **`AnalysisStore`** keeps `storage/statamic/asset-usage/compression.json`, same lock-and-rename pattern as `IndexStore`. Each record carries the file version (`Analyzer::version()`: size and mtime) and the compressor `fingerprint()`, and display code reads records through `fresh()` only, so a replaced file or changed settings show as "not analysed" instead of an old saving. A full run is split into batches of 25 and counts `pending` down per batch, failed batches included, so it can't stay "analysing" forever. `AnalyzeAllCompression::batches()` lists the images fresh instead of through the Blink-memoised `ids()`: Blink lives as long as a queue worker does, so a second run in the same worker would otherwise analyse an old list.
- **Invariant: a file is only ever replaced through `$asset->reupload()`** (with `PathReplacementFile`). That regenerates the meta, clears Glide and fires `AssetReuploaded`, which the git integration commits. Writing to the disk directly leaves Statamic's cached meta and old Glide renders behind.
- **`Requirements`** says whether the Glide driver can be built at all (its extension can be missing), which formats it can't handle and whether pngquant is there. Everything that would build a manager checks `Requirements::available()` first, because `ImageManagers::glide()` throws without the extension, and on the Tools page that would take the whole overview down instead of only the Saving column.
- The Compression part of the asset editor comes from the `asset_usage` field's `preload()` (`compression` in its meta), only for users with the compress permission and Statamic's `reupload` ability. It is there for every compressible image, with a `state` that says why there is or isn't anything to offer (`not_analyzed` included), so an image that was checked and has nothing to gain doesn't look like one that was never checked.
- "Compress all" and "Compress selected" (both `components/CompressAll.vue`) are driven by the page, not a job: for "all", `compress/all` lists the ids (the same filters as the list, through `compressibleIds()`), a selection sends the ticked ids as they are, and the page posts them to `compress/batch` a few at a time. The Compression page reuses the overview's checkboxes; `isSelectable()` gives one only to rows whose `compression.compressible` is true. That keeps every request short on the `sync` queue, which is what most sites run, and gives a progress bar. A batch skips and reports an image it can't compress, and ends the run (`halt`) on a failed backup, because every next image would fail the same way. `asset-usage:compress` (`CompressCommand`) is the same run from the CLI, with the same skip and halt rules. The controller and the command both pick their images through `Analyzer::compressible()`, so they can't drift on what counts as compressible.
- **`Log\AssetLog`** (`asset-log.jsonl`, one entry per line, so recording is an append) records every compression and every deletion in the enabled containers, each entry with a `type`. It stores a user's name, never the email address, and a `source` (`Source`; a compression is `TOOLS` or `CLI`), from which the log page fills "By" with "Command line" when there was no user; `visibleTo()` limits entries and totals to the containers a user can view. A restore marks the compression instead of removing it; `compressionTotals()` counts only what is still compressed. Deletions come from `Listeners\LogAssetDeletion`, which reads the size and usage on `AssetDeleting` (the file is gone by `AssetDeleted`) and writes on `AssetDeleted`, so a cancelled deletion isn't logged. The addon's own delete paths wrap `delete()` in `Source::during()`; anything else is told apart by the route it happens in. The menu items rely on each page having its own URL (`overview`, `compress`, `log`), with the before and after page under `compress`: Statamic 6 has no way to set an item's active pattern, it derives it from the URL. The Log page has a permission of its own (`view asset log`); without it, `logUrl` is null and the tab and "View log" buttons are left out. The Compression page needs `compress assets` in the same way (`compressionPageUrl`), while the Saving column on the Overview only needs `view asset usage`. The Compression page is `AssetUsage.vue` with `view: 'compression'`, which asks `assets` for `compression=all|compressible|compressed` and leaves out deleting and the usage-only filters. "Compressed" is read from the log rather than the backups, so it holds after the originals are pruned.
- Sorting on Saving puts the images that can get smaller first in either direction (`savingsValues()` in `Concerns\ListsAssets`, so the PDF sorts the same way). A compressed image shows what compressing saved, which can be more than any image still has to gain, so the number alone would mix the two.
- After compressing, `Backups::markCompressed()` records the version it produced. `Analyzer::compress()` reports such a file as `COMPRESSED` instead of test-compressing it again: re-encoding a q82 JPEG at q82 still saves a percent or so, so without this every compressed image would be offered again, losing quality each round. **That holds whatever the settings**, on purpose: a change unrelated to the image (the server finding pngquant) would otherwise offer every compressed image again at once. Other settings start from the original, which can be put back.
- `CompressionService::compress()` writes exactly the preview that was shown and refuses when the file version changed since (409) or the result isn't smaller (422). `Backups` keeps the first original should an image be compressed twice, which earlier versions allowed after a settings change, so a log can still hold two compressions of one image. A backup only counts while the file at that path is still the version compressing produced, so a replaced or re-uploaded file never gets an unrelated original back; `KeepCompressionDataWithAssets` deletes it with its asset and moves it, and the analysis, on a rename. A full run drops the analysis of assets that are gone, and `prune()` keeps the version marker of a pruned original so the image isn't offered again. The copy goes through a temp file checked against the size on the disk, and a failed copy (`BackupFailed`) stops the compression before the file is touched.
- Imagick-only tests skip when the extension is missing, and so does the pngquant one without the binary. `vendor/bin/phpunit` runs on whichever `php` is first in the PATH, which may not be the one with Imagick.
- CI's PHP 8.2 jobs resolve Intervention v3 with Glide 3.x; the newer PHP versions get v4. Compression tests have to pass on both: build a manager through `CompressorTest::manager()` (`usingDriver()` is v4 only, v3 has `withDriver()`), and fake a failing decode with `tests/Support/FailingDriver` rather than a subclass of `ImageManager`, which is final in v3. Glide 3.x doesn't accept the array form of the driver config at all. A local passing run on v4 says nothing about v3: to check, copy the repo, run `composer config platform.php 8.2.34 && composer update` in the copy (`--prefer-lowest` for the lowest job) and run the tests there.
### PDF export
The Overview and the Compression page as a PDF, under `src/Export/` and `ExportController`.
- **The PDF lists exactly what the list does.** `Http\Controllers\Concerns\ListsAssets` holds the filtering and sorting (`listedIds()`) for both `AssetUsageController::assets()` and the export; don't give the export a filter of its own. Its `findEnabledAsset()` is what the delete endpoints and the thumbnail batch use to refuse an id outside the enabled containers. `ExportController::prepare()` applies each page's own filters and drops the other's, and on the Overview refuses (409) without usable usage data, for the same reason `Unused` is handed an `$unusableReason`.
- Like "Compress all", the run is driven by the page (`components/DownloadPdf.vue`): `export/thumbnails` lists the images in the PDF that have no thumbnail yet and how many parts the PDF has, the page posts the images back a few at a time, then fetches the PDF part by part (`?part=`) and joins the parts into one file (`support/joinPdf.js`). Fetched, not linked: Statamic's `Button` turns an `href` into an Inertia `Link`, which can't take a file.
- **`Thumbnails`** keeps a 72 px JPEG at quality 50 per file version (`Analyzer::version()`), with the size and quality in the file name so changing either makes new ones, in `storage/statamic/asset-usage/thumbnails/`, made through `ImageManagers::glide()` with the same header check before decoding as the compressor. `KeepCompressionDataWithAssets` deletes them with their asset and on a rename. They go into the PDF as data URIs because `Pdf` runs dompdf with remote files and PHP off, and file names are user content; keep it that way.
- dompdf lays out a table as a whole, which grows faster than the rows do (one table of 1,000 rows: 12 s, 690 MB). The view splits the rows into tables of 50 with a width on every header cell but the name, which keeps them lined up: 1,000 rows take about 4.5 s and 260 MB, 2,000 about 11 s and 420 MB. dompdf ignores `table-layout: fixed` and `<col>` widths, which is why the widths sit on the header cells.
- Even split up, a document grows faster than its rows (measured again: 1,000 rows 11 s and 186 MB, 2,000 rows 30 s and 333 MB), so the server makes the PDF in parts of `PART_ROWS` (500), one request each, up to `MAX_ROWS` (10,000), which the PDF says at the top when more match. Only the first part has the header (`Report::$continued`); the total and size it shows count every match. The page joins the parts with pdf-lib, loaded as a chunk of its own on the first download, and numbers the pages across them, which no part can: dompdf doesn't number pages any more. The chunk is imported relatively, without preload dependencies, because addon assets are served from `/vendor/…/build/` while the Laravel plugin's `base` is `/build/`; check that after changing the Vite config. pdf-lib and its dependencies are listed with their licenses in `THIRD-PARTY-NOTICES.md`; update it when they change. The limits are read through `static::` so `PdfExportTest` can lower them with a subclass.
- dompdf is required at `^3.1.6`: the 3.1 releases before it have security advisories, which current Composer refuses to install anyway, so `--prefer-lowest` lands on 3.1.6 too.
- The header names Key Agency with its logo, `resources/images/key-agency.jpg` (160 px, from the Statamic Marketplace avatar), passed in as a data URI by `Pdf` like the thumbnails.
- For the PDF, `raiseLimits()` lifts memory and time to what Statamic gives Glide, and that lasts for the rest of the PHP process, so `PdfExportTest` puts `memory_limit` back in `tearDown()`. Making thumbnails lifts only the time (`raiseTimeLimit()`): Statamic's memory limit for Glide is none at all by default, and without a limit the GD check in `Thumbnails` would let an image of any size be decoded (`making_thumbnails_leaves_the_memory_limit_alone`).
- A thumbnail batch that fails is skipped rather than ending the run: those images get their extension in the PDF. Ending it would make the PDF impossible for that list, since the same image fails again on every try.
- The filters, sort and page are in the URL (`support/filterQuery.js`), written with `history.replaceState(history.state, …)` so Inertia's state on the entry survives and the back button leaves the page. `ExportController::overviewUrl()` builds the same URL for the link in the PDF; change one, change the other.
### How the CP panel and the column work
One `asset_usage` field, injected into every enabled container's blueprint by `Listeners\InjectUsageField` on `AssetContainerBlueprintFound`, covers both places.
**Invariant: the field is `visibility: computed`, and that is what keeps it out of saved data.** `Fields::values()` (`src/Fields/Fields.php`) rejects computed fields unless `withComputedValues()` was called, and `AssetsController::update()` saves via `$fields->process()->values()`, which never calls it. `preProcess()` does call it, so the editor still sees the value. Change that visibility and every asset's `.meta` file gains an `asset_usage` key, and `UsageFieldTest::updating_an_asset_never_writes_the_field_into_its_data` is the guard.
The asset is read from `$this->field->parent()`: the editor sets it via `AssetContainer::blueprint($asset)`, the listing via `FolderAsset::values()`. Handle a non-Asset parent gracefully, because `setColumns()` resolves the blueprint with the *container* as parent.
`Field::isSortable()` returns false for computed fields, so only in the asset browser's listing requests (`BrowserController::folder`/`search`, see `InjectUsageField::sortsHere()`) the field is `read_only` instead, which makes the column sortable. Saving an asset is always another request, where it stays computed, so the invariant above holds. The Stache then sorts on an index built from `toQueryableValue()`, which returns the usage count, and caches it; `IndexStore::put()` drops it through `Usage\SortIndex::clear()` on every change. On the eloquent driver `orderBy('asset_usage')` would hit a database column that doesn't exist, so `SortIndex::supported()` keeps the column unsortable there.
### Dashboard widget
`Widgets\AssetUsage` (handle `asset_usage`, added by a site to `widgets` in `config/statamic/cp.php`) returns `null` from `component()` without `view asset usage`, which makes Statamic leave it off the dashboard. **It does no counting itself**: Statamic calls `component()` while rendering the dashboard, and counting lists every asset in every container, so the Vue component (`asset-usage-widget`) fetches its numbers from `AssetUsageController::summary()` afterwards. `unused` is null without usage data, for the same reason `Unused` is handed an `$unusableReason`, and the widget shows why instead of a number. `compression` is null without the compress permission; the log totals cover only the containers the user can view. `summary()` counts with an empty `Request`, so the overview's filters in a query string can't change the numbers, and hands the log entries it already read to `Status::toArray()`, which otherwise reads the whole log again.
### Frontend
Vite + Vue in `resources/js/`. `cp.js` registers the Inertia pages (`asset-usage::AssetUsage`, `asset-usage::Compress`, `asset-usage::AssetLog`), the dashboard widget (`asset-usage-widget`, the name `Widgets\AssetUsage::component()` renders) and the two fieldtype components, which **must** be named `asset_usage-fieldtype` and `asset_usage-fieldtype-index`, because the listing resolves the index component as `` `${fieldtype}-fieldtype-index` `` and silently falls back to plain text otherwise. Components come from `@statamic/cms/ui`; those imports resolve at runtime through the `__STATAMIC__` global, so a wrong export name builds fine and breaks in the browser. Check `vendor/statamic/cms/resources/dist-package/src/ui.js` for the real export list.
The addon's Tailwind utilities land in the `addon-utilities` layer, which Statamic declares before its own `utilities`, so core wins whenever both set the same property on an element. A responsive variant therefore can't override a base class core also ships (`flex` with `@xl:block`, `text-lg` with `@xl:text-2xl`): give each property one class per state instead, such as `@max-xl/widget:text-lg @xl/widget:text-2xl`, or change a property the base class doesn't set (`@xl/widget:flex-col`).
The header buttons that start the check and the analysis are `RunButton`, which turns red and asks before stopping while the run goes; `RunProgress` and `RunFailure` show how far it is and why the last one didn't finish. `AssetUsage.vue` serves both the Overview and the Compression page; its table head (`AssetTableHead`), row (`AssetRow`, with the details row under it), filters (`AssetFilters`, one `v-model` per filter), index alerts (`IndexAlerts`), PDF button (`DownloadPdf`), delete confirmation and footer are components of their own, and status polling is the `support/polling.js` mixin. Keep it under 800 lines by moving self-contained parts out rather than adding to it.
Translated sentences with a value set apart are built with `mark()` and `parts()` from `support/formatting.js` and rendered by `MarkedText`, never through `v-html`. Statamic's toasts do render their message as HTML, so anything users control in one (a file path, a server message) goes through `escapeHtml()` first. The slider and the side-by-side comparison share their sizing through the `support/comparisonFrame.js` mixin. A message that has to survive a redirect is a server-side `Toast::success()` with a `redirect` in the JSON; a toast shown just before navigating away is never seen.
## Deliberate trade-offs
Documented in the README as well; don't "fix" them without a reason:
- A bare path present in two enabled containers counts as used in **both**. Over-reporting beats deleting a file that was in use.
- Glide URLs (`/img/asset/…`) and template references are out of scope.
- The index is never built implicitly during a page render. Not-indexed and unused are shown as different states on purpose.
- On a fresh install the first content save builds the whole index (`ItemUsageUpdater::update()`). Don't "optimise" that into recording only the saved item: an index holding one item makes every other asset look unused, and so deletable.
## Conventions
- **Before every commit, run the marketplace review** (`.agents/skills/marketplace-review/SKILL.md`) on the working tree and report its findings first. Commit only once the user has seen them, with every required change fixed or explicitly accepted by the user.
- The marketplace screenshots (8 images, 1200 x 800, framed on #214FF0) are made with `.agents/skills/marketplace-screenshots/SKILL.md`, which holds the test site, the shot list, the capture settings, the frame script and what may not appear in a shot. Like the review skill, it is kept outside git.
- Namespace `KeyAgency\AssetUsage\` → `src/`. Config is merged/published under the `statamic.asset-usage` key (`config/asset-usage.php`), read through `Support\Settings` rather than `config()` calls scattered around.
- Long-running commands show a progress bar (`Console\Commands\Concerns\BuildsIndexWithProgress` for an index build). Bars go to stderr and are left out with `--json`, so a script reads only the data.
- Console output is English, always, and written as literals in the command. It goes into bug reports, where `asset-usage:doctor` in the reporter's language helps nobody, and Statamic's own `please` commands are English too. Don't reach for a `messages.*` key in a command just because one happens to exist for the CP; `Unused::blocker()` takes a `$locale` for exactly this, and the commands pass `'en'`. `the_commands_stay_english_on_a_site_that_is_not` guards it.
- Everything else user-facing lives in `lang/{en,nl,de,fr,es,it}/messages.php`. Add keys to every locale, don't inline literals; `TranslationsTest` checks each one against English for keys, plural segments and placeholders. Related strings are grouped under their own array key (`permissions`, `nav`, `index`, `filters`, `columns`, `compress`, `log`, `export`, `widget`, `delete`, `item_type`, `errors`) rather than prefixed; singletons stay at the top level. Statamic flattens the file with `Arr::dot()` for the JS side, so `__('asset-usage::messages.errors.asset_missing')` works the same in Vue as in PHP.
- `scanned_types` is read as the complete set (`Settings::scans()`): a type that isn't listed is off. Laravel merges that config key as a whole, so a published config with one type means one type, and a config published before you added a type leaves that type off in that site. `DoctorCommand` names the keys a site's config is missing. Adding a scanned type means four things together: `config/asset-usage.php`, `Settings::SCANNABLE_TYPES`, an `item_type` label in every lang file, and an update script in `src/UpdateScripts/` (autoloaded by `AddonServiceProvider::bootUpdateScripts()`, no registration needed) that adds the key to already-published configs. Those scripts list the types they introduce explicitly rather than reading `SCANNABLE_TYPES`, so a type a site left out on purpose is never silently re-added.
- **Every new config key gets an update script**, even one `Settings` has a fallback for. A key a site can't see in its published config is a key it doesn't know it has. One script per release, listing that release's keys by hand; don't write a generic "sync whatever is missing from the shipped config", which would put back keys a site deliberately deleted. Insert through `UpdateScripts\Concerns\InsertsIntoConfig`, which first adds the comma a hand-edited last entry may lack; without it the published config no longer parses.
- **An update script's `isUpdatingTo()` version has to be the version you actually tag.** `isUpdatingTo('1.1.0')` never fires on a release tagged 1.0.2, and it fails silently: the script is registered, `shouldUpdate()` just answers false and nobody hears about it. Adding a config key makes the release a minor one for this reason alone. Check the gate against the CHANGELOG heading before tagging.
- **A git tag always starts with `v`**: `git tag v1.5.1`, never `git tag 1.5.1`. `.github/workflows/release.yml` only runs for tags matching `v*`, and it is what builds the CP assets (`resources/dist` is not in git) and creates the GitHub release with `dist.tar.gz` and the CHANGELOG section as notes; a tag without the `v` gets neither. Only the tag has the `v`: the CHANGELOG heading, the commit message (`Release 1.5.1: …`) and `isUpdatingTo('1.5.1')` use the bare number.
- Statamic saves addon settings under the addon's slug but reads them back under its package name (`FileSettings::path()` vs `FileSettingsRepository::find()`). For an addon that overrides its slug those differ, which cuts both ways: its real settings file is never found, so `addon_settings` misses it entirely, and a file that happens to sit on the package-name path resolves to a null addon and throws. `Items::addonSettings()` catches the throw per addon so one broken addon can't take the whole scan down; `an_addon_whose_settings_cannot_be_read_does_not_break_the_scan` guards it. The miss is Statamic's to fix, so don't work around it by reading `resources/addons/` directly.
- Comments use `/** */` for multi-line, and only where something needs explaining.
This file provides guidance to AI coding tools when working with code in this repository.
A Statamic 6 Control Panel addon (keyagency/statamic-asset-usage) that shows where every asset is used and which assets nothing references. PHP 8.2+, single free edition. Content is read through Statamic's repositories, never by scanning content/, which is what makes it work on eloquent-driver sites. Don't replace that with filesystem reads.
vendor/bin/phpunit # run all tests
vendor/bin/phpunit --filter testName # run a single test
vendor/bin/phpunit tests/Unit/ReferenceExtractorTest.php
vendor/bin/pint # apply code style (laravel preset)
vendor/bin/pint --test # check code style (CI gate)
npm run build # build CP assets (vite → resources/dist)
npm run dev # vite dev server
Tests run on an in-memory sqlite via Orchestra Testbench + Statamic\Testing\AddonTestCase. TestCase turns Statamic Pro on because the permission tests need more than one user.
Blueprints, fieldsets and addon settings are written to real files under resource_path(), which Testbench does not reset between tests, so TestCase::setUp() deletes resources/{addons,blueprints,fieldsets}. Don't drop that: a blueprint left behind by one test silently replaces another test's, and the failure surfaces somewhere unrelated. makeBlueprint() and fakeAddon() are the helpers for creating them; fakeAddon() registers an addon through the Manifest because Addon::all() is memoised. Saving any item without an index builds one, and that includes the user superUser() saves, so a test about the state before the first build has to delete the index after its setup (ToolsPageTest::superUserWithoutIndex()). Note that entryBlueprints() is blinked per collection, so create a blueprint before the first entry that would resolve it. CI runs PHPUnit on PHP 8.2–8.5 (plus a --prefer-lowest job) and Pint once against current Pint.
The pipeline is extract → index → display.
Usage\ReferenceExtractor walks one item's data array and returns assetId => [dotted field paths]. It is deliberately generic, not blueprint-driven: every format Statamic uses is recognisable from the string itself (asset::container::path, statamic://asset::…), and the one exception, the bare container-relative path an assets field stores, is resolved against the list of paths that actually exist. That's why nav trees and form submissions, which have no useful blueprint, work with the same code. See src/Fieldtypes/UpdatesReferences.php in core for the formats.Usage\Containers holds the two lookup tables the extractor needs: every known asset path (path => [container handles], from $container->queryAssets()->pluck('path')) and the URL prefixes that point at a container. Building it lists every asset in every container, so build it once per scan and pass it around. Don't switch this back to $container->files(): on the eloquent driver that listing returns the container root only, because its recursive lookup is folder LIKE '/%' while nested assets store folder without a leading slash, so every asset in a folder would disappear from the overview. ContainerPathsTest guards it.Usage\Items turns everything scannable into Items, through the repository facades only. Besides content that is collection and taxonomy cascades, addon settings and the default values in blueprints and fieldsets, which are the places an asset is referenced without ever being saved onto a single item. Static from*() factories are shared with the event listener so a full build and an incremental patch describe an item identically.Usage\BlueprintDefaults pulls the default values out of a blueprint or fieldset, keyed by dotted field handle. It walks the raw contents() rather than the Fields objects on purpose: a default can sit inside a grid, a group or a replicator set, and each of those stores its fields in a shape only that fieldtype knows about.Usage\UsageIndex keeps assetId => Usage[] and a reverse itemKey => assetIds map. The reverse map is the whole reason an incremental update is possible; don't drop it.Usage\IndexStore persists to storage/statamic/asset-usage/index.json, outside site content, so it is driver-agnostic and stays out of git-integration commits. Writes go through a Cache::lock and a temp-file rename. index() is built once per request and shared (the asset browser asks once per row), so treat what it returns as read-only; changes go through mutate() or write(), which clear it. isStale() compares every setting an index depends on (the container set, scanned_types, scan_urls and include_working_copies) against config, so a config change surfaces as an out-of-date index instead of quietly wrong results. Add the setting to settingsMeta() when you introduce a new one that changes what gets indexed.Usage\Unused owns the safety rails (usable index, zero usages, ignore patterns, minimum_age_in_days). The CP controller and the CLI both go through it so they can't drift on what they're willing to delete. It is handed an $unusableReason because an empty index and one that was never built look identical from inside: read the second as "nothing is used" and every asset on the site becomes deletable. Anything counting unused assets for itself has to ask hasUsageData() first.Support\Run tracks a long run (the index build, a full analysis) in storage/statamic/asset-usage/{index,analysis}-run.json: its id, its progress and a sign of life on every beat. A file of its own, so a beat never rewrites the index or the analysis, and a cache clear during a deploy doesn't lose it.IndexStore::isBuilding() and AnalysisStore::isAnalyzing() need the state flag and a live run. A run that died (no queue worker, out of memory, a worker timeout) stops counting after Run::STALL_MINUTES and shows as a failure with its reason instead. Before this, a dead build said "checking" for days. Don't go back to the flag alone.Run::stop() sets stopping_at): the run notices on its next beat, saves what it has and ends itself through isOver(), so the page reloads only once the results are in. A run that isn't busy (queued, or quiet for ACTIVE_SECONDS) ends at once and the endpoint puts the state back. Stopping a rebuild puts the previous index back (cancelBuilding(), or no index at all when there was none); stopping an analysis keeps the records made so far.Runs::dispatch() runs the job after the response, so the page can poll the progress and stop it. Without PHP-FPM (php-cgi, mod_php, MAMP) the browser still waits for that response until the run is done, so the page (support/runs.js) follows a run by polling from the click on and never reads the start request's answer, which can date from before the run. The first-save build in ItemUsageUpdater stays inline on purpose: what follows the save expects the index to be there. Tests that need the job queued set queue.default to a real connection before Queue::fake().items_scanned is written by a full build only (mutate() leaves it alone): it is the estimate for the next build's progress, held at 99% until the build is done.A second pipeline next to the usage one: analyse → preview → replace, all under src/Compression/.
Compressor does one image the way the key-asset-convert skill does: scaleDown(max), setResolution(dpi), re-encode in the same format (decoding already applies the EXIF rotation, so don't call orient() again), pngquant for PNGs when the binary is found. findPngquant() also looks in PNGQUANT_DIRECTORIES after the PATH: a web server's PATH is often shorter than a terminal's (MAMP, PHP-FPM), and since finding pngquant is part of the fingerprint(), a binary only one side finds makes the CP and the CLI each see the other's analysis as out of date. Its image manager comes from ImageManagers::glide(), which asks Glide for the manager it renders with, so statamic.assets.image_manipulation.driver applies whatever it is ("gd", "imagick", a class name, an array). Don't construct a GD or Imagick manager yourself. Statamic 6 allows Intervention v3 and v4, so images are decoded through ImageManagers::decode() (decodeBinary() or read()), the driver is read through the v3 method or the v4 property, and ImageManagers::encoder() passes an encoder only the options its constructor has (v3.9 has no strip). Analyzer checks the type and the memory needed from the file's header before it reads the whole file, because running out of memory is a fatal nothing can catch. Intervention sets only the numbers on Imagick and keeps the source's unit, so the compressor sets pixels per inch explicitly (imagick_writes_the_dpi_per_inch). The memory guard applies to GD only; other drivers allocate outside memory_limit.AnalysisStore keeps storage/statamic/asset-usage/compression.json, same lock-and-rename pattern as IndexStore. Each record carries the file version (Analyzer::version(): size and mtime) and the compressor fingerprint(), and display code reads records through fresh() only, so a replaced file or changed settings show as "not analysed" instead of an old saving. A full run is split into batches of 25 and counts pending down per batch, failed batches included, so it can't stay "analysing" forever. AnalyzeAllCompression::batches() lists the images fresh instead of through the Blink-memoised ids(): Blink lives as long as a queue worker does, so a second run in the same worker would otherwise analyse an old list.$asset->reupload() (with PathReplacementFile). That regenerates the meta, clears Glide and fires AssetReuploaded, which the git integration commits. Writing to the disk directly leaves Statamic's cached meta and old Glide renders behind.Requirements says whether the Glide driver can be built at all (its extension can be missing), which formats it can't handle and whether pngquant is there. Everything that would build a manager checks Requirements::available() first, because ImageManagers::glide() throws without the extension, and on the Tools page that would take the whole overview down instead of only the Saving column.asset_usage field's preload() (compression in its meta), only for users with the compress permission and Statamic's reupload ability. It is there for every compressible image, with a state that says why there is or isn't anything to offer (not_analyzed included), so an image that was checked and has nothing to gain doesn't look like one that was never checked.components/CompressAll.vue) are driven by the page, not a job: for "all", compress/all lists the ids (the same filters as the list, through compressibleIds()), a selection sends the ticked ids as they are, and the page posts them to compress/batch a few at a time. The Compression page reuses the overview's checkboxes; isSelectable() gives one only to rows whose compression.compressible is true. That keeps every request short on the sync queue, which is what most sites run, and gives a progress bar. A batch skips and reports an image it can't compress, and ends the run (halt) on a failed backup, because every next image would fail the same way. asset-usage:compress (CompressCommand) is the same run from the CLI, with the same skip and halt rules. The controller and the command both pick their images through Analyzer::compressible(), so they can't drift on what counts as compressible.Log\AssetLog (asset-log.jsonl, one entry per line, so recording is an append) records every compression and every deletion in the enabled containers, each entry with a type. It stores a user's name, never the email address, and a source (Source; a compression is TOOLS or CLI), from which the log page fills "By" with "Command line" when there was no user; visibleTo() limits entries and totals to the containers a user can view. A restore marks the compression instead of removing it; compressionTotals() counts only what is still compressed. Deletions come from Listeners\LogAssetDeletion, which reads the size and usage on AssetDeleting (the file is gone by AssetDeleted) and writes on AssetDeleted, so a cancelled deletion isn't logged. The addon's own delete paths wrap delete() in Source::during(); anything else is told apart by the route it happens in. The menu items rely on each page having its own URL (overview, compress, log), with the before and after page under compress: Statamic 6 has no way to set an item's active pattern, it derives it from the URL. The Log page has a permission of its own (view asset log); without it, logUrl is null and the tab and "View log" buttons are left out. The Compression page needs compress assets in the same way (compressionPageUrl), while the Saving column on the Overview only needs view asset usage. The Compression page is AssetUsage.vue with view: 'compression', which asks assets for compression=all|compressible|compressed and leaves out deleting and the usage-only filters. "Compressed" is read from the log rather than the backups, so it holds after the originals are pruned.savingsValues() in Concerns\ListsAssets, so the PDF sorts the same way). A compressed image shows what compressing saved, which can be more than any image still has to gain, so the number alone would mix the two.Backups::markCompressed() records the version it produced. Analyzer::compress() reports such a file as COMPRESSED instead of test-compressing it again: re-encoding a q82 JPEG at q82 still saves a percent or so, so without this every compressed image would be offered again, losing quality each round. That holds whatever the settings, on purpose: a change unrelated to the image (the server finding pngquant) would otherwise offer every compressed image again at once. Other settings start from the original, which can be put back.CompressionService::compress() writes exactly the preview that was shown and refuses when the file version changed since (409) or the result isn't smaller (422). Backups keeps the first original should an image be compressed twice, which earlier versions allowed after a settings change, so a log can still hold two compressions of one image. A backup only counts while the file at that path is still the version compressing produced, so a replaced or re-uploaded file never gets an unrelated original back; KeepCompressionDataWithAssets deletes it with its asset and moves it, and the analysis, on a rename. A full run drops the analysis of assets that are gone, and prune() keeps the version marker of a pruned original so the image isn't offered again. The copy goes through a temp file checked against the size on the disk, and a failed copy (BackupFailed) stops the compression before the file is touched.vendor/bin/phpunit runs on whichever php is first in the PATH, which may not be the one with Imagick.CompressorTest::manager() (usingDriver() is v4 only, v3 has withDriver()), and fake a failing decode with tests/Support/FailingDriver rather than a subclass of ImageManager, which is final in v3. Glide 3.x doesn't accept the array form of the driver config at all. A local passing run on v4 says nothing about v3: to check, copy the repo, run composer config platform.php 8.2.34 && composer update in the copy (--prefer-lowest for the lowest job) and run the tests there.The Overview and the Compression page as a PDF, under src/Export/ and ExportController.
Http\Controllers\Concerns\ListsAssets holds the filtering and sorting (listedIds()) for both AssetUsageController::assets() and the export; don't give the export a filter of its own. Its findEnabledAsset() is what the delete endpoints and the thumbnail batch use to refuse an id outside the enabled containers. ExportController::prepare() applies each page's own filters and drops the other's, and on the Overview refuses (409) without usable usage data, for the same reason Unused is handed an $unusableReason.components/DownloadPdf.vue): export/thumbnails lists the images in the PDF that have no thumbnail yet and how many parts the PDF has, the page posts the images back a few at a time, then fetches the PDF part by part (?part=) and joins the parts into one file (support/joinPdf.js). Fetched, not linked: Statamic's Button turns an href into an Inertia Link, which can't take a file.Thumbnails keeps a 72 px JPEG at quality 50 per file version (Analyzer::version()), with the size and quality in the file name so changing either makes new ones, in storage/statamic/asset-usage/thumbnails/, made through ImageManagers::glide() with the same header check before decoding as the compressor. KeepCompressionDataWithAssets deletes them with their asset and on a rename. They go into the PDF as data URIs because Pdf runs dompdf with remote files and PHP off, and file names are user content; keep it that way.table-layout: fixed and <col> widths, which is why the widths sit on the header cells.PART_ROWS (500), one request each, up to MAX_ROWS (10,000), which the PDF says at the top when more match. Only the first part has the header (Report::$continued); the total and size it shows count every match. The page joins the parts with pdf-lib, loaded as a chunk of its own on the first download, and numbers the pages across them, which no part can: dompdf doesn't number pages any more. The chunk is imported relatively, without preload dependencies, because addon assets are served from /vendor/…/build/ while the Laravel plugin's base is /build/; check that after changing the Vite config. pdf-lib and its dependencies are listed with their licenses in THIRD-PARTY-NOTICES.md; update it when they change. The limits are read through static:: so PdfExportTest can lower them with a subclass.^3.1.6: the 3.1 releases before it have security advisories, which current Composer refuses to install anyway, so --prefer-lowest lands on 3.1.6 too.resources/images/key-agency.jpg (160 px, from the Statamic Marketplace avatar), passed in as a data URI by Pdf like the thumbnails.raiseLimits() lifts memory and time to what Statamic gives Glide, and that lasts for the rest of the PHP process, so PdfExportTest puts memory_limit back in tearDown(). Making thumbnails lifts only the time (raiseTimeLimit()): Statamic's memory limit for Glide is none at all by default, and without a limit the GD check in Thumbnails would let an image of any size be decoded (making_thumbnails_leaves_the_memory_limit_alone).support/filterQuery.js), written with history.replaceState(history.state, …) so Inertia's state on the entry survives and the back button leaves the page. ExportController::overviewUrl() builds the same URL for the link in the PDF; change one, change the other.One asset_usage field, injected into every enabled container's blueprint by Listeners\InjectUsageField on AssetContainerBlueprintFound, covers both places.
Invariant: the field is visibility: computed, and that is what keeps it out of saved data. Fields::values() (src/Fields/Fields.php) rejects computed fields unless withComputedValues() was called, and AssetsController::update() saves via $fields->process()->values(), which never calls it. preProcess() does call it, so the editor still sees the value. Change that visibility and every asset's .meta file gains an asset_usage key, and UsageFieldTest::updating_an_asset_never_writes_the_field_into_its_data is the guard.
The asset is read from $this->field->parent(): the editor sets it via AssetContainer::blueprint($asset), the listing via FolderAsset::values(). Handle a non-Asset parent gracefully, because setColumns() resolves the blueprint with the container as parent.
Field::isSortable() returns false for computed fields, so only in the asset browser's listing requests (BrowserController::folder/search, see InjectUsageField::sortsHere()) the field is read_only instead, which makes the column sortable. Saving an asset is always another request, where it stays computed, so the invariant above holds. The Stache then sorts on an index built from toQueryableValue(), which returns the usage count, and caches it; IndexStore::put() drops it through Usage\SortIndex::clear() on every change. On the eloquent driver orderBy('asset_usage') would hit a database column that doesn't exist, so SortIndex::supported() keeps the column unsortable there.
Widgets\AssetUsage (handle asset_usage, added by a site to widgets in config/statamic/cp.php) returns null from component() without view asset usage, which makes Statamic leave it off the dashboard. It does no counting itself: Statamic calls component() while rendering the dashboard, and counting lists every asset in every container, so the Vue component (asset-usage-widget) fetches its numbers from AssetUsageController::summary() afterwards. unused is null without usage data, for the same reason Unused is handed an $unusableReason, and the widget shows why instead of a number. compression is null without the compress permission; the log totals cover only the containers the user can view. summary() counts with an empty Request, so the overview's filters in a query string can't change the numbers, and hands the log entries it already read to Status::toArray(), which otherwise reads the whole log again.
Vite + Vue in resources/js/. cp.js registers the Inertia pages (asset-usage::AssetUsage, asset-usage::Compress, asset-usage::AssetLog), the dashboard widget (asset-usage-widget, the name Widgets\AssetUsage::component() renders) and the two fieldtype components, which must be named asset_usage-fieldtype and asset_usage-fieldtype-index, because the listing resolves the index component as `${fieldtype}-fieldtype-index` and silently falls back to plain text otherwise. Components come from @statamic/cms/ui; those imports resolve at runtime through the __STATAMIC__ global, so a wrong export name builds fine and breaks in the browser. Check vendor/statamic/cms/resources/dist-package/src/ui.js for the real export list.
The addon's Tailwind utilities land in the addon-utilities layer, which Statamic declares before its own utilities, so core wins whenever both set the same property on an element. A responsive variant therefore can't override a base class core also ships (flex with @xl:block, text-lg with @xl:text-2xl): give each property one class per state instead, such as @max-xl/widget:text-lg @xl/widget:text-2xl, or change a property the base class doesn't set (@xl/widget:flex-col).
The header buttons that start the check and the analysis are RunButton, which turns red and asks before stopping while the run goes; RunProgress and RunFailure show how far it is and why the last one didn't finish. AssetUsage.vue serves both the Overview and the Compression page; its table head (AssetTableHead), row (AssetRow, with the details row under it), filters (AssetFilters, one v-model per filter), index alerts (IndexAlerts), PDF button (DownloadPdf), delete confirmation and footer are components of their own, and status polling is the support/polling.js mixin. Keep it under 800 lines by moving self-contained parts out rather than adding to it.
Translated sentences with a value set apart are built with mark() and parts() from support/formatting.js and rendered by MarkedText, never through v-html. Statamic's toasts do render their message as HTML, so anything users control in one (a file path, a server message) goes through escapeHtml() first. The slider and the side-by-side comparison share their sizing through the support/comparisonFrame.js mixin. A message that has to survive a redirect is a server-side Toast::success() with a redirect in the JSON; a toast shown just before navigating away is never seen.
Documented in the README as well; don't "fix" them without a reason:
/img/asset/…) and template references are out of scope.ItemUsageUpdater::update()). Don't "optimise" that into recording only the saved item: an index holding one item makes every other asset look unused, and so deletable..agents/skills/marketplace-review/SKILL.md) on the working tree and report its findings first. Commit only once the user has seen them, with every required change fixed or explicitly accepted by the user..agents/skills/marketplace-screenshots/SKILL.md, which holds the test site, the shot list, the capture settings, the frame script and what may not appear in a shot. Like the review skill, it is kept outside git.KeyAgency\AssetUsage\ → src/. Config is merged/published under the statamic.asset-usage key (config/asset-usage.php), read through Support\Settings rather than config() calls scattered around.Console\Commands\Concerns\BuildsIndexWithProgress for an index build). Bars go to stderr and are left out with --json, so a script reads only the data.asset-usage:doctor in the reporter's language helps nobody, and Statamic's own please commands are English too. Don't reach for a messages.* key in a command just because one happens to exist for the CP; Unused::blocker() takes a $locale for exactly this, and the commands pass 'en'. the_commands_stay_english_on_a_site_that_is_not guards it.lang/{en,nl,de,fr,es,it}/messages.php. Add keys to every locale, don't inline literals; TranslationsTest checks each one against English for keys, plural segments and placeholders. Related strings are grouped under their own array key (permissions, nav, index, filters, columns, compress, log, export, widget, delete, item_type, errors) rather than prefixed; singletons stay at the top level. Statamic flattens the file with Arr::dot() for the JS side, so __('asset-usage::messages.errors.asset_missing') works the same in Vue as in PHP.scanned_types is read as the complete set (Settings::scans()): a type that isn't listed is off. Laravel merges that config key as a whole, so a published config with one type means one type, and a config published before you added a type leaves that type off in that site. DoctorCommand names the keys a site's config is missing. Adding a scanned type means four things together: config/asset-usage.php, Settings::SCANNABLE_TYPES, an item_type label in every lang file, and an update script in src/UpdateScripts/ (autoloaded by AddonServiceProvider::bootUpdateScripts(), no registration needed) that adds the key to already-published configs. Those scripts list the types they introduce explicitly rather than reading SCANNABLE_TYPES, so a type a site left out on purpose is never silently re-added.Settings has a fallback for. A key a site can't see in its published config is a key it doesn't know it has. One script per release, listing that release's keys by hand; don't write a generic "sync whatever is missing from the shipped config", which would put back keys a site deliberately deleted. Insert through UpdateScripts\Concerns\InsertsIntoConfig, which first adds the comma a hand-edited last entry may lack; without it the published config no longer parses.isUpdatingTo() version has to be the version you actually tag. isUpdatingTo('1.1.0') never fires on a release tagged 1.0.2, and it fails silently: the script is registered, shouldUpdate() just answers false and nobody hears about it. Adding a config key makes the release a minor one for this reason alone. Check the gate against the CHANGELOG heading before tagging.v: git tag v1.5.1, never git tag 1.5.1. .github/workflows/release.yml only runs for tags matching v*, and it is what builds the CP assets (resources/dist is not in git) and creates the GitHub release with dist.tar.gz and the CHANGELOG section as notes; a tag without the v gets neither. Only the tag has the v: the CHANGELOG heading, the commit message (Release 1.5.1: …) and isUpdatingTo('1.5.1') use the bare number.FileSettings::path() vs FileSettingsRepository::find()). For an addon that overrides its slug those differ, which cuts both ways: its real settings file is never found, so addon_settings misses it entirely, and a file that happens to sit on the package-name path resolves to a null addon and throws. Items::addonSettings() catches the throw per addon so one broken addon can't take the whole scan down; an_addon_whose_settings_cannot_be_read_does_not_break_the_scan guards it. The miss is Statamic's to fix, so don't work around it by reading resources/addons/ directly./** */ for multi-line, and only where something needs explaining.How can I help you explore Laravel packages today?