Magento 2 and Schema.org: Advanced Markup for Rich Search Results

Let’s talk about one of the most practical and often overlooked ways to get more clicks and conversions from product pages: implementing advanced Schema.org markup in Magento 2 — with a special focus on stock status and handling custom inventory states. I’ll walk you through what matters, why it moves the needle, and give ready-to-drop-in code examples (module, template, and plugin) that handle normal stock, custom statuses and even integration with the Force Product Stock Status extension.
Why Schema.org markup matters for Magento 2 stores
Schema.org structured data (JSON-LD) helps search engines understand your product pages in a machine-readable format. For e-commerce sites it enables rich results such as price, availability, reviews, and breadcrumbs. Those enhancements often translate into higher CTRs from search results and better-qualified traffic — especially when your markup accurately reflects real inventory and custom stock states.
How inventory-aware rich snippets impact conversions
Think about a user searching for an exact product. Seeing “In stock” + price + rating in the SERP increases trust and urgency. Conversely, showing “Out of stock” but also offering “Available soon” or “Backorder” may keep qualified shoppers in the funnel if you express the right availability and a clear call-to-action. Proper structured data for availability can:
- Improve click-through rates (CTR) by adding visual cues to your result.
- Reduce bounce when price or availability is mismatched by search and actual page content.
- Help conversion if you combine availability with accurate shipping times or backorder messages.
Schema.org basics for Magento product pages
For product pages, standard fields to include in your JSON-LD are:
- @context and @type (Product)
- name, description
- sku, gtin (if present), brand
- image, url
- offers block (Offer) with price, priceCurrency, priceValidUntil (optional), availability (ItemAvailability), itemCondition
- aggregateRating and review (if applicable)
Availability should use ItemAvailability enums like https://schema.org/InStock or https://schema.org/OutOfStock. Additional states like PreOrder or BackOrder exist too. Proper mapping of Magento stock states and custom statuses to these enums is critical.
High-level approach for Magento 2 implementation
There are multiple ways to add JSON-LD in Magento 2:
- Inline in phtml template on product page (quick and explicit).
- Head injection via an observer that adds script to head block.
- Custom module that provides a block and template so it’s manageable and cache-friendly.
I recommend building a small module that outputs product JSON-LD in the head or right before the closing body. Modular approach makes it easier to read custom attributes (like those added by Force Product Stock Status), plug into DI, and cache results.
Example: Minimal JSON-LD snippet for a Magento product (PHP in phtml)
Place this snippet in app/design/frontend/YourVendor/YourTheme/Magento_Catalog/templates/product/jsonld.phtml or in a module template. It’s intentionally simple so you can see the structure.
<?php
/** @var $block \Magento\Catalog\Block\Product\View */
$product = $block->getProduct();
$price = $product->getFinalPrice();
$currency = $block->getStore()->getCurrentCurrencyCode();
$sku = $product->getSku();
$image = $product->getImage();
$url = $product->getProductUrl();
$availability = $product->isAvailable() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';
$json = [
'@context' => 'https://schema.org/',
'@type' => 'Product',
'name' => $product->getName(),
'image' => [$block->getImage($product, 'product_page_image_small')->toHtml()],
'description' => strip_tags($product->getShortDescription()),
'sku' => $sku,
'offers' => [
'@type' => 'Offer',
'priceCurrency' => $currency,
'price' => number_format($price, 2, '.', ''),
'availability' => $availability,
'url' => $url
]
];
?>
<script type="application/ld+json">= \json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>
Why that’s not enough for advanced stores
The minimal example above is useful for simple cases, but it lacks handling for:
- Custom stock statuses (e.g., "Contact us", "Available on request").
- Forced stock statuses provided by extensions like Force Product Stock Status.
- Multiple inventory sources or MSI (Multi-Source Inventory).
- Price validity or tier pricing details.
To be precise and avoid mismatch between what users see and what search engines read, extend logic to read your authoritative inventory source (stock table, MSI, or extension helper).
Mapping custom stock statuses to Schema.org ItemAvailability
Common mapping patterns:
- In stock -> https://schema.org/InStock
- Out of stock -> https://schema.org/OutOfStock
- Pre-order -> https://schema.org/PreOrder
- Backorder -> https://schema.org/BackOrder
- Available on request / Contact us -> map to OutOfStock or use descriptive text in the product description and don’t claim InStock.
Important: never falsely claim "InStock" if the product is not purchaseable. Search engines can penalize inaccurate availability markup.
Step-by-step: Build a Magento 2 module that outputs inventory-aware JSON-LD
I’ll show a pragmatic module that:
- Adds a block to product pages through layout XML.
- Reads stock using StockRegistryInterface and checks for a custom attribute used by Force Product Stock Status.
- Generates JSON-LD with accurate availability mapping.
1) Module skeleton
Create files under app/code/Magefine/SchemaStock (you can replace Magefine with your vendor).
app/code/Magefine/SchemaStock/registration.php
<?php
use \Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magefine_SchemaStock', __DIR__);
app/code/Magefine/SchemaStock/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Magefine_SchemaStock" setup_version="1.0.0" />
</config>
2) Layout: add block to product view
app/code/Magefine/SchemaStock/view/frontend/layout/catalog_product_view.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="head.components">
<block class="Magefine\SchemaStock\Block\ProductJsonLd" name="magefine.product.jsonld" template="Magefine_SchemaStock::product/jsonld.phtml" />
</referenceContainer>
</body>
</page>
Note: head.components is a safe place to add JSON-LD so it lands in <head> and keeps markup together.
3) Block class
Use DI to get StockRegistryInterface and product registry. This block prepares data for the template.
app/code/Magefine/SchemaStock/Block/ProductJsonLd.php
<?php
namespace Magefine\SchemaStock\Block;
use Magento\Catalog\Model\Product;
use Magento\Catalog\Block\Product\View as ProductViewBlock;
use Magento\CatalogInventory\Api\StockRegistryInterface;
use Magento\CatalogInventory\Api\StockStateInterface;
use Magento\Framework\Registry;
use Magento\Store\Model\StoreManagerInterface;
class ProductJsonLd extends \Magento\Framework\View\Element\Template
{
protected $registry;
protected $stockRegistry;
protected $storeManager;
public function __construct(
\Magento\Framework\View\Element\Template\Context $context,
Registry $registry,
StockRegistryInterface $stockRegistry,
StoreManagerInterface $storeManager,
array $data = []
) {
parent::__construct($context, $data);
$this->registry = $registry;
$this->stockRegistry = $stockRegistry;
$this->storeManager = $storeManager;
}
public function getProduct(): ?Product
{
$product = $this->registry->registry('current_product');
return $product;
}
public function getStockStatus($product)
{
// Default stockInfo from stock registry
$stockItem = $this->stockRegistry->getStockItem($product->getId());
$isInStock = (bool)$stockItem->getIsInStock();
$qty = $stockItem->getQty();
return ['is_in_stock' => $isInStock, 'qty' => $qty];
}
}
4) Template: generate JSON-LD including availability mapping
app/code/Magefine/SchemaStock/view/frontend/templates/product/jsonld.phtml
<?php
/** @var $block \Magefine\SchemaStock\Block\ProductJsonLd */
$product = $block->getProduct();
if (! $product) {
return; // nothing to do
}
$stockInfo = $block->getStockStatus($product);
$currency = $block->getStore()->getCurrentCurrencyCode();
$price = $product->getFinalPrice();
// Basic availability mapping function
function mapAvailability($isInStock, $qty) {
if ($isInStock && $qty > 0) {
return 'https://schema.org/InStock';
}
if ($isInStock && $qty == 0) {
return 'https://schema.org/BackOrder';
}
return 'https://schema.org/OutOfStock';
}
$availability = mapAvailability($stockInfo['is_in_stock'], (int)$stockInfo['qty']);
$json = [
'@context' => 'https://schema.org/',
'@type' => 'Product',
'name' => $product->getName(),
'image' => [$block->getImage($product, 'product_page_image_small')->toHtml()],
'description' => strip_tags($product->getShortDescription()),
'sku' => $product->getSku(),
'offers' => [
'@type' => 'Offer',
'priceCurrency' => $currency,
'price' => number_format($price, 2, '.', ''),
'availability' => $availability,
'url' => $product->getProductUrl(),
],
];
?>
<script type="application/ld+json">= \json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>
Integrating custom stock statuses and Force Product Stock Status
If you use an extension like Force Product Stock Status (or any extension that allows admins to force a stock state), you must read that extension's configuration or attribute rather than blindly using stock registry. That extension often exposes one of the following:
- A product attribute (e.g., force_stock_status) you can read with $product->getData('force_stock_status').
- A helper or model to ask for the effective stock state: \Vendor\ForceStock\Helper\Data::getEffectiveStockStatus($product).
Here’s a safe way to integrate: check if the attribute exists, then fall back to stock registry. This pattern avoids fatal errors and keeps the module compatible whether the extension is installed or not.
Example: read a forced status attribute safely
// inside the block class
$forced = null;
if ($product->hasData('force_stock_status')) {
$forced = $product->getData('force_stock_status');
}
// Possible mapping: 'in_stock', 'out_of_stock', 'preorder', 'contact'
if ($forced) {
switch ($forced) {
case 'in_stock':
$availability = 'https://schema.org/InStock';
break;
case 'preorder':
$availability = 'https://schema.org/PreOrder';
break;
case 'backorder':
$availability = 'https://schema.org/BackOrder';
break;
default:
$availability = 'https://schema.org/OutOfStock';
}
} else {
// fallback to stock registry mapping
}
Example: use a helper if available
If the Force Product Stock Status extension provides a helper, inject it via DI but keep it optional in di.xml using virtual types or by checking for class existence to avoid hard dependency.
// Example check in the block constructor
if (class_exists('\Vendor\ForceStock\Helper\Data')) {
$this->forceStockHelper = \Magento\Framework\ObjectManager::getInstance()->get('\Vendor\ForceStock\Helper\Data');
}
// Later
if ($this->forceStockHelper) {
$forcedAvailabilityCode = $this->forceStockHelper->getEffectiveStatus($product);
// map it to Schema.org value
}
Handling multi-source inventory (MSI)
Magento 2 MSI introduces multiple sources and aggregate salable quantity. For MSI setups, use the SalesInventory APIs (StockSourceRepository, SourceItemRepository, or GetSalableQuantityDataBySkuInterface) to determine ammount available for sale rather than the old stock registry. Example pseudo-logic:
- Query salable quantity for current stock(s).
- If salable quantity > 0 → InStock.
- If salable quantity == 0 but a source shows quantity > 0 with condition that it’s not assigned to this website, treat as OutOfStock.
Advanced Offer properties to increase trust
Besides availability, include:
- priceValidUntil — for promotions or limited-time pricing.
- shippingDetails — if you can declare shippingDestinationAvailability via ShippingDeliveryTime markup.
- seller — include your store as Merchant and add aggregateRating/reviews to show social proof.
Example: richer JSON-LD with aggregateRating and GTIN
$json = [
'@context' => 'https://schema.org/',
'@type' => 'Product',
'name' => $product->getName(),
'image' => [$imageUrl],
'description' => strip_tags($product->getShortDescription()),
'sku' => $product->getSku(),
'gtin13' => $product->getData('gtin') ?: null,
'brand' => ['@type' => 'Brand', 'name' => $product->getAttributeText('manufacturer')],
'aggregateRating' => [
'@type' => 'AggregateRating',
'ratingValue' => 4.4,
'reviewCount' => 25
],
'offers' => [
'@type' => 'Offer',
'priceCurrency' => $currency,
'price' => number_format($price, 2, '.', ''),
'availability' => $availability,
'url' => $product->getProductUrl(),
],
];
Best practices when mapping stock states and structured data
- Always prefer authoritative inventory source (MSI, stock registry, or extension helper) over isAvailable()/getIsInStock lazy checks.
- Do not claim InStock unless the product is actually purchasable (has salable qty or can be ordered as pre-order/backorder).
- For custom statuses like “Contact us” or “Available on request”, do not invent new Schema.org enums — use OutOfStock or BackOrder and make the product page copy explicit about how customers can proceed.
- Include price and currency in Offer; missing price often prevents rich results.
- Keep JSON-LD small, valid, and unique per page. Avoid injecting duplicate Product objects that conflict.
Cache and performance considerations
Generating JSON-LD per page on the fly can be cheap, but if you compute expensive inventory aggregation across many sources, cache the result with a reasonable TTL. Suggestions:
- Cache JSON-LD output in full page cache (FPC) or as a block cacheable=true with proper cache keys (product ID, website, store currency).
- If you use Varnish, keep the JSON-LD inside the HTML (not dynamically injected by client JS) because crawlers may not execute JS consistently.
- Invalidate cached JSON-LD when stock levels change (observe inventory events or batch reindex events to flush or regenerate cached blocks).
Testing and validation
After deploying the markup:
- Use Google’s Rich Results Test and the Schema.org validator to check for errors.
- Inspect the page source to ensure your JSON-LD reflects current availability and price.
- When you change stock policies (e.g., a product is forced to “In stock” by an extension), re-test the structured data for accuracy.
- Monitor Search Console for enhancements and any structured data errors – Google surfaces issues like missing required properties or conflicting values.
Edge cases and subtle pitfalls
- Multistore / Multi-currency: ensure price and currency in Offer matches the store view and page language.
- Pre-order and release date: when using PreOrder, include expected availability dates if you can (use additional properties like availabilityStarts).
- Third-party marketplaces: if you show marketplace prices on pages, be explicit about the seller property.
- Automated crawlers vs. humans: keep human-facing messaging and structured data consistent — mismatches may trigger manual review or reduced eligibility for rich results.
Monitoring business results: measuring CTR and conversion uplift
Structured data is not a guarantee, but to measure impact:
- Record baseline organic CTRs and impressions in Search Console first.
- Deploy structured data on a subset of SKU categories and compare CTR changes over 2-6 weeks.
- Use UTM tags for campaigns where you promote back-in-stock or preorder pages and measure conversion rate differences.
Store owners often see a modest CTR uplift but a meaningful increase in conversion when rich data increases user trust. For inventory-sensitive products (high-priced electronics, limited editions), clear availability in SERPs significantly reduces drop-off.
Deploy checklist for production
- Map Magento inventory states and any extension-provided states to Schema.org enums.
- Implement module or template as cacheable block and ensure cache keys include product id and store id.
- Hook into extension helper if it exists, but keep fallbacks to native stock APIs.
- Validate via Rich Results Test and Search Console.
- Monitor logs and Search Console for regressions.
Sample full plugin example: override availability at Offer generation time
Sometimes you want the logic to live in a plugin that modifies the JSON-LD data provider or block output rather than templating. Here is a conceptual plugin for a provider class that returns structured data array. The idea: read forced stock attribute first, then MSI, then fallback.
app/code/Magefine/SchemaStock/etc/di.xml
<type name="Vendor\SchemaProvider\Model\ProductDataProvider">
<plugin name="magefine_force_stock_plugin" type="Magefine\SchemaStock\Plugin\ForceStockPlugin" />
</type>
app/code/Magefine/SchemaStock/Plugin/ForceStockPlugin.php
<?php
namespace Magefine\SchemaStock\Plugin;
class ForceStockPlugin
{
public function afterGetProductData($subject, $result, $product)
{
// $result is the array used for JSON-LD
// Check product attribute
if ($product->hasData('force_stock_status')) {
$forced = $product->getData('force_stock_status');
// map to schema
$result['offers']['availability'] = $this->mapForced($forced);
} else {
// optionally set based on MSI or stock registry
}
return $result;
}
private function mapForced($forced)
{
switch ($forced) {
case 'in_stock': return 'https://schema.org/InStock';
case 'preorder': return 'https://schema.org/PreOrder';
case 'backorder': return 'https://schema.org/BackOrder';
default: return 'https://schema.org/OutOfStock';
}
}
}
Operational tips specific to Force Product Stock Status extensions
If you already use a Force Product Stock Status extension:
- Check how the extension stores forced states: attribute, EAV option, or separate table.
- Prefer using a helper or service exposed by the extension to get the "effective" availability — it handles priority rules (global > category > product).
- Don’t hard-depend on extension classes — use class_exists or optional DI to keep your module resilient.
- If the extension supports bulk update of stock statuses, trigger JSON-LD cache flush or reindex after those operations.
SEO-friendly microcopy for inventory states
Besides structured data, include clear human-readable inventory messages on the product page that mirror the JSON-LD. Example patterns:
- In stock: show shipping lead time and “Add to cart” button.
- Backorder: show estimated shipping and expected restock date.
- Pre-order: show release date and clear pre-order terms.
- Contact us / Request quote: provide a short form and expected response time.
Consistency between JSON-LD and visible content is essential.
Putting it all together: full JSON-LD example for a product page
Below is a sample JSON-LD of a product with price, availability (sensitive to forced stock), aggregateRating, and seller info. Drop it inside the JSON-LD template after you resolve availability as shown earlier.
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "Premium Wireless Headphones",
"image": ["https://yourstore.com/pub/media/catalog/product/h/e/headphones.jpg"],
"description": "Noise-cancelling premium wireless headphones with 30h battery.",
"sku": "PH-12345",
"gtin13": "0123456789012",
"brand": {"@type": "Brand", "name": "AcmeAudio"},
"aggregateRating": {"@type": "AggregateRating", "ratingValue": "4.6", "reviewCount": "52"},
"offers": {
"@type": "Offer",
"priceCurrency": "EUR",
"price": "199.00",
"availability": "https://schema.org/InStock",
"url": "https://yourstore.com/premium-wireless-headphones",
"seller": {"@type": "Organization", "name": "Magefine Store"}
}
}
Final checklist before launch
- Validate the JSON-LD for every product template variant (simple, configurable, grouped).
- Make sure price/currency are correct per store view.
- Confirm the availability value follows the forced stock status if your extension is active.
- Cache JSON-LD intelligently and flush on stock changes.
- Monitor Search Console and adjust as needed.
Closing notes and recommended next steps
If you run a Magento 2 store, you can get disproportionate wins from correct Schema.org implementation: better SERP appearance, more qualified clicks, and fewer surprises for buyers about availability. For stores using Force Product Stock Status or other inventory extensions, make the extension the authoritative source for availability in your JSON-LD. Be conservative: do not claim InStock if it's not truly available for purchase.
If you want, I can:
- Review your current product templates and provide a patched module that integrates with Force Product Stock Status if you use it.
- Provide a reference implementation that supports MSI and cache integration for high-traffic stores.
Need the module files or a tailored integration for your store or magefine hosting setup? Tell me whether you use MSI, Force Product Stock Status (and its vendor name), and whether you want JSON-LD in the head or before closing body — I’ll produce the exact module files ready for copy/paste.