Zoo for Craft CMS

The enclosures

Zoo groups its audits into three: content, structure and schema. Everything in the schema group, plus dormant users and empty folders, is off by default — what those delete is project config or an account rather than content.

Content

Unused assets

Files nothing refers to. This is the audit most people install Zoo for, and the one where a relations-only check is most obviously wrong.

An asset is tested against its filename and the URL it would be served at, computed per volume from the filesystem's root URL and the volume's subpath. The filename test is deliberately loose: if two assets in different volumes share a name and either is used, both survive. The cost of being wrong in that direction is a file that stays on disk; the cost of being wrong the other way is a hole in a published page.

When it finds something: the size column is the interesting one. Deleting a hundred unused thumbnails saves nothing; deleting six unused videos can halve a volume.

Unreferenced entries

Entries nothing points at. By default this means entries with no URL of their own as well — an entry at /about is a page, and a page is reachable whether or not anything on the site links to it.

Turn urlCountsAsReachable off and this becomes "pages linked from nowhere on the site", which is a genuinely useful list and not a list of things that are safe to delete.

Nested entries are excluded here — they belong to the orphaned-nested-entries audit, which knows what a missing owner means.

Careful: deleting a structure entry does not delete the entries beneath it. Craft moves them up to take its place, so a page with children leaves its whole subtree at the top level.

Orphaned nested entries

Matrix blocks and content blocks whose owning element has been deleted or trashed.

entries.primaryOwnerId is a plain integer column, not a cascading key, so an owner deleted outside Elements::deleteElement() — by a migration, by a plugin, by hand — leaves its blocks behind, still holding content, still indexed, reachable from nothing.

This is the one audit whose finding is a fact rather than an inference, so it does not run the probes: a block with no owner is orphaned by definition.

Abandoned drafts

Two kinds. A provisional draft is the autosave Craft makes the moment somebody types in an entry; one that has sat untouched for a month is abandoned work, and it is what makes an editor open an entry and see changes they do not recognise. An orphaned draft is one whose canonical entry has been deleted.

Discarding a draft is always permanent, whatever the delete mode says. There is nowhere for a deleted draft to go.

Dormant users (off by default)

Accounts that have never been logged into, have never authored anything, and that no other table names — which on a Commerce site means every customer who has ever ordered is excluded.

Admins are never reported, and neither are you. Zoo will only ever move a user to the trash: a permanently deleted user takes their authorship, addresses and permissions with them, and a partial restore will not put those back.

Structure

Empty categories

Categories with nothing filed under them and no children. A category that is somebody's parent is never reported — deleting it would promote its children rather than remove them.

Unused tags

Tags nothing is tagged with. Tag fields create tags as people type, so a site edited for a few years usually has a long tail of typos, near-duplicates and one-offs whose only entry was deleted.

Empty asset folders (off by default)

Folders with no files and no subfolders. Volume roots are never reported.

Schema

Everything here is off by default, and everything here is a project-config change: it will be applied to production on the next deploy, and there is no trash and no undo short of a database restore.

Dangling relations

Rows in relations pointing at elements that no longer exist. There is no foreign key on relations.targetId — Craft cascades the source but not the target — so hard-deleting an element leaves every relation at it behind, dangling at an ID nothing answers to.

Craft's own garbage collection sweeps these up, so on a healthy site this audit finds nothing. It finds things where garbage collection is off, where a migration deleted rows directly, or where a plugin deleted elements without going through Elements::deleteElement(). Zero here is a good result.

Fields in no layout

Fields whose UID appears in no field layout and in no other field's settings — and whose handle appears in no template, because entry.myField in Twig puts the handle nowhere in the database.

Deleting a field deletes its content everywhere, permanently, in every site.

Unused entry types

Entry types no section uses, no Matrix or CKEditor field names, and no entry was ever created with. All three have to be true. The second is checked by searching every field's settings JSON for the entry type's UID, because that is where Craft 5 keeps it.

Adding your own

An audit is a class implementing AuditInterface — in practice, extending BaseAudit and writing one candidates() method. Register it on Audits::EVENT_REGISTER_AUDITS and it appears on the dashboard, gets the probes, the keep list, the freshness checks and the delete guards for free.

Event::on(Audits::class, Audits::EVENT_REGISTER_AUDITS, function(RegisterAuditsEvent $event) {
    $event->types[] = MyAudit::class;
});

An audit deliberately cannot decide whether its own candidates are referenced. References does that for all of them, so that "unreferenced" means one thing across the whole plugin.