Zoo for Craft CMS

How it works

An orphan has to be proved

The naive version of this plugin is one query:

SELECT * FROM assets WHERE id NOT IN (SELECT targetId FROM relations)

That query is wrong on every real site, and it is wrong in the direction that deletes things. A relation row is one of at least seven ways one piece of content can point at another.

The seven probes

1. Relational fields — the relations table. The one everybody checks.

2. Nested-entry ownership — elements_owners. In Craft 5 a Matrix block is an entry owned by another element. A block is referenced by its owner, and nothing in relations says so.

3. Structure parentage — structureelements. An entry with a parent is somewhere, even if nothing links to it.

4. Reference tags — {asset:41:url} typed into a text field. It is a string inside a JSON column. No row anywhere records the relationship.

5. Markup — <img src="/uploads/hero.jpg"> in a rich-text field. This is the one that matters most in practice: an image dragged into a CKEditor field creates no relation row at all, and every relations-only tool will offer to delete it while it is visibly on the page.

6. Templates — craft.entries.id(412), {% if entry.id == 88 %}, an asset filename in a CSS background. None of it is in the database. Zoo reads your templates/ and config/ directories and looks for IDs used as IDs, and for quoted literals matching a candidate's slug, URI or filename.

7. Other tables — every column in the database with a foreign key into elements. See below.

Each probe reports whether it ran, how many places it searched, and how many references it found. Every finding is a claim that all of them came back empty, and the finding travels with the list.

Reading the schema instead of keeping a list

Zoo does not know that Commerce exists. It asks information_schema for every column with a foreign key into elements.id or users.id, and queries each one.

On a plain Craft install that is a couple of dozen columns. On a site with Commerce, Formie, Navigation, SEOmatic and a few custom plugins it is over a hundred and fifty. That is how a variant sitting in a five-year-old order, or an entry held by a navigation node, keeps itself alive without Zoo having heard of either plugin.

Craft's own bookkeeping is excluded by an explicit list, published on the settings screen:

elements, elements_sites, elements_owners, relations, structureelements, searchindex, changedattributes, changedfields, drafts, revisions, sessions, sequences, and Zoo's own tables — plus any column literally named id, which is the element's own row in its type table rather than a reference to it.

The first three of those are on the list because they get dedicated probes with the right semantics. relations.sourceId says "this element points at something", which is the opposite of the question being asked.

Weak references

A reference held only by a revision, a draft, or something already in the trash is a real row and a real pointer — but it is not a reason to keep an asset alive. Nobody is looking at it, and the thing holding it is itself on its way out.

Zoo reports those findings anyway, and puts the weak reference in the evidence panel with a note saying what it is. It neither hides the row nor treats it as load-bearing, because which of those is right depends on how you use revisions and Zoo does not get a vote.

What a probe that did not run means

Turning off Scan templates does not make Zoo find more orphans and it does not make it find fewer. It makes every finding weaker, and the probe list is where that shows up:

– Twig templates and config     Template scanning is turned off in settings.

"Nothing points at this" and "nothing I was able to check points at this" are different claims and only one of them is a reason to delete something. Zoo will not quietly make the first claim when the second is what it has.

What it costs

A census is bounded by three things: one pass over elements_sites.content, one walk of templates/ and config/, and one SELECT DISTINCT per keyed column per five hundred candidates.

The cheap cases are thrown out in SQL first — most content on a healthy site is related to something or owned by something, and both are indexed — so the expensive probes only ever see the elements that already look like orphans. A census on a mid-sized site is seconds. On a very large one, queue it.