Why it exists
On a large site, the most common structured-data failure is silent. An event says its venue is #venue and its performer is #group, pointing at them by @id, but the venue or performer node was never actually included in the page. To a person the page looks perfect. To Google, the event's location doesn't exist.
Google's Rich Results Test shows the symptom ("references missing @id") one page at a time. Nothing tells you the root cause across a thousand pages, and nothing stops the same mistake from coming back next week. This tool does both.
Three checks in one pass
- 1. Required properties
- Each schema.org type has properties it can't do without: a MusicEvent needs a performer, a Question needs an answer. Every node is checked against its type.
- 2. @id resolution
- Every internal
#fragmentreference must point to a node that actually exists in the same graph. "Mentioned" isn't enough: it has to be defined. - 3. Parse errors
- A JSON-LD block with a stray comma is invisible to Google. It's reported as an error instead of being silently skipped.
- The exit code
0means clean,1means errors. That one number is what lets it guard a CI pipeline.
Why the @id check takes 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. Checking references in a single top-to-bottom pass would report false errors. So the checker first walks the whole graph and indexes every node that's actually defined with an @id, then walks it again checking every reference against that index.
// Pass 1: index every node that is defined (has an @id AND content of its own)
foreach (allNodes($graph) as $node) {
if (isset($node['@id']) && count($node) > 1) $defined[$node['@id']] = true;
}
// Pass 2: every bare {"@id": ...} reference must point at something defined
foreach (allReferences($graph) as [$type, $prop, $ref]) {
if (str_contains($ref, '#') && !isset($defined[$ref])) {
$errors[] = "[$type] property \"$prop\" references unresolved @id \"$ref\"";
}
}
Simplified sketch. The real implementation is one file, audit.php on GitHub. The demo above is a JavaScript version of the same three checks, using a representative set of required properties per type.
Usage
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
$ 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"
$ php audit.php tests/fixtures/good.html
=== SCHEMA AUDIT: clean. 0 errors across 1 file(s). ===
From the repo's README. PHP 7.4+, no extensions, no Composer: one file you can drop anywhere.
A tool that tests itself
The repo audits itself on every push, and the CI checks both directions: the good fixture must pass and the broken fixture must fail. Most test suites only prove the happy path. This one also proves the tool still catches the exact failure it was built for. If it ever stops catching it, CI goes red.
The same discipline on this site
Every page on bryanhamilton.info carries structured data: a profile page, your Person and the projects you've written, the FAQ, and a breadcrumb trail and software entry on each of these project pages. Every reference between them resolves. Press bryanhamilton.info homepage or This page above to audit them yourself.
Related projects
Frequently asked questions
What does jsonld-schema-audit check?
The PHP CLI checks required properties for supported schema.org types, unresolved internal @id fragment references, and JSON-LD blocks that cannot be parsed.
How does it check internal @id references?
It first indexes nodes that define an @id, then checks references against that index so a reference only passes when its target node is actually defined in the graph.
Can jsonld-schema-audit run in continuous integration?
Yes. A clean audit exits with code 0 and findings exit with code 1, allowing CI to fail when structured-data regressions are detected.
Does this tool replace Google’s Rich Results Test?
No. It complements page-level testing by auditing structural issues such as missing required properties, unresolved graph references, and malformed JSON-LD; it does not guarantee search eligibility or rankings.
Technical SEO that holds
Bryan Hamilton is a full-stack web engineer with 25+ years in PHP and JavaScript, available immediately for remote, hybrid or on-site roles in the NYC metro area.