On a large site, structured data breaks when more than one tool writes it. The fix is one authoritative source that every page type inherits from. The hard part is proving nothing else is still writing one alongside it: the documented filter for switching my SEO plugin's schema off switched nothing off, and I lost days to it because I never counted the blocks on the page.
Look at a Google results page for anything you sell. The listings that pull the eye are no longer plain blue links: they carry prices, breadcrumb trails, review stars, extra links underneath. Those are rich results, and they quietly win the click. They take up more room, they signal trust before anyone lands on your site, and they cost you nothing for each visitor they send. Which of them a business like yours can still earn has narrowed over the years, and I will come back to that at the end, because I have since removed two types from my own implementation for exactly that reason. The ones that remain are worth real money on a large site, and earning them comes down to one thing you fully control.
The thing that makes those rich results appear is structured data: a small block of machine-readable information you put on each page telling Google exactly what the page is. Get it right and you earn free visibility. Get it wrong, and you get nothing, or worse, Google flags errors and quietly ignores the page.
Here is the trap. On a big site, structured data is usually a mess. Your SEO plugin adds some. Your theme adds some. A page builder adds some more. They contradict each other, they duplicate each other, and validation errors pile up. On one site I run, a multi-location trades business, the same page was being described more than once, by one plugin that had quietly grown a separate emitter for every schema type it supported. Each of those emitters hooked its own output into the head independently, none of them counting what the others had already written, so the page went out carrying several JSON-LD blocks where it needed one. That is the state validation errors come out of, and you cannot fix it page by page, because the thing producing them is not on any one page. I fixed it by taking control: one authoritative source of schema, defined once, that every page type inherits from and extends. Nobody hand edits schema any more, so there is exactly one place it can be wrong and exactly one place to fix it. Getting the old source to actually shut up took far longer than writing the new one, and that is the part of this article I would read twice.
This is exactly how I did it, step by step, in a way you can copy.
What this changes about how your listing looks
Structured data is invisible plumbing right up until it changes what your listing looks like in search.
- Your listings can carry the extra detail Google is still prepared to show, prices and breadcrumbs among them, so your result takes up more room than a plain blue link and you paid nothing extra for the attention.
- Every page carries correct, consistent structured data automatically, so a large site behaves like one well-built page.
- The site can grow without the schema rotting. New product, new article, new location: it inherits the base and just works.
- You stop firefighting validation errors, because there is only one source and no one hand edits it.
You do not need a premium schema plugin or a monthly service. You need one authoritative source that you control, a base graph defined once, and page types that inherit it and add only their own piece. Build that, and your structured data stays valid forever while quietly pulling in clicks you would otherwise have paid for.
Why one source, and why inheritance
Two ideas do all the work here. Understand them and the code is easy. The rest of this guide is just plumbing that carries these two ideas onto every page of a large site without you touching them again.
One authoritative source
Structured data breaks when more than one thing writes it. If your SEO plugin says the page is an Article and your theme says it is a WebPage with different details, Google sees a conflict. The fix is not to tune each source. The fix is to switch every other source OFF and let one piece of code you control emit all of it. One source, one voice, no contradictions.
Why plugins fight each other
Every plugin author wants their tool to work out of the box, so each one ships schema turned on by default. That is helpful on a five page brochure site. On a large site it is chaos, because three tools all claim the right to describe the same page and none of them knows the others exist. You cannot win that fight by configuring each plugin. You win it by ending the fight: one emitter, everyone else silent.
Inheritance instead of hand writing
The second idea is the one that keeps it valid forever. Most sites hand write schema per page or per template. That does not scale. Someone forgets a field, someone pastes the wrong ID, and drift creeps in as the site grows.
Instead, you define a base graph once. Every site shares the same Organization (who you are) and the same WebSite (the site itself). Those never change from page to page, so you write them a single time. Then each page type inherits that base and adds only its own small piece: a Product adds price and rating, an Article adds author and date, a location page adds its address, an FAQ adds its questions. Nobody rewrites the shared parts. That is why it cannot drift.
Define once, reference everywhere
The trick that makes inheritance real is the reference by ID. You give the Organization a stable identifier, then anywhere else that needs it, you point at that identifier instead of copying the whole block. Change your company name once and every page updates, because every page was only ever pointing at the one true copy.
Define the Organization once, point at it everywhere else, and every page on the site stays in sync without you touching a thing.
The plugin’s documented off switch did not switch anything off
Before you write a single line of your own schema, you have to silence everything else. If you skip this, you will emit clean schema and still fail validation, because the plugin is quietly emitting its own broken copy right next to yours. Two Organization blocks, two WebSite blocks, two truths.
Most SEO plugins expose a filter to turn their structured data off. SeoPress, which I use on that site, has one. Yoast and Rank Math have their own. Put these in a small must-use plugin or your theme functions file. Start here, and then distrust it completely, because on the site I am describing this exact code ran on every request and silenced nothing at all.
<?php
// Turn OFF SeoPress structured data so we have one source.
add_filter( 'seopress_schemas_auto_enable', '__return_false' );
add_filter( 'seopress_json_ld_enable', '__return_false' );
// If you use Yoast, disable its schema graph too.
add_filter( 'wpseo_json_ld_output', '__return_false' );
// If you use Rank Math, remove its JSON-LD.
add_filter( 'rank_math/json_ld', function ( $data ) {
return array();
} );
When the filter does nothing
I wrote those filters, cleared the cache, and moved on to the interesting part. Days later I was still failing validation on duplicate Organization nodes and could not see why, because as far as I was concerned the plugin was off. It was not off. It was emitting its own JSON-LD on every page, in several separate blocks, exactly as it always had.
The reason is worth understanding, because it is not one plugin’s fault. A filter only does anything if the code that reads it is still the code doing the work. Plugins get refactored. The single function that used to check seopress_json_ld_enable gets broken up into a set of small classes, one per schema type, each instantiated at boot and each hooking its own render method onto wp_head at an early priority. Nothing in those new classes ever asks the old filter what it thinks. The filter still exists. It still returns false. It governs nothing.
The obvious next move is remove_action, and the obvious next mistake is to copy a class name out of an article written two years ago. That is what I did. The emitters had since moved into a Schemas namespace nested inside the plugin’s own front-end actions, so the class name I was carefully unhooking no longer existed. remove_action does not complain about that. It returns false and carries on, and nobody in the history of WordPress has checked the return value of remove_action. I had written code that looked like a fix, reported nothing, and did nothing, which is the worst kind.
So do not guess the name. Ask WordPress what is actually hooked, and read the answer.
<?php
/**
* Temporary. Prints what is really hooked to wp_head, with priorities.
* Read the log, then delete this.
*/
add_action( 'wp', function () {
if ( empty( $GLOBALS['wp_filter']['wp_head'] ) ) {
return;
}
foreach ( $GLOBALS['wp_filter']['wp_head']->callbacks as $priority => $hooks ) {
foreach ( $hooks as $hook ) {
$fn = $hook['function'];
if ( is_array( $fn ) && is_object( $fn[0] ) ) {
error_log( $priority . ' ' . get_class( $fn[0] ) . '::' . $fn[1] );
} elseif ( is_string( $fn ) ) {
error_log( $priority . ' ' . $fn );
}
}
}
}, 99 );
Load one page, read the log, and you have the real class names and the real priorities for the version you are actually running, which is the only version that matters. Then unhook what you found rather than what you hoped for.
<?php
/**
* Unhook the plugin's schema emitters by matching the class,
* not by trusting a filter. Runs on 'wp' so the plugin has
* finished registering, and before wp_head fires.
*/
add_action( 'wp', function () {
if ( empty( $GLOBALS['wp_filter']['wp_head'] ) ) {
return;
}
$doomed = array();
foreach ( $GLOBALS['wp_filter']['wp_head']->callbacks as $priority => $hooks ) {
foreach ( $hooks as $hook ) {
$fn = $hook['function'];
// Match on the distinctive part of the namespace you saw in the log.
// Class names get renamed between releases, namespaces last longer.
if ( is_array( $fn ) && is_object( $fn[0] )
&& false !== strpos( get_class( $fn[0] ), 'Schemas' ) ) {
$doomed[] = array( $fn, $priority );
}
}
}
// Remove after the walk, not during it.
foreach ( $doomed as $item ) {
remove_action( 'wp_head', $item[0], $item[1] );
}
}, 99 );
Matching a namespace fragment instead of a list of exact class names is deliberate. These plugins ship one class per schema type and they add new ones between releases. A namespace match still covers the new one. A hand-written list of six class names quietly stops being complete on the day the plugin ships a seventh, and you will not be told.
Count the blocks before you believe any of it
This is the step I skipped, and skipping it is what cost me the days. Do not trust the filter. Do not trust your own remove_action either. Count what the page actually sends.
Count it over the network rather than from the browser. View source can hand you something the browser already had, and if there is a full page cache in front of the site you may be reading a file written before your change existed. Ask for it yourself, with something on the end of the URL to miss the cache.
# Count the JSON-LD blocks the page actually sends.
curl -s 'https://example.com/some-product/?cachebust=1' | grep -o '<script[^>]*application/ld+json[^>]*>' | wc -l
Two details in that command matter more than they look. The first is that it anchors on the opening script tag. Searching the page for the bare string “application/ld+json” is the natural thing to do and it will lie to you in the direction of panic: that string turns up in your tag manager container, in inline JavaScript, and in the body copy of any page that happens to discuss structured data, which is how this very article would report blocks it does not have.
The second is that it counts opening tags rather than trying to match whole blocks. If you reach for the tidier-looking regex, opening tag, then anything, then closing tag, the “anything” in the middle is greedy. It runs from the first opening tag on the page all the way to the last closing tag and returns exactly one match. Four real blocks, reported as one, which is precisely the answer you were hoping for. A false clean is much worse than a false alarm, because a false alarm makes you look again and a false clean makes you stop.
One last trap if the site sits behind a page cache: the cached files on disk are usually gzipped, so grepping them directly finds nothing and you conclude the schema is gone when you have simply searched a compressed file for plain text. Use zgrep on those, or do what the command above does and ask over HTTP where the response has already been decompressed for you.
Before you write a line of your own schema, that count wants to be zero. Not “one, and it is probably mine”. Zero. Add your generator, run the same command again, and the answer should be one.
Count the JSON-LD blocks over HTTP with a cache buster on the URL, and count opening script tags rather than searching for the bare string “application/ld+json”. The bare string matches your tag manager and your own page copy, and a greedy whole-block regex collapses four real blocks into one match. Get the count to zero before you write any schema of your own, then confirm it is exactly one afterwards.
Inheritance is one stable ID that everything else points at
Now build the one source. It is a small class. I keep it in a must-use plugin so it loads on every request and cannot be turned off by a theme switch.
The shape of the class
The class has three jobs. Build the base graph that every page shares. Ask the current page what type it is and let it add its own piece. Print the whole thing into the head as one JSON-LD block. That is all.
<?php
/**
* Plugin Name: Site Schema Generator
* Description: One authoritative JSON-LD source with per-type inheritance.
*/
class Schema_Generator {
public function __construct() {
add_action( 'wp_head', array( $this, 'render' ), 20 );
}
/**
* The base graph every page inherits.
* Organization and WebSite never change page to page,
* so we define them once here.
*/
private function base_graph() {
$site_url = home_url( '/' );
$org_id = $site_url . '#organization';
$site_id = $site_url . '#website';
return array(
array(
'@type' => 'Organization',
'@id' => $org_id,
'name' => get_bloginfo( 'name' ),
'url' => $site_url,
'logo' => array(
'@type' => 'ImageObject',
'url' => get_theme_mod( 'custom_logo_url', $site_url . 'logo.png' ),
),
),
array(
'@type' => 'WebSite',
'@id' => $site_id,
'url' => $site_url,
'name' => get_bloginfo( 'name' ),
'publisher' => array( '@id' => $org_id ),
),
);
}
}
new Schema_Generator();
The role of the @id
Notice the @id values. Each node gets a stable ID built from the site URL. That is the glue of inheritance. When a Product later says its brand is the Organization, it does not repeat the whole Organization. It just points at #organization by ID. Google follows the reference. Define once, reference everywhere. If you ever rename the company or swap the logo, you change it in this one method and every page across the site follows.
Letting each page type add its piece
Now the part that makes it inherit. Ask WordPress what is being viewed, then merge in the extra node for that type. The base is always there. The extra node is small and only carries what is unique to that page.
<?php
/**
* Build the piece specific to the current page and
* merge it onto the shared base graph.
*/
private function page_graph() {
$graph = $this->base_graph();
if ( is_singular( 'product' ) ) {
$graph[] = $this->product_node();
} elseif ( is_singular( 'post' ) ) {
$graph[] = $this->article_node();
} elseif ( is_page( 'faq' ) ) {
$graph[] = $this->faq_node();
} elseif ( is_singular( 'location' ) ) {
$graph[] = $this->location_node();
}
return $graph;
}
Each of those node methods returns one array. They stay short on purpose. Here is the Product one. It reads its fields from custom fields (I use SCF there) and points its brand back at the shared Organization by ID.
<?php
private function product_node() {
$post_id = get_the_ID();
$site_url = home_url( '/' );
$node = array(
'@type' => 'Product',
'@id' => get_permalink( $post_id ) . '#product',
'name' => get_the_title( $post_id ),
'description' => wp_strip_all_tags( get_the_excerpt( $post_id ) ),
// Inherit the shared Organization instead of repeating it.
'brand' => array( '@id' => $site_url . '#organization' ),
);
// Only add an offer if we actually have a price.
$price = get_post_meta( $post_id, 'price', true );
if ( $price ) {
$node['offers'] = array(
'@type' => 'Offer',
'price' => $price,
'priceCurrency' => 'SEK',
'availability' => 'https://schema.org/InStock',
);
}
return $node;
}
The Article and FAQ nodes follow the same pattern. Short, focused, and they lean on the base for anything shared.
<?php
private function article_node() {
$post_id = get_the_ID();
$site_url = home_url( '/' );
return array(
'@type' => 'Article',
'@id' => get_permalink( $post_id ) . '#article',
'headline' => get_the_title( $post_id ),
'datePublished' => get_the_date( 'c', $post_id ),
'dateModified' => get_the_modified_date( 'c', $post_id ),
'author' => array(
'@type' => 'Person',
'name' => get_the_author_meta( 'display_name', get_post_field( 'post_author', $post_id ) ),
),
// Publisher is the shared Organization, by reference.
'publisher' => array( '@id' => $site_url . '#organization' ),
);
}
private function faq_node() {
// Pull question/answer pairs from a repeater field.
$pairs = get_field( 'faq_items' ); // returns array of ['question','answer']
$questions = array();
if ( $pairs ) {
foreach ( $pairs as $pair ) {
$questions[] = array(
'@type' => 'Question',
'name' => $pair['question'],
'acceptedAnswer' => array(
'@type' => 'Answer',
'text' => wp_strip_all_tags( $pair['answer'] ),
),
);
}
}
return array(
'@type' => 'FAQPage',
'mainEntity' => $questions,
);
}
Before you copy that FAQ method across: I do not run it any more, and there is a section further down explaining why. It is still in this article because it is the clearest short example of a node that nests other nodes inside itself, not because I think you should ship it.
When you add a new page type later, you add one method and one line in page_graph(). You never touch the base. That is the whole point. A junior developer can add a location or a recipe type in ten minutes and cannot break the shared graph, because the shared graph is not something they edit.
Printing it into the head
Last, wrap the graph in the standard JSON-LD envelope and print it once. One block, one context, every node inside it.
<?php
public function render() {
// Do not emit schema on admin, feeds, or search results.
if ( is_admin() || is_feed() || is_search() ) {
return;
}
$document = array(
'@context' => 'https://schema.org',
'@graph' => $this->page_graph(),
);
$json = wp_json_encode(
$document,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
echo '<script type="application/ld+json">' . $json . '</script>' . PHP_EOL;
}
Why wp_json_encode and not json_encode
Use wp_json_encode, not raw json_encode. It handles WordPress encoding correctly and keeps special characters, common in Swedish content, from breaking the block. The @graph wrapper is what lets many nodes live in one script tag and reference each other by ID. Without it you would be back to separate blocks that cannot see each other, which is the exact problem this sets out to remove.
Two types I shipped and later removed
The FAQ node above is real code that ran on a real site for a long time, and I have since taken it out, along with an aggregateRating that was sitting on the shared Organization node in the base graph. I have left the FAQ method in this article because the honest version of the story is more useful to you than a tidy one, and because “here is a thing I built and later deleted” is the part of a build most write-ups leave out.
FAQPage stopped paying for itself
When I wrote that method, FAQ drop-downs were appearing in results for ordinary commercial sites and they were worth having. Google has since narrowed FAQ rich results down to a small set of authoritative sources, largely government and health. An ordinary business emitting FAQPage today gets nothing visible back for it.
What it does still get is cost. More nodes to validate. More fields that have to stay in step with the visible page, because the day somebody edits the answer in the page builder and the schema says something else, you are publishing a contradiction. And a category in Search Console that will eventually go amber over something you could not have cashed in anyway. That is the whole argument for removing it: not that it is wrong, but that it is maintenance with no return, and schema nobody is being paid for is schema nobody remembers to fix. The questions and answers still sit on the page as real content, which is where they were doing the actual work all along.
AggregateRating was on the wrong node
The second removal was an aggregateRating, and where it lived is the whole reason it had to go. It was not on a product. It sat on the shared Organization node in the base graph, alongside the name and the logo, which meant it shipped on every page of the site. The business was rating itself, everywhere, in its own markup, on the strength of numbers it had collected and stored and published itself.
That is the specific thing Google’s review snippet guidance rules out: reviews and ratings about the entity that owns the page, written by that entity. Self-serving markup on an Organization or a LocalBusiness node does not earn a star however clean the JSON underneath it is.
Be careful how far you carry that rule, though, because it gets repeated much more broadly than it is written, and I have watched people strip perfectly good markup off the back of it. It is about a business marking up its own reputation. First-party product reviews are a different case and they are eligible. If you sell things and you gather ratings from the customers who bought them, an aggregateRating on the Product node on your own product pages is exactly what that feature is for, and it is how most of the e-commerce listings you see with stars in them got their stars. You will notice the product_node further up this article carries no rating at all. If you have genuine customer ratings for a product, that is where one belongs, and I would add it. Mine was on the company, and a company does not get to review itself.
So the node was valid. It parsed. The Rich Results Test was perfectly happy with it, because that tool checks the shape of your markup, not your standing to make the claim. And it was never going to produce a single star. At best that is dead weight in the graph. At worst somebody decides a site is marking up ratings it has no business marking up, and a structured data manual action is a genuinely bad week that starts with an email and ends with a reconsideration request. I would rather ship a smaller graph I can defend line by line than a bigger one carrying a node I would struggle to justify if asked. Both came out, and the file got shorter, which is usually a good sign.
What is still worth emitting
Breadcrumbs are the type I would add first now. BreadcrumbList changes what the visitor sees in the result, it costs almost nothing, and on a deep site with categories and locations nested several levels down it genuinely helps somebody understand where they are about to land. There is a shortcut worth knowing too: if your theme or page builder already renders a breadcrumb trail, it almost certainly exposes a filter carrying that trail as an array of items. Build the node from that array rather than reconstructing the hierarchy yourself, and the schema is guaranteed to agree with the breadcrumb on the page, because it is the same data. Reconstruct it by hand and the two will disagree eventually, usually the first time somebody reparents a category.
Past breadcrumbs it is a short list: the Organization and WebSite base, Product with a real offer on it, Article metadata, LocalBusiness details on pages that describe a place you can actually visit. That is not many types. The shortness is the point. Every node you emit is a promise about the page that somebody has to keep true.
Count the blocks on the page, because your eyes will not
Do not trust this by eye. Structured data fails in quiet ways, so verify it properly. Check three things, in order, from fastest to slowest.
Check there is exactly one block
Run the same count you ran before you started, on a product page this time, and expect exactly one. Not one plus a tag manager block. Not one big greedy match that is really four. If the number is higher than one, an old source has come back to life, which happens after plugin updates more often than you would like, and it is why this check belongs in whatever you run after a release rather than only in your head. Go back to the unhooking step and find out which class is new.
Run it through the validators
Copy the page URL into two tools. Google’s Rich Results Test tells you which rich result types the page currently qualifies for, and that list is shorter than it was a few years ago, so read it as a report on what Google is willing to use rather than as a score out of ten. The Schema.org validator catches structural mistakes Google stays quiet about, which is most of them. Run a product page, an article, and a location page. Fix until they all come back clean.
Watch it in Search Console
The real proof arrives over the following weeks. Google Search Console has an Enhancements section that reports valid items, warnings, and errors across the whole site. It is slow, and it is the only one of the three checks that looks at every page rather than the one you pasted into a box, so it is the one that tells you whether the pattern held everywhere instead of whether a single URL parses. Watch the error rows there for a month after you ship. A type you thought you had removed reappearing in that report is the site telling you an old emitter is still alive on page templates you never thought to open.
I am deliberately not giving you a before and after off my own run of this. I never took a reading before I started, so anything I told you about the drop would be a number I remembered rather than a number I took, and most of this article has been about not believing things I had not actually checked. So take the reading before you change anything. That is the half of this report people skip, and it is the half that makes the other half mean something.