All posts
Ray Iyer
Ray Iyer
Co-founder, Anglera

Adding Product JSON-LD on OroCommerce — and keeping it in sync

How to add schema.org Product JSON-LD to OroCommerce PDPs via a layout update, map GTIN/SKU/brand/offers correctly, and keep the markup in sync with the page.

Adding Product JSON-LD on OroCommerce — and keeping it in sync

Once product data is enriched — real names, brand, identifiers, specs, availability — the remaining job is getting that data onto the rendered page in a form buyers and AI/search crawlers can both parse. OroCommerce ships its own Schema.org markup out of the box, but it's HTML Microdata, not JSON-LD, and it's driven by a fixed set of SEO settings rather than the full Product vocabulary distributors usually need (GTIN, MPN, aggregateRating). This guide covers how to add a proper JSON-LD Product block to the OroCommerce storefront PDP, which fields actually matter, and how to keep the markup from drifting away from what's rendered on the page.

What OroCommerce already renders

OroSEOBundle extends the Product entity with localized SEO Title, SEO Description, and SEO Keywords fields (stored as LocalizedFallbackValue collections), and it emits Schema.org Microdataitemprop/itemscope attributes — directly in the storefront HTML. Two related settings live under System → Configuration → Commerce → Guests → SEO (also configurable per organization or per website):

  • Disable Product Microdata Without Price — turns off the microdata block for products with no assigned price, since crawlers can flag priceless product markup as invalid.
  • Used Product Description Field — controls whether the Microdata description pulls from the long description, the SEO Meta Description, or the short description.

This native markup is useful but limited: no gtin, mpn, aggregateRating, or explicit availability/priceCurrency out of the box, and Google's own guidance is that JSON-LD is the preferred format for structured data even though Microdata is still parsed. Adding a JSON-LD block alongside (or in place of) the Microdata is the standard move — just make sure the two never disagree, since Google treats contradictory markup as low quality.

Fields that matter, and where they live in Oro

JSON-LD propertyOroCommerce source
nameProduct::names (localized ProductName collection) — entity.defaultName.string in Twig
skuProduct::sku (native field, always present)
brandProduct::brand — the Brand entity relation
gtin / gtin13 / mpnNot native. Create as custom Product Attributes
descriptionProduct::descriptions / shortDescriptions, or the SEO Meta Description field
imageProduct::images (ProductImage collection, filtered by image type)
offers.price, priceCurrencyResolved price from the active price list / price rule for the visitor's price list (PricingBundle)
offers.availabilityInventory status (In Stock / Out of Stock / Backorder) from InventoryBundle
aggregateRatingOnly if you have a reviews/ratings source actually rendered on the page

GTIN, UPC, EAN, and MPN are not system fields on Product — OroCommerce's EAV-based Product Attributes framework is where you add them. In the back-office, go to Products → Product Attributes → Create Attribute, choose the String type, set the field name (e.g., gtin), and on the following step enable Show On View under Storefront options so the value is exposed to the storefront rather than kept back-office-only. Then assign it to the relevant Attribute Family/Group so it appears on the products that need it. Once saved, the value is reachable in Twig the same way native fields are — entity.gtin — because Oro's extended-entity layer generates the accessor for you.

Rendering the JSON-LD through a layout update

OroCommerce storefront pages are composed through the Layout component, not by editing controller templates directly. The product view page route is oro_product_frontend_product_view. Layout updates for a single route live under a theme folder matching that route name:

src/Acme/Bundle/ThemeBundle/Resources/views/layouts/default/oro_product_frontend_product_view/layout_update.yml

Add a block (parented to head, alongside where Oro's own meta blocks for title/description already attach) and point it at a Twig file:

layout:
    actions:
        - '@add':
            id: product_json_ld
            parentId: head
            blockType: container
            options:
                attr:
                    class: 'product-json-ld'
        - '@setBlockTheme':
            themes: '@AcmeThemeBundle/layouts/default/oro_product_frontend_product_view/product_json_ld.html.twig'

Then define the widget block in the matching Twig file, reusing the exact same entity accessors the core PDP template uses (entity.sku, entity.defaultName.string, entity.brand, entity.images) so the JSON-LD is always sourced from the same data the visible page renders from, not a hand-copied duplicate:

{% block _product_json_ld_widget %}
    <script type="application/ld+json">
    {{ {
        "@context": "https://schema.org",
        "@type": "Product",
        "name": entity.defaultName.string,
        "sku": entity.sku,
        "gtin13": entity.gtin,
        "mpn": entity.mpn,
        "brand": { "@type": "Brand", "name": entity.brand ? entity.brand|oro_format_name(null, 'full') : null },
        "description": entity.defaultShortDescription.text,
        "image": product_image_url,
        "offers": {
            "@type": "Offer",
            "url": product_url,
            "priceCurrency": price_currency,
            "price": price_value,
            "availability": "https://schema.org/" ~ availability_schema
        }
    }|json_encode(constant('JSON_UNESCAPED_SLASHES') b-or constant('JSON_UNESCAPED_UNICODE'))|raw }}
    </script>
{% endblock %}

The exact variable names available inside a layout block template (whether the price/availability come through a dedicated data provider or need a small PHP block type) vary by Oro version — check the Symfony profiler's Layout tab on a live page to confirm what's already in context before wiring these up, rather than assuming a name.

Two OroCommerce-specific gotchas for distributors:

  • Guest pricing. Many B2B storefronts hide price until login (see Guests settings above). If an anonymous visitor — which is what Googlebot is — can't see a price, don't emit a placeholder offers.price; omit offers or the price fields rather than showing a number that won't match what a logged-in buyer sees.
  • Availability must track the same inventory flag the "Add to Cart" button uses, not a separately maintained value, or the two will eventually disagree.

Keeping it in sync

The single biggest risk with JSON-LD blocks is that they get written once and then drift from the page. Concretely:

  • Never hardcode strings into the Twig block — always pull from entity.* or the same price/inventory services the rest of the PDP layout uses.
  • If the page is behind full-page or CDN caching, the JSON-LD block must invalidate on the same cache tags/events as the rest of the product page — don't give it a separate TTL.
  • If aggregateRating isn't visibly rendered on the page for a given product, don't emit it for that product either; Google requires the rating to be user-visible.
  • Add a lightweight scheduled check (or a Behat/functional test) that pulls a sample of PDPs and diffs the JSON-LD name/sku/price against the rendered DOM — cheap insurance against a future template change silently breaking one but not the other.

How to validate

  1. View-source vs. rendered DOM: curl -s https://yourstore.example.com/product/123 | grep -A 30 'application/ld+json' shows what crawlers that don't execute JS will see; compare it against the browser DevTools Elements panel (which shows the rendered DOM) to confirm they match.
  2. Rich Results Test: paste the live URL (or the raw HTML) and confirm the Product type is detected with no missing-field errors.
  3. Schema Markup Validator: a stricter, non-Google check of the raw JSON-LD syntax and vocabulary.
  4. Spot-check that the emitted price and availability match what an anonymous visitor actually sees on that URL — not a logged-in, customer-specific price.

Verified as of July 2026 against the current OroCommerce/OroSEOBundle documentation and Google Search Central's Product structured data guidelines; menu paths and layout syntax should be confirmed against your specific Oro version before shipping, since layout internals do shift between minor releases.

This is the half of the problem page-side tooling was built to solve — but it only has something worth marking up if the underlying data is actually there. Anglera enriches product attributes, identifiers, and specs continuously in the PIM or platform your team already uses, so the gtin, mpn, brand, and description fields this JSON-LD block references are populated and current rather than blank placeholders you're stitching together by hand.

Sources

Ray Iyer

About the author

Ray IyerCo-founder, Anglera

Ray is a co-founder of Anglera, building the product-data infrastructure for agentic commerce — turning messy catalogs into structured, AI-readable data that buyers and answer engines can find. Previously product at Uber; Stanford CS.

See it on your own SKUs.

A 30-minute walkthrough on your categories and your supplier data.

Book a demo