Xenios WooCommerce Product Schema
Outputs clean Product JSON-LD schema for WooCommerce product pages.
by Kostas Chasiotis · github.com/kostaschasiotis/xenios-woocommerce-product-schema
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/kostaschasiotis/xenios-woocommerce-product-schema/archive/refs/heads/main.zipA lightweight WordPress plugin that outputs clean JSON-LD Product schema for WooCommerce product pages.
The plugin is intentionally focused: it outputs Product schema for simple WooCommerce products and ProductGroup schema for variable WooCommerce products. It does not try to become a full schema framework and it does not remove or modify schema generated by other plugins.
Purpose
Xenios WooCommerce Product Schema is a standalone spin-off from the broader Xenios Schema work.
Its goal is to provide a focused WooCommerce Product schema layer that is:
- simple to install
- predictable in output
- friendly to site owners
- easy for developers to extend with WordPress filters
- compatible with Google product structured data expectations
Plugin path
Recommended plugin path:
wp-content/plugins/xenios-woocommerce-product-schema/
Main plugin file:
wp-content/plugins/xenios-woocommerce-product-schema/xenios-woocommerce-product-schema.php
Plugin header:
/**
* Plugin Name: Xenios WooCommerce Product Schema
* Description: Outputs clean Product JSON-LD schema for WooCommerce product pages.
* Version: 1.3.0
* Author: Kostas Chasiotis
* Author URI: https://kostaschasiotis.com
* Text Domain: xenios-woocommerce-product-schema
*/
File structure
The plugin uses a small class-based structure with a lightweight namespace autoloader.
xenios-woocommerce-product-schema/
├── xenios-woocommerce-product-schema.php
├── uninstall.php
├── includes/
│ ├── AdminNotice.php
│ ├── Plugin.php
│ ├── SchemaBuilder.php
│ ├── Admin/
│ │ └── AttributeMappingsPage.php
│ ├── Schema/
│ │ ├── AggregateRating.php
│ │ ├── Brand.php
│ │ ├── Offer.php
│ │ ├── Product.php
│ │ ├── ProductGroup.php
│ │ └── PropertyValue.php
│ └── Support/
│ ├── Attributes.php
│ ├── Gtin.php
│ ├── Images.php
│ ├── Text.php
│ └── VariationUrl.php
└── readme.md
Naming convention
Classes inside includes/Schema/ are named after the Schema.org type they build.
Schema/Product.php -> Product
Schema/ProductGroup.php -> ProductGroup
Schema/Offer.php -> Offer
Schema/Brand.php -> Brand
Schema/AggregateRating.php -> AggregateRating
Schema/PropertyValue.php -> PropertyValue
The plugin does not use a separate VariationProduct class because Schema.org does not have a ProductVariant type. WooCommerce variations are output as normal Schema.org Product nodes inside ProductGroup.hasVariant, so that logic lives in Schema/Product.php.
Each Schema class includes a comment block with:
- the official Schema.org URL
- the official Schema.org description
Example:
/**
* Product
*
* Schema.org reference:
* https://schema.org/Product
*
* Description:
* Any offered product or service. For example: a pair of shoes; a concert ticket;
* the rental of a car; a haircut; or an episode of a TV show streamed online.
*/
Core behavior
The plugin outputs JSON-LD on:
wp_head
Only when the current page is a WooCommerce single product page:
is_product()
It gets the current product with:
wc_get_product(get_queried_object_id())
If WooCommerce is unavailable, the current page is not a product page, or no valid WC_Product is found, the plugin outputs nothing.
Output can also be disabled conditionally with the xenios_wc_product_schema_output_enabled filter.
Schema output strategy
Simple products
Simple WooCommerce products output Schema.org Product.
{
"@context": "https://schema.org",
"@type": "Product"
}
When available, the Product schema includes:
@idnameurldescriptionskugtin,gtin8,gtin12,gtin13, orgtin14imagebrandoffersaggregateRating
Variable products
Variable WooCommerce products output Schema.org ProductGroup.
{
"@context": "https://schema.org",
"@type": "ProductGroup"
}
The parent variable product becomes the ProductGroup.
Each visible WooCommerce variation becomes a nested Schema.org Product inside:
"hasVariant": []
The ProductGroup includes, when available:
@idnameurlproductGroupIDdescriptionskugtin,gtin8,gtin12,gtin13, orgtin14imagebrandaggregateRatingvariesByhasVariant
Each variation Product includes, when available:
@type: Product@idnameurlisVariantOfskugtin,gtin8,gtin12,gtin13, orgtin14imagebrand- mapped variation attributes
additionalPropertyoffers
Final rule:
Simple product -> Product
Variable product -> ProductGroup
Variation -> Product inside ProductGroup.hasVariant
Entity IDs
The plugin adds @id values to schema entities so that related nodes can reference each other clearly.
Example ProductGroup ID:
"@id": "https://example.com/product/example/#product-group"
Example variant Product ID:
"@id": "https://example.com/product/example/?attribute_pa_size=large#variant-123"
Example variation relationship:
"isVariantOf": {
"@type": "ProductGroup",
"@id": "https://example.com/product/example/#product-group",
"name": "Example Product",
"productGroupID": "456"
}
Important: variation URLs may include selected variation attributes, but isVariantOf.@id always points back to the parent ProductGroup ID.
Variation URLs
Variation Product nodes use URLs with selected attributes appended as query parameters.
Example:
/product/example/?attribute_pa_size=large&attribute_pa_color=black
This is handled by:
includes/Support/VariationUrl.php
WooCommerce variation attributes are normalized to the expected query parameter format:
pa_size -> attribute_pa_size
pa_color -> attribute_pa_color
Variation Product.url, variation Product.@id, and variation Offer.url should use the selected variation URL.
Attribute mapping for variants
The plugin supports deterministic mapping of WooCommerce product attributes to Schema.org Product properties.
Attribute mappings are managed in:
WooCommerce -> Product Schema
The settings page lists global WooCommerce attributes and allows the site owner to map each one to a supported Schema.org Product property.
Supported mapping targets:
Do not map
color
size
material
pattern
The mapping is stored in a single WordPress option:
xenios_wc_product_schema_attribute_mappings
This option is removed on plugin uninstall.
Mapping priority
Attribute mapping follows this order:
Saved admin mapping -> default plugin mapping -> additionalProperty
Default plugin mappings:
pa_color / color / colour -> color
pa_size / size -> size
pa_material / material -> material
pa_pattern / pattern -> pattern
Example saved mapping:
pa_ring-size-for-men -> size
This produces:
"size": "60"
If an attribute is not mapped, it remains as additionalProperty:
"additionalProperty": [
{
"@type": "PropertyValue",
"name": "Ring Size for Men",
"value": "60"
}
]
variesBy
For variable products, the plugin outputs variesBy when a variation attribute can be mapped to a known Schema.org Product property.
Example:
"variesBy": [
"https://schema.org/size",
"https://schema.org/color"
]
The same saved attribute mappings used for variation Product properties are also used for ProductGroup.variesBy.
Example:
pa_ring-size-for-men -> size
Produces:
"variesBy": [
"https://schema.org/size"
]
Offers
Simple products and variation Products receive an Offer when a price exists.
Offer includes:
@type: OfferurlpricepriceCurrencyavailabilityitemConditionpriceValidUntil, when available
Variable parent products intentionally do not receive a parent-level Offer or AggregateOffer. Instead, each variation receives its own offer.
Final offer rule:
Simple Product -> Offer
ProductGroup -> no parent Offer
Variant Product -> Offer
This keeps pricing precise for variable products, especially when variations have different prices, availability, or sale dates.
Availability mapping
WooCommerce stock status is mapped as:
Backorder -> https://schema.org/PreOrder
In stock -> https://schema.org/InStock
Out of stock -> https://schema.org/OutOfStock
Price validity
The plugin adds:
"priceValidUntil"
when WooCommerce has a sale end date:
$product->get_date_on_sale_to()
The value is formatted as:
Y-m-d
GTIN support
The plugin supports WooCommerce’s native global unique ID field when available.
It checks:
$product->get_global_unique_id()
Fallback meta key:
_global_unique_id
The value is mapped by digit length:
8 digits -> gtin8
12 digits -> gtin12
13 digits -> gtin13
14 digits -> gtin14
Other -> gtin
Non-numeric values fall back to:
"gtin"
GTIN output can be modified with:
xenios_wc_product_schema_gtin
SKU support
The plugin uses WooCommerce SKU when available:
$product->get_sku()
For variations, if the variation has no SKU, the plugin falls back to the parent product SKU.
Brand support
WooCommerce brands are supported through product taxonomies.
The plugin checks:
product_brand
product_brands
If a brand term exists, it outputs:
"brand": {
"@type": "Brand",
"name": "Brand Name"
}
There is also a filter fallback:
xenios_wc_product_schema_brand
The filter may return:
- a string brand name
- a full Brand schema array
null
Images
The plugin uses:
- featured product image
- product gallery images
For variations:
- variation image
- parent product images as fallback
All images are output as full-size URLs.
Image output can be modified with:
xenios_wc_product_schema_images
Ratings
The plugin outputs aggregate ratings only, not individual Review nodes.
It uses:
$product->get_average_rating()
$product->get_review_count()
It outputs:
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 5,
"reviewCount": 10
}
Only when the rating is greater than zero and the review count is at least one.
Individual Review nodes are intentionally not generated unless reviews are visibly rendered on the product page.
Duplicate schema handling
The plugin does not remove, disable, or alter schema generated by other plugins.
This is intentional.
The site owner is responsible for disabling duplicate Product schema from SEO/schema plugins if needed.
The plugin only displays an admin notice when common SEO/schema plugins are active.
Currently detected plugins:
Rank Math SEO
Yoast SEO
SEOPress
AIOSEO
Detection is based on constants:
RANK_MATH_VERSION
WPSEO_VERSION
SEOPRESS_VERSION
AIOSEO_VERSION
Developer filters
The plugin includes WordPress filters as developer extension points.
The plugin builds its default schema first, then applies filters at the end of each relevant build step. This means future plugin functionality is inherited automatically unless a developer intentionally changes or removes it through a filter.
Filter summary
xenios_wc_product_schema_output_enabled
xenios_wc_product_schema_product
xenios_wc_product_schema_product_group
xenios_wc_product_schema_variant_product
xenios_wc_product_schema_offer
xenios_wc_product_schema_brand
xenios_wc_product_schema_images
xenios_wc_product_schema_gtin
xenios_wc_product_schema_output_enabled
Controls whether the plugin outputs schema for the current product.
Runs before schema is built.
apply_filters(
'xenios_wc_product_schema_output_enabled',
true,
$product
);
Arguments:
| Argument | Type | Description |
|---|---|---|
$enabled |
bool |
Whether schema output is enabled. Default true. |
$product |
WC_Product |
Current WooCommerce product. |
Expected return:
bool
Example: disable schema for one product.
add_filter('xenios_wc_product_schema_output_enabled', function (bool $enabled, WC_Product $product): bool {
return $product->get_id() !== 123;
}, 10, 2);
Example: disable schema for products in a category.
add_filter('xenios_wc_product_schema_output_enabled', function (bool $enabled, WC_Product $product): bool {
if (has_term('private-products', 'product_cat', $product->get_id())) {
return false;
}
return $enabled;
}, 10, 2);
xenios_wc_product_schema_product
Filters the final Schema.org Product array for simple products.
Runs after the default simple Product schema has been built.
apply_filters(
'xenios_wc_product_schema_product',
$schema,
$product
);
Arguments:
| Argument | Type | Description |
|---|---|---|
$schema |
array<string, mixed> |
Final Product schema array. |
$product |
WC_Product |
WooCommerce product. |
Expected return:
array<string, mixed>
Example: add a custom property.
add_filter('xenios_wc_product_schema_product', function (array $schema, WC_Product $product): array {
$customValue = get_post_meta($product->get_id(), '_custom_schema_value', true);
if (is_string($customValue) && $customValue !== '') {
$schema['additionalProperty'][] = [
'@type' => 'PropertyValue',
'name' => 'Custom Value',
'value' => $customValue,
];
}
return $schema;
}, 10, 2);
Example: add MPN manually if a shop stores it in custom meta.
add_filter('xenios_wc_product_schema_product', function (array $schema, WC_Product $product): array {
$mpn = get_post_meta($product->get_id(), '_mpn', true);
if (is_string($mpn) && $mpn !== '') {
$schema['mpn'] = $mpn;
}
return $schema;
}, 10, 2);
xenios_wc_product_schema_product_group
Filters the final Schema.org ProductGroup array for variable products.
Runs after the default ProductGroup schema has been built, including hasVariant.
apply_filters(
'xenios_wc_product_schema_product_group',
$schema,
$product
);
Arguments:
| Argument | Type | Description |
|---|---|---|
$schema |
array<string, mixed> |
Final ProductGroup schema array. |
$product |
WC_Product_Variable |
WooCommerce variable product. |
Expected return:
array<string, mixed>
Example: override productGroupID.
add_filter('xenios_wc_product_schema_product_group', function (array $schema, WC_Product_Variable $product): array {
$schema['productGroupID'] = 'group-' . $product->get_id();
return $schema;
}, 10, 2);
Example: remove aggregate rating from ProductGroup only.
add_filter('xenios_wc_product_schema_product_group', function (array $schema, WC_Product_Variable $product): array {
unset($schema['aggregateRating']);
return $schema;
}, 10, 2);
xenios_wc_product_schema_variant_product
Filters the final Schema.org Product array for a WooCommerce variation.
Runs after the variation Product schema has been built, including isVariantOf, mapped attributes, images, brand, GTIN, SKU, and offer.
apply_filters(
'xenios_wc_product_schema_variant_product',
$variant,
$variation,
$parent
);
Arguments:
| Argument | Type | Description |
|---|---|---|
$variant |
array<string, mixed> |
Final variation Product schema array. |
$variation |
WC_Product_Variation |
WooCommerce variation. |
$parent |
WC_Product_Variable |
Parent WooCommerce variable product. |
Expected return:
array<string, mixed>
Example: add custom variation metadata.
add_filter(
'xenios_wc_product_schema_variant_product',
function (array $variant, WC_Product_Variation $variation, WC_Product_Variable $parent): array {
$customCode = get_post_meta($variation->get_id(), '_custom_code', true);
if (is_string($customCode) && $customCode !== '') {
$variant['additionalProperty'][] = [
'@type' => 'PropertyValue',
'name' => 'Custom Code',
'value' => $customCode,
];
}
return $variant;
},
10,
3
);
Example: remove offers from variants for a specific parent product.
add_filter(
'xenios_wc_product_schema_variant_product',
function (array $variant, WC_Product_Variation $variation, WC_Product_Variable $parent): array {
if ($parent->get_id() === 123) {
unset($variant['offers']);
}
return $variant;
},
10,
3
);
xenios_wc_product_schema_offer
Filters the final Schema.org Offer array for simple products and variation products.
Runs after the default Offer schema has been built.
apply_filters(
'xenios_wc_product_schema_offer',
$offer,
$product
);
Arguments:
| Argument | Type | Description |
|---|---|---|
$offer |
array<string, mixed> |
Final Offer schema array. |
$product |
WC_Product |
WooCommerce product or variation the offer belongs to. |
Expected return:
array<string, mixed>
Example: override price validity.
add_filter('xenios_wc_product_schema_offer', function (array $offer, WC_Product $product): array {
$offer['priceValidUntil'] = '2026-12-31';
return $offer;
}, 10, 2);
Example: add return policy manually.
add_filter('xenios_wc_product_schema_offer', function (array $offer, WC_Product $product): array {
$offer['hasMerchantReturnPolicy'] = [
'@type' => 'MerchantReturnPolicy',
'applicableCountry' => 'GR',
'returnPolicyCategory' => 'https://schema.org/MerchantReturnFiniteReturnWindow',
'merchantReturnDays' => 14,
'returnMethod' => 'https://schema.org/ReturnByMail',
'returnFees' => 'https://schema.org/FreeReturn',
];
return $offer;
}, 10, 2);
Example: add shipping details manually.
add_filter('xenios_wc_product_schema_offer', function (array $offer, WC_Product $product): array {
$offer['shippingDetails'] = [
'@type' => 'OfferShippingDetails',
'shippingDestination' => [
'@type' => 'DefinedRegion',
'addressCountry' => 'GR',
],
'shippingRate' => [
'@type' => 'MonetaryAmount',
'value' => 5,
'currency' => get_woocommerce_currency(),
],
];
return $offer;
}, 10, 2);
Note: shipping and return policy output is intentionally not generated automatically in v1 because WooCommerce shipping rules can be highly conditional.
xenios_wc_product_schema_brand
Filters or provides the Product brand value.
Runs after the plugin checks the supported brand taxonomies. If no brand taxonomy term is found, this filter can provide a fallback.
apply_filters(
'xenios_wc_product_schema_brand',
null,
$product
);
Arguments:
| Argument | Type | Description |
|---|---|---|
$brand |
array<string, mixed>|string|null |
Current brand value. Usually null when used as fallback. |
$product |
WC_Product |
WooCommerce product. |
Expected return:
array<string, mixed>|string|null
Accepted return values:
nullto output no brand- string brand name
- full Brand schema array
Example: provide a brand name manually.
add_filter('xenios_wc_product_schema_brand', function ($brand, WC_Product $product) {
return 'Custom Brand Name';
}, 10, 2);
Example: return a full Brand object.
add_filter('xenios_wc_product_schema_brand', function ($brand, WC_Product $product): array {
return [
'@type' => 'Brand',
'name' => 'Custom Brand Name',
];
}, 10, 2);
Example: use a custom taxonomy as brand fallback.
add_filter('xenios_wc_product_schema_brand', function ($brand, WC_Product $product) {
$terms = get_the_terms($product->get_id(), 'designer');
if (empty($terms) || is_wp_error($terms)) {
return $brand;
}
$term = reset($terms);
if (! $term instanceof WP_Term) {
return $brand;
}
return [
'@type' => 'Brand',
'name' => $term->name,
];
}, 10, 2);
xenios_wc_product_schema_images
Filters the product image URLs before they are added to schema.
Runs after the plugin collects the featured image and gallery images.
apply_filters(
'xenios_wc_product_schema_images',
$images,
$product
);
Arguments:
| Argument | Type | Description |
|---|---|---|
$images |
array<int, string> |
Full-size image URLs. |
$product |
WC_Product |
WooCommerce product or variation. |
Expected return:
array<int, string>
Example: prepend a custom schema image from post meta.
add_filter('xenios_wc_product_schema_images', function (array $images, WC_Product $product): array {
$customImage = get_post_meta($product->get_id(), '_schema_image', true);
if (is_string($customImage) && $customImage !== '') {
array_unshift($images, $customImage);
}
return array_values(array_unique($images));
}, 10, 2);
Example: remove gallery images and keep only the first image.
add_filter('xenios_wc_product_schema_images', function (array $images, WC_Product $product): array {
return array_slice($images, 0, 1);
}, 10, 2);
xenios_wc_product_schema_gtin
Filters or provides the GTIN result before it is added to schema.
The filter receives either:
null, when no GTIN was found- an array with
propertyandvalue, when a GTIN was found
apply_filters(
'xenios_wc_product_schema_gtin',
$gtin,
$product
);
Arguments:
| Argument | Type | Description |
|---|---|---|
$gtin |
array{property: string, value: string}|null |
GTIN result. |
$product |
WC_Product |
WooCommerce product or variation. |
Expected return:
array{property: string, value: string}|null
Valid property values:
gtin
gtin8
gtin12
gtin13
gtin14
Example: provide GTIN manually from custom meta.
add_filter('xenios_wc_product_schema_gtin', function (?array $gtin, WC_Product $product): ?array {
$customGtin = get_post_meta($product->get_id(), '_custom_gtin', true);
if (! is_string($customGtin) || $customGtin === '') {
return $gtin;
}
return [
'property' => 'gtin13',
'value' => $customGtin,
];
}, 10, 2);
Example: remove GTIN for one product.
add_filter('xenios_wc_product_schema_gtin', function (?array $gtin, WC_Product $product): ?array {
if ($product->get_id() === 123) {
return null;
}
return $gtin;
}, 10, 2);
Public support methods used internally
The current attribute helper keeps these public methods for consistency across schema classes:
Attributes::addToVariant(array &$variant, WC_Product_Variation $variation): void
Attributes::variesBy(WC_Product_Variable $product): array
Attributes::schemaProperty(string $attributeName): ?string
Attributes::valueLabel(string $attributeName, mixed $attributeValue): string
These are internal support methods, not formal public APIs. Developers should prefer WordPress filters for customization.
Uninstall behavior
On uninstall, the plugin removes only its own option:
xenios_wc_product_schema_attribute_mappings
No product data, WooCommerce data, schema generated by other plugins, or unrelated WordPress options are modified.
Design principles
- Keep it simple.
- Activate and go.
- Keep settings minimal and only when needed.
- No destructive behavior.
- No automatic removal of other schemas.
- Prefer WooCommerce native data.
- Avoid custom fields unless handled through filters.
- Output only valid, available data.
- Keep Google-compatible product structured data in mind.
Intentionally not implemented in v1
Parent-level ProductGroup offers
Variable parent products do not receive a parent-level Offer or AggregateOffer.
Reason: variation pricing, availability, and sale dates can differ. Variant-level offers are more precise.
Shipping and returns
Shipping and return structured data is not generated automatically in v1.
Reason: WooCommerce shipping and return rules can vary heavily by shop and may depend on destination, cart contents, weight, shipping class, carrier APIs, plugins, coupons, and other conditional logic.
For now, developers can add shippingDetails or hasMerchantReturnPolicy through:
xenios_wc_product_schema_offer
This area is deferred to v2.
MPN support
MPN is not implemented by default because WooCommerce core does not provide a universal native MPN field.
The plugin already supports:
- WooCommerce SKU
- WooCommerce native global unique ID / GTIN
- GTIN fallback meta
- GTIN filter
Developers can add MPN through:
xenios_wc_product_schema_product
xenios_wc_product_schema_variant_product
Current project status
The plugin currently supports:
Simple WooCommerce products as Product
Variable WooCommerce products as ProductGroup
WooCommerce variations as Product nodes inside ProductGroup.hasVariant
Variation URLs with selected attribute query parameters
Correct isVariantOf relationship back to the parent ProductGroup
No parent-level ProductGroup Offer by design
Native WooCommerce SKU
Native WooCommerce GTIN/global unique ID
GTIN override filter
Native WooCommerce brand taxonomies
Brand override filter
Featured and gallery images
Image override filter
Sale end date as priceValidUntil
Aggregate ratings
Duplicate schema warning notice
Admin attribute mapping page
Single stored option for attribute mappings
Uninstall cleanup for plugin-owned option
Developer filters for Product, ProductGroup, variant Product, Offer, images, GTIN, brand, and output control
Final validation checklist
Before tagging a release, test:
- one simple product in Google Rich Results Test
- one variable product in Google Rich Results Test
- one simple product in Schema Markup Validator
- one variable product in Schema Markup Validator
- source output for duplicate Product schema from SEO/schema plugins
- attribute mapping page save/update behavior
- uninstall removes
xenios_wc_product_schema_attribute_mappings
Releases
1 release.
These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.