DEV Community

Bryan Hamilton
Bryan Hamilton

Posted on

How I Took ~1,000 Pages From Hundreds of Rich Results Warnings to Zero

The DJ Calendar has about 1,000 pages of structured data: artist pages, tour dates, venues and FAQs. For a while, Google flagged hundreds of Rich Results warnings across them. Today the count is zero, and a single 178-line PHP file keeps it there.

This post covers the bug behind almost all of those warnings, why it's so easy to ship without noticing, and the small auditor I built to catch it, including a CI job that proves the auditor still works.

The bug that looks perfectly fine

JSON-LD lets one node point at another by @id instead of repeating it. An event can say "my venue is #venue" and "my performer is #group", and those nodes are defined once elsewhere in the @graph.

The failure is simple: the event points at a node that was never actually put on the page.

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "MusicEvent",
      "@id": "https://example.com/event/2#event",
      "name": "Nova Kade at The Fillmore",
      "startDate": "2026-11-21T21:00",
      "location": { "@id": "https://example.com/venue/ghost#venue" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The JSON is valid. The page renders normally. A person reading it sees a venue in the HTML. But in the graph Google reads, #venue points at nothing, so the event has no location, and it isn't eligible for event rich results.

On a site generated from templates, this happens quietly: a partial that renders the venue node gets skipped for one kind of page, or a refactor renames an @id on one side but not the other. Nothing errors. Traffic just doesn't show up.

Why the Rich Results Test wasn't enough

Google's Rich Results Test is the right tool for one page. It will tell you a property is missing or that a reference goes nowhere. But it had three problems at my scale:

  • It's one page at a time. I had about 1,000 pages built from a handful of templates. Pasting URLs in one by one doesn't tell you which template is the root cause.
  • It shows the symptom, not the source. A warning on an event page doesn't say which partial failed to emit the venue node.
  • Nothing stops it coming back. Fix it today, and next month's refactor can reintroduce it. I'd only find out when the warnings reappeared, weeks later.

I didn't need a better validator. I needed something I could point at a whole folder of rendered pages, that would fail loudly, and that could run on every push.

Three checks in one pass

jsonld-schema-audit is one PHP file with no dependencies. It pulls every <script type="application/ld+json"> block out of each HTML file and runs three checks.

1. Required properties. Each type has properties it can't do without. The list lives in one constant, so adding a type is a one-line change:

const KNOWN_TYPES = [
    'MusicEvent'     => ['name', 'startDate', 'location', 'performer'],
    'MusicGroup'     => ['name'],
    'Place'          => ['name'],
    'Organization'   => ['name', 'url'],
    'WebPage'        => ['name'],
    'FAQPage'        => ['mainEntity'],
    'BreadcrumbList' => ['itemListElement'],
    'Person'         => ['name'],
];
Enter fullscreen mode Exit fullscreen mode

A property counts as missing if it's absent, an empty string or an empty array.

2. @id resolution. Every internal #fragment reference has to point at a node that is defined in the same graph. More on this below, because it's the interesting part.

3. Parse errors. A JSON-LD block with a trailing comma is invisible to Google, and most tools skip it silently. Here, a block that json_decode can't read becomes an error instead:

$decoded = json_decode(trim($raw), true);
if (!is_array($decoded)) {
    $blocks[] = [['__parse_error__' => 'unparsable JSON-LD block']];
    continue;
}
Enter fullscreen mode Exit fullscreen mode

The exit code ties it together: 0 when clean, 1 when there are errors, 2 for bad usage. That single number is what lets it guard a pipeline.

Why the @id check needs two passes

A reference can appear before the node it points to. An event near the top of the graph can point at a venue defined at the bottom. Check references top to bottom in one pass, and every forward reference looks broken.

So the auditor walks the graph twice. Pass one only builds an index of what's defined:

// First pass: index every node carrying an @id (these are definitions).
foreach ($nodes as $node) {
    if (is_array($node) && isset($node['@id']) && is_string($node['@id'])) {
        $nodesById[$node['@id']] = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Pass two looks at every value nested inside each node. Anything shaped like an internal reference (an optional URL, then # and a fragment) has to exist in that index:

$copy = $node;
unset($copy['@id']);   // a node's own @id is a definition, not a reference
array_walk_recursive($copy, function ($value, $key) use (&$errors, $nodesById, $typeLabel) {
    if (!is_string($value) || $key === '@context') { return; }
    if (preg_match('/^(?:https?:\/\/[^\s#]+)?#[A-Za-z0-9_-]+$/', $value)
        && !isset($nodesById[$value])) {
        $errors[] = '[' . $typeLabel . '] property "' . $key
            . '" references unresolved @id "' . $value . '"';
    }
});
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • The unset before the walk. Without it, every node's own @id would be checked as if it were a reference to itself. That's a definition, not a pointer.
  • "Defined", not "mentioned". A URL appearing somewhere in the graph doesn't count. The target has to be a node that actually carries that @id. That's exactly the distinction Google makes, and the one my templates kept getting wrong.

Run against the broken example from earlier, the output points straight at the cause:

$ php audit.php tests/fixtures/broken.html
=== SCHEMA AUDIT: 2 error(s) across 1 block(s) ===

tests/fixtures/broken.html  [JSON-LD block 1]
  [MusicEvent] missing required property "performer" on https://example.com/event/2#event
  [MusicEvent] property "@id" references unresolved @id "https://example.com/venue/ghost#venue"
Enter fullscreen mode Exit fullscreen mode

(The reported property is @id because array_walk_recursive reports the innermost key, the @id inside location.)

A CI job that proves the tool still catches the bug

Most test suites only prove the happy path. For a checker, that's the wrong half. A checker that passes everything also passes your good fixture.

So the repo's GitHub Actions workflow tests both directions: the good fixture must pass, and the broken fixture must fail.

name: Schema Audit CI
on: [push, pull_request]
jobs:
  self-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
      - name: Good fixture must pass
        run: php audit.php tests/fixtures/good.html
      - name: Broken fixture must fail
        run: |
          if php audit.php tests/fixtures/broken.html; then
            echo "ERROR: broken fixture passed audit"; exit 1
          else
            echo "OK: broken fixture correctly rejected"
          fi
Enter fullscreen mode Exit fullscreen mode

The second step inverts the exit code. If a future change makes the auditor too lenient, the broken fixture sneaks through, and CI goes red. The tool can't silently stop catching the exact bug it was written for.

Using it on your own site

It needs PHP 7.4 or newer and nothing else: no extensions, no Composer. Drop audit.php anywhere and point it at rendered HTML:

php audit.php path/to/html/            # audit a directory (*.html, *.php)
php audit.php page.html                # audit one file
php audit.php ./ --report=out.txt      # write errors to a file
Enter fullscreen mode Exit fullscreen mode

Clean pages stay quiet:

$ php audit.php tests/fixtures/good.html
=== SCHEMA AUDIT: clean. 0 errors across 1 file(s). ===
Enter fullscreen mode Exit fullscreen mode

To guard your own site, render your pages to a folder during the build and add one step to your pipeline: php audit.php build/. A non-zero exit fails the job.

If you want to see it work before installing anything, the project page has an in-browser version of the same three checks. Paste your own HTML or JSON-LD, or load the broken fixture and watch it fail.

What I took away from it

  • "Defined" beats "mentioned." The bug was never invalid JSON. It was a graph that pointed at nodes that weren't there. Check the relationships, not just the syntax.
  • Test the failure, not just the success. A checker's most important test is the one where it's supposed to say no.
  • Small tools get used. It covers eight types on purpose, and only checks nodes at the top level of each graph. One file with no dependencies means there's no excuse to leave it out of CI. I'd rather have a narrow check that runs on every push than a thorough one that runs when I remember.

The structured data on bryanhamilton.info is held to the same standard. Every reference between its Person, projects, FAQ and breadcrumbs resolves.

Links

If you've been bitten by a dangling @id, or you check structured data some other way in CI, I'd like to hear how in the comments.

Top comments (0)