Adobe Commerce — the platform formerly known as Magento — has the most powerful catalog model of any major commerce platform and the weakest default schema output. The default Luma theme ships almost no JSON-LD; Hyvä ships some; PWA Studio storefronts ship none. The work isn’t extending an existing schema layer (like Shopify or WooCommerce); it’s building one.
This guide walks through the Adobe Commerce catalog model, the module pattern for adding schema, and the rendering paths for traditional Luma, Hyvä, and PWA Studio architectures.
The EAV catalog model
Adobe Commerce stores product attributes in an Entity-Attribute-Value
(EAV) model. Every product attribute — name, sku, description,
custom attributes — is a separate row in eav_attribute joined to
catalog_product_entity_* value tables.
This is powerful (any attribute can be added without altering tables) and unusual (other platforms use a flat row-per-product model). For schema, the implication is that the renderer has to load attributes explicitly. There’s no “product object with all properties” in the sense Shopify or BigCommerce expose.
The four value tables (catalog_product_entity_int,
catalog_product_entity_varchar, catalog_product_entity_text,
catalog_product_entity_decimal) store values by data type. The
schema layer needs to know which attribute lives in which table.
The module pattern
The standard Adobe Commerce extension pattern: create a custom module that adds a layout XML update for product pages, registers a block that loads the product’s attributes, and renders a JSON-LD template.
Skeleton structure:
app/code/Vendor/AiSchema/
├── etc/
│ ├── module.xml
│ └── frontend/
│ └── di.xml
│ └── routes.xml
├── view/
│ └── frontend/
│ ├── layout/
│ │ └── catalog_product_view.xml
│ └── templates/
│ └── product/jsonld.phtml
└── Block/
└── Product/
└── JsonLd.php
The block class loads the product registry, pulls attributes by code, and exposes them to the template:
namespace Vendor\AiSchema\Block\Product;
use Magento\Framework\Registry;
use Magento\Framework\View\Element\Template;
class JsonLd extends Template
{
private Registry $registry;
public function __construct(Template\Context $context, Registry $registry, array $data = [])
{
$this->registry = $registry;
parent::__construct($context, $data);
}
public function getProduct()
{
return $this->registry->registry('current_product');
}
public function getJsonLd(): array
{
$product = $this->getProduct();
if (!$product) return [];
return [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $product->getName(),
'description' => $product->getDescription(),
'sku' => $product->getSku(),
'image' => $product->getMediaGalleryImages()->getFirstItem()->getUrl(),
'brand' => [
'@type' => 'Brand',
'name' => $product->getAttributeText('manufacturer') ?: '',
],
'gtin13' => $product->getData('gtin') ?: null,
'category' => $product->getAttributeText('google_product_category') ?: null,
'offers' => [
'@type' => 'Offer',
'url' => $product->getProductUrl(),
'price' => $product->getFinalPrice(),
'priceCurrency' => $this->_storeManager->getStore()->getCurrentCurrencyCode(),
'availability' => $product->isAvailable()
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
],
];
}
}
The template wraps the array in a JSON-LD script tag:
<?php
$jsonLd = $block->getJsonLd();
if (!empty($jsonLd)):
?>
<script type="application/ld+json"><?= json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>
<?php endif; ?>
The layout XML wires the block to the product page:
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<body>
<referenceContainer name="head.additional">
<block class="Vendor\AiSchema\Block\Product\JsonLd"
name="ai.schema.product"
template="Vendor_AiSchema::product/jsonld.phtml"/>
</referenceContainer>
</body>
</page>
Configurable products
Adobe Commerce’s variant model is “configurable products” — a parent SKU with multiple “simple products” as children. The parent has the configurable attributes (size, color); the simples have the unique SKUs and inventory.
For schema:
- Parent product page: render the parent with
hasVariantarray enumerating the simples. - Simple-as-direct-URL: each simple has its own URL when SEO settings enable variant URLs. Render the simple’s schema directly.
The block class needs to detect product type:
public function getJsonLd(): array
{
$product = $this->getProduct();
if ($product->getTypeId() === 'configurable') {
return $this->buildConfigurableSchema($product);
}
return $this->buildSimpleSchema($product);
}
private function buildConfigurableSchema($product): array
{
$children = $product->getTypeInstance()->getUsedProducts($product);
$variants = array_map(fn($child) => [
'@type' => 'Product',
'sku' => $child->getSku(),
'gtin13' => $child->getData('gtin') ?: null,
'offers' => [
'@type' => 'Offer',
'price' => $child->getFinalPrice(),
'availability' => $child->isAvailable()
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
],
], $children);
$base = $this->buildSimpleSchema($product);
$base['hasVariant'] = $variants;
return $base;
}
Reviews and aggregateRating
Adobe Commerce stores reviews in a separate review table; the
review summary is not on the product object by default. The block
class has to load it explicitly through
Magento\Review\Model\ResourceModel\Review\Summary, the standard
collection for fetching the aggregate rating value and review count
for a given product and store.
Extend the existing Vendor\AiSchema\Block\Product\JsonLd class with
a getReviewSummary() method and merge the result into the JSON-LD
output only when the product has real reviews. Faking ratings is a
Google Merchant Center policy violation
and against Bing’s webmaster guidelines. The defensible pattern is to
omit aggregateRating entirely for products without reviews rather
than populating zeros or aspirational values.
namespace Vendor\AiSchema\Block\Product;
use Magento\Review\Model\ResourceModel\Review\SummaryFactory;
class JsonLd extends Template
{
private SummaryFactory $summaryFactory;
public function __construct(
Template\Context $context,
Registry $registry,
SummaryFactory $summaryFactory,
array $data = []
) {
$this->registry = $registry;
$this->summaryFactory = $summaryFactory;
parent::__construct($context, $data);
}
public function getReviewSummary(): ?array
{
$product = $this->getProduct();
if (!$product) return null;
$storeId = $this->_storeManager->getStore()->getId();
$summary = $this->summaryFactory->create();
$summary->load($product, $storeId);
$count = (int) $summary->getReviewsCount();
$rating = (float) $summary->getRatingSummary();
if ($count < 1) return null;
return [
'@type' => 'AggregateRating',
'ratingValue' => round($rating / 20, 1),
'reviewCount' => $count,
'bestRating' => 5,
'worstRating' => 1,
];
}
}
getRatingSummary() returns a 0–100 percentage; divide by 20 to get
the conventional 1–5 scale. In getJsonLd(), conditionally merge the
summary:
$aggregate = $this->getReviewSummary();
if ($aggregate !== null) {
$base['aggregateRating'] = $aggregate;
}
Google narrowed which review markup earns organic rich results
through 2023–2024. aggregateRating continues to be a documented
input to Google Merchant Center
and Bing’s structured-data layer.
Stripping it removes the catalog from those documented surfaces. See
Review and AggregateRating schema after Google’s 2024 changes
for the breakdown of what changed and which surfaces continue to read
the markup.
A common third-party reviews trap: Yotpo, Bazaarvoice, Trustpilot,
and Reviews.io ship Adobe Commerce extensions that inject their own
aggregateRating script. Combined with a custom module like the one
above (or with the third-party schema extensions some catalogs also
run), the page can end up with multiple duplicate aggregateRating
blocks. Validators flag duplicates; pick one source per catalog and
disable the others.
Q&A pairs as a sibling FAQPage block
If the catalog has product-specific Q&A content, FAQPage is its own
Schema.org type — render it as a second JSON-LD block alongside the
Product block. Don’t nest one inside the other; Schema.org models
them as peers.
In Adobe Commerce, the cleanest pattern is to add a custom EAV
attribute on the product (product_faq_pairs) that stores the Q&A
list as a serialized array. The block class adds a second method:
public function getFaqJsonLd(): array
{
$product = $this->getProduct();
$faq = $product->getData('product_faq_pairs');
if (empty($faq)) return [];
$entities = array_map(fn($pair) => [
'@type' => 'Question',
'name' => $pair['question'],
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $pair['answer'],
],
], $faq);
return [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $entities,
];
}
The template renders both blocks as siblings:
<?php
$jsonLd = $block->getJsonLd();
$faqJsonLd = $block->getFaqJsonLd();
?>
<?php if (!empty($jsonLd)): ?>
<script type="application/ld+json"><?= json_encode($jsonLd) ?></script>
<?php endif; ?>
<?php if (!empty($faqJsonLd)): ?>
<script type="application/ld+json"><?= json_encode($faqJsonLd) ?></script>
<?php endif; ?>
For larger catalogs, model the Q&A pairs as a separate content entity linked to the product. Adobe Commerce’s CMS module supports custom content types — gives per-FAQ versioning and reuse across product variants.
This is the rendering pattern. Note that testing in late 2025 suggests that AI agents may not consistently extract JSON-LD on direct page fetch; the value of structured Q&A markup appears to come through index and feed paths rather than agents reading the page directly.
Theme considerations
Three rendering paths in production Adobe Commerce stores, each with different schema implications:
Luma (default theme)
The traditional theme ships almost no JSON-LD by default. The module pattern above is the standard fix. Layout XML adds the block; schema renders server-side.
Hyvä
A modern Tailwind-based frontend. Hyvä themes ship a seo
component that emits some JSON-LD; coverage is better than Luma but
still incomplete. Hyvä-specific extensions add the missing properties
through Tailwind component overrides rather than layout XML — a
flatter pattern that matches how Hyvä works.
PWA Studio (headless)
A React-based PWA frontend. PWA Studio fetches product data through GraphQL and renders client-side. Schema must be rendered server-side (SSR) or it’s invisible to AI agents that don’t execute JavaScript.
The fix: enable Magento’s PWA Studio SSR option, OR move to a prerendering service (Rendertron, prerender.io) that returns rendered HTML to crawlers. Without one of these, PWA Studio storefronts ship to AI agents as empty product pages.
Where it breaks
- Cache invalidation. Adobe Commerce’s full-page cache (FPC) and
Varnish layer cache rendered HTML aggressively. Schema changes
require explicit cache flush via
bin/magento cache:flush full_page. - EAV attribute access from blocks. Custom attributes that aren’t
in the default product entity catalog need the attribute to be
loaded explicitly with
$product->load($product->getId())— performance trap if done inside a loop. - Multi-store setups. Adobe Commerce supports multiple websites
and store views. Schema needs to use the current store’s currency,
language, and product attribute values —
$this->_storeManagerrather than hardcoded values. - B2B Edition shared catalogs. B2B Edition’s shared catalogs can hide products or change pricing per customer group. The schema rendered for an anonymous visitor may differ from what a logged-in B2B customer sees; AI agents see the anonymous version.
The contrarian take
Most Adobe Commerce schema content recommends third-party extensions (MagePlaza, Mageworx, Amasty all sell schema modules). The extensions work, but they introduce dependencies that complicate upgrades and add features (FAQ schema, breadcrumb schema) most catalogs don’t need.
The simpler pattern: a 200-line custom module that does only product schema, version-controlled in your codebase, no third-party dependency. Adobe Commerce upgrades occasionally rename block classes or layout handles; a custom module is easier to audit and fix than a bought extension during a major version migration.
What to ship this week
- Scaffold the custom module structure shown above. Wire the block, template, and layout XML.
- For configurable products, add the variant-detection logic.
- Decide on rendering path: Luma → done. Hyvä → adapt to Tailwind override pattern. PWA Studio → enable SSR or prerendering.
- Flush full-page cache after deploy.
- Validate ten products through the Rich Results Test.
For a typical Adobe Commerce catalog (5,000–50,000 products), this is a 1–2 day module-development project. The time is mostly configuration and testing across product types, not the schema itself.