Structured data mastery

Product schema for Adobe Commerce (Magento)

The catalog model is different enough to need its own playbook. Module patterns, the EAV trap, headless rendering, and PWA Studio considerations.

9 min read Updated May 1, 2026

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.

catalog_product_entity

eav_attribute

product attribute
values across
4 value tables

Block / module
loads attributes

JSON-LD output

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:

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

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

  1. Scaffold the custom module structure shown above. Wire the block, template, and layout XML.
  2. For configurable products, add the variant-detection logic.
  3. Decide on rendering path: Luma → done. Hyvä → adapt to Tailwind override pattern. PWA Studio → enable SSR or prerendering.
  4. Flush full-page cache after deploy.
  5. 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.