If you sell products with WooCommerce and use Yoast SEO, you already get a good amount of structured data out-of-the-box.
But there are two common gaps:
- WooCommerce’s Product JSON-LD is not explicitly linked to Yoast’s WebPage graph, even though Yoast’s whole Schema approach is “graph-based”.(Yoast developer portal)
- Important technical attributes (material, width, height, load class, etc.) are missing from the schema, even though you show them in your product tables.
In this article we’ll:
- Add a semantically correct link between the Yoast
WebPagegraph and the WooCommerceProductgraph usingmainEntityandmainEntityOfPage. - Use Schema.org’s
additionalProperty+PropertyValuepattern to expose all key product attributes in a machine-readable way.(schema.org) - Package everything as a small WordPress plugin you can install on any WooCommerce + Yoast SEO site.
But wait! Before we can even start, if you don’t know what schema markup is, then please read our article here → What Is Schema Markup? & How to Add It to Your Site
Table of Contents
Why bother? A quick refresher on Product structured data
Google uses Schema.org Product markup to power product snippets, Google Images, Google Lens and merchant experiences.(Google for Developers)
Benefits:
- Richer search results (price, availability, ratings, etc.).
- Cleaner input for LLMs and other semantic search systems.
- Easier feed-free syncing with Google Merchant Center and similar tools.(support.google.com)
Most docs focus on the basics (name, price, availability). They rarely cover domain-specific attributes such as:
- Channel material
- Nominal width
- Load class according to EN or DIN
- Packaging units, etc.
That’s exactly what additionalProperty + PropertyValue were designed for: arbitrary key/value feature pairs that still live in the Schema.org universe.
How WooCommerce and Yoast SEO handle Schema by default
- WooCommerce outputs a
ProductJSON-LD block on single product pages viaWC_Structured_Data. It already includes name, description, price, offers, image, etc. - Yoast SEO outputs a full Schema graph with
WebPage,WebSite,Organization, breadcrumbs and more, using its Schema API.
By default these two worlds don’t “know” about each other:
- Yoast’s
WebPagedoesn’t declare that its main entity is the WooCommerceProduct. - WooCommerce’s
Productdoesn’t explicitly say whichWebPageit belongs to.
Search engines are smart enough to infer this, but linking them explicitly with mainEntity and mainEntityOfPage gives you a cleaner, more robust graph.
The Schema pattern we’re aiming for
Conceptually we want this:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://example.com/product/example-url/",
"url": "https://example.com/product/example-url/",
"name": "Example Product ...",
"mainEntity": {
"@id": "https://example.com/product/example-url/#product"
}
},
{
"@type": "Product",
"@id": "https://example.com/product/example-url/#product",
"name": "Example Product ...",
"mainEntityOfPage": {
"@id": "https://example.com/product/example-url/"
},
"additionalProperty": [
{
"@type": "PropertyValue",
"name": "A Sample Value Name",
"value": "A Sample Value"
},
{
"@type": "PropertyValue",
"name": "Width",
"value": 160,
"unitCode": "MMT"
}
]
}
]
}
Key points:
WebPage.mainEntity→ references the Product@id.Product.mainEntityOfPage→ references the WebPage / canonical URL.- All extra attributes live under
Product.additionalPropertyasPropertyValueitems.(schema.org)
Step-by-step: creating the plugin
We’ll now build a self-contained plugin that:
- Hooks into WooCommerce’s
ProductJSON-LD and:- Sets a stable
@idfor the Product. - Adds
mainEntityOfPagepointing to the WebPage URL. - Appends an
additionalPropertyarray from Woo attributes.
- Sets a stable
- Hooks into Yoast SEO’s
WebPagepiece and addsmainEntityreferencing the Product@id.
1. Plugin file structure
Create a folder inside wp-content/plugins:
wp-content/
plugins/
wc-yoast-product-schema/
wc-yoast-product-schema.php
2. Paste this plugin code
<?php
/**
* Plugin Name: Woo + Yoast Product Schema Linker
* Description: Links WooCommerce Product schema to Yoast WebPage schema and exposes product attributes via additionalProperty/PropertyValue.
* Author: ZedKima.com
* Version: 1.0.0
* Requires PHP: 7.4
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class WC_Yoast_Product_Schema_Link {
public static function init() : void {
// Enrich WooCommerce Product schema.
add_filter( 'woocommerce_structured_data_product', [ __CLASS__, 'filter_product_schema' ], 10, 2 );
// Link Yoast WebPage piece -> Product piece.
add_filter( 'wpseo_schema_webpage', [ __CLASS__, 'filter_yoast_webpage' ], 10, 2 );
}
/**
* Add @id, mainEntityOfPage and additionalProperty to Woo's Product schema.
*
* @param array $markup
* @param WC_Product $product
* @return array
*/
public static function filter_product_schema( $markup, $product ) : array {
if ( ! $product instanceof WC_Product ) {
return $markup;
}
// Stable Product @id and page URL.
$page_url = get_permalink( $product->get_id() );
$product_id = trailingslashit( $page_url ) . '#product';
if ( empty( $markup['@id'] ) ) {
$markup['@id'] = $product_id;
}
// Product -> WebPage via mainEntityOfPage (WebPage is a CreativeWork / URL).
$markup['mainEntityOfPage'] = [
'@id' => $page_url,
];
// Build additionalProperty array from visible attributes.
$additional = self::build_additional_properties( $product );
if ( ! empty( $additional ) ) {
if ( isset( $markup['additionalProperty'] ) && is_array( $markup['additionalProperty'] ) ) {
$markup['additionalProperty'] = array_values( array_merge( $markup['additionalProperty'], $additional ) );
} else {
$markup['additionalProperty'] = $additional;
}
}
return $markup;
}
/**
* Make Yoast's WebPage schema reference the Product as mainEntity.
*
* @param array $webpage Yoast WebPage piece.
* @param \Yoast\WP\SEO\Generators\Schema\Context|null $context
* @return array
*/
public static function filter_yoast_webpage( array $webpage, $context = null ) : array {
if ( function_exists( 'is_product' ) && is_product() ) {
$product_url = get_permalink( get_queried_object_id() );
$webpage['mainEntity'] = [
'@id' => trailingslashit( $product_url ) . '#product',
];
}
return $webpage;
}
/**
* Convert WooCommerce product attributes to PropertyValue objects.
*
* @param WC_Product $product
* @return array[]
*/
protected static function build_additional_properties( WC_Product $product ) : array {
$additional = [];
// Only visible attributes.
$attributes = array_filter( $product->get_attributes(), 'wc_attributes_array_filter_visible' );
foreach ( $attributes as $attribute ) {
$label = $attribute->is_taxonomy()
? wc_attribute_label( $attribute->get_name(), $product )
: $attribute->get_name();
if ( ! $label ) {
continue;
}
// Get a human-readable value.
if ( $attribute->is_taxonomy() ) {
$terms = wc_get_product_terms(
$product->get_id(),
$attribute->get_name(),
[ 'fields' => 'names' ]
);
$values = array_map( 'wc_clean', $terms );
} else {
$values = array_map( 'wc_clean', $attribute->get_options() );
}
$value_text = trim( implode( ', ', array_filter( $values ) ) );
$value_text = str_replace( "\xC2\xA0", ' ', $value_text ); // Replace
if ( $value_text === '' || $value_text === '-' ) {
continue;
}
$item = [
'@type' => 'PropertyValue',
'name' => wp_strip_all_tags( $label ),
];
// Try to parse "160 mm", "34.80 kg", "88 cm²" into number + unitCode.
$parsed = self::parse_numeric_with_unit( $value_text );
if ( $parsed['is_numeric'] ) {
$item['value'] = $parsed['number'];
if ( $parsed['unitCode'] ) {
$item['unitCode'] = $parsed['unitCode']; // UN/CEFACT
} elseif ( $parsed['unitText'] ) {
$item['unitText'] = $parsed['unitText']; // human-readable fallback
}
} else {
// Keep complex ranges or multi-values as text.
$item['value'] = $value_text;
}
$additional[] = $item;
}
return $additional;
}
/**
* Parse "34.80 kg", "160 mm", "88 cm²", "100", "5 %", etc.
*
* @param string $text
* @return array{is_numeric:bool,number:?float,unitCode:?string,unitText:?string}
*/
protected static function parse_numeric_with_unit( string $text ) : array {
$out = [
'is_numeric' => false,
'number' => null,
'unitCode' => null,
'unitText' => null,
];
// Single number with optional unit token.
if ( preg_match( '/^\s*([\-+]?[0-9]+(?:[.,][0-9]+)?)\s*([a-zA-Zµ°%]+|cm2|cm²|m2|m²|cm3|cm³|m3|m³)?\s*$/u', $text, $m ) ) {
$num = str_replace( ',', '.', $m[1] );
if ( is_numeric( $num ) ) {
$out['is_numeric'] = true;
$out['number'] = (float) $num;
$token = isset( $m[2] ) ? mb_strtolower( $m[2] ) : '';
// Normalise superscripts and µ.
$token = strtr(
$token,
[
'cm²' => 'cm2',
'm²' => 'm2',
'cm³' => 'cm3',
'm³' => 'm3',
'µm' => 'um',
]
);
if ( $token !== '' ) {
// Basic UN/CEFACT mapping for common units.
$map = [
'mm' => 'MMT',
'cm' => 'CMT',
'm' => 'MTR',
'kg' => 'KGM',
'g' => 'GRM',
'l' => 'LTR',
'cm2' => 'CMK',
'm2' => 'MTK',
'cm3' => 'CMQ',
'm3' => 'MTQ',
'%' => 'P1', // percent
'°c' => 'CEL', // degree Celsius
'um' => 'UMT', // micrometre
];
if ( isset( $map[ $token ] ) ) {
$out['unitCode'] = $map[ $token ];
}
$out['unitText'] = $token;
}
}
}
return $out;
}
}
WC_Yoast_Product_Schema_Link::init();
Activate the plugin in Plugins → Installed Plugins and visit any single product page.
You should now see:
- The Yoast
<script type="application/ld+json" class="yoast-schema-graph">block with aWebPagethat has:"mainEntity": { "@id": "https://example.com/product/your-product/#product" } - A separate WooCommerce
<script type="application/ld+json">block with yourProductnode that has:"@id": "https://example.com/product/your-product/#product", "mainEntityOfPage": { "@id": "https://example.com/product/your-product/" }, "additionalProperty": [ ...PropertyValue items... ]
Exactly like in the conceptual example above.
How additionalProperty + PropertyValue works
Schema.org describes PropertyValue as a generic type for property–value pairs, and additionalProperty as a way to attach such pairs to any entity (including Product).
In our plugin:
- Each visible WooCommerce attribute becomes a
PropertyValuenode. - The attribute label becomes
name. - The attribute value becomes:
- either a number with
"unitCode"(when we can parse “160 mm”, “34.8 kg”, etc.), in line with Schema.org’s recommendations for numeric values,(schema.org) - or plain
"value"text when parsing doesn’t make sense (ranges like “1000 x 160 x 160 mm”, load classes like “A 15 – F 900”, etc.).
- either a number with
This gives search engines and LLMs a rich, strongly typed view of your catalogue without you having to fight the schema for every single physical property.
Linking the graphs the “Yoast way”
Yoast’s Schema API is designed to let you plug your own entities into their graph. Their docs explain the pattern with examples using Book and other types.
We’re following that same pattern:
- On product pages, the plugin makes Yoast’s
WebPagepiece say:$webpage['mainEntity'] = [ '@id' => trailingslashit( get_permalink( get_queried_object_id() ) ) . '#product', ]; - On the WooCommerce side, the plugin makes the
Productpiece say:$markup['mainEntityOfPage'] = [ '@id' => get_permalink( $product->get_id() ) ];
Together that creates a two-way link between WebPage and Product that matches both Schema.org’s model and Yoast’s own integration guidelines.
Testing and debugging
Before shipping this to production, run a few checks:
- Google’s Rich Results Test
Paste a product URL and confirm:- You have a
Productitem.No errors for “mainEntityOfPage” target type.Your extra attributes appear underadditionalProperty.
- You have a
- Google Search Console / Merchant Center
For bigger catalogues, keep an eye on the Enhancements → Product reports and/or Merchant Center diagnostics for structured data-related issues.(support.google.com) - Yoast Schema debugging
Yoast offers a “development mode” that pretty-prints the Schema JSON so it’s easier to read while you’re working on it:add_filter( 'yoast_seo_development_mode', '__return_true' );Don’t leave this on in production — it adds overhead.
Advanced custom templates (like ours)
If you’re using a custom single-product template where you don’t call the normal woocommerce_single_product_summary hook, WooCommerce might not print its Product JSON-LD at all.
In that case, you can:
if ( function_exists( 'WC' ) && isset( WC()->structured_data ) ) {
WC()->structured_data->generate_product_data();
}
…in a place you control (for example, in a custom action your template triggers). That’s exactly what we do on our own site before applying the filters above.
Wrap-up
With a relatively small amount of code, we have accomplished our goal which it is → Link WooCommerce & Yoast SEO Product Schema
- Enriched WooCommerce’s Product schema with all those critical engineering attributes your customers care about.
- Linked the Product node into Yoast’s Schema graph so search engines can clearly see which product a page is about.
- Packaged it as a plugin that you (and other developers) can install, tweak and extend.
You can now add domain-specific semantics to your shop without fighting the default Woo/Yoast behavior — and give both Google and modern LLMs exactly the structured data they need to understand your products.


