Skip to content

SEO - Rich Snippets - Developer Notes

This document contains technical notes for developers extending the SEO Rich Snippets module.

Architecture Overview

The module uses a three-phase rendering process:

  1. Snippet Expansion - Nested snippets are resolved and merged into a final template
  2. Variable Resolution - All variables are extracted and resolved to actual values
  3. DJson Processing - Conditionals and loops are processed, producing final JSON-LD

Extension Points

Value Resolvers

Value resolvers handle variable resolution for specific prefixes (e.g., product.*, config.*, enum.*).

Interface: Qoliber\SeoRichSnippets\Api\ValueResolverInterface

Methods: - getPrefix(): string - Returns the variable prefix (e.g., "product") - resolve(string $path, array $context = []): ?string - Resolves the variable to a value

Registration via di.xml:

<type name="Qoliber\SeoRichSnippets\Model\VariableExtractor">
    <arguments>
        <argument name="valueResolvers" xsi:type="array">
            <item name="custom" xsi:type="object">
                Vendor\Module\Model\ValueResolver\CustomValueResolver
            </item>
        </argument>
    </arguments>
</type>

Example Implementation:

<?php
namespace Vendor\Module\Model\ValueResolver;

use Qoliber\SeoRichSnippets\Api\ValueResolverInterface;

class OrderValueResolver implements ValueResolverInterface
{
    public function getPrefix(): string
    {
        return 'order'; // Handles {{order.*}}
    }

    public function resolve(string $path, array $context = []): ?string
    {
        $order = $context['order'] ?? null;
        if (!$order) {
            return null;
        }

        return match ($path) {
            'incrementId' => $order->getIncrementId(),
            'grandTotal' => (string)$order->getGrandTotal(),
            'createdAt' => $order->getCreatedAt(),
            default => null
        };
    }
}

Variable Providers

Variable providers supply variable metadata for admin display and provide collection data for DJson loops.

Interface: Qoliber\SeoRichSnippets\Api\VariableProviderInterface

Methods: - getLabel(): string - Display name in admin - getVariables(): array - Array of available variables with metadata - renderVariables(array $context = []): array - Returns actual data for template processing - getSortOrder(): int - Display order in admin - getColorClass(): string - CSS class for admin styling

Registration via di.xml:

<type name="Qoliber\SeoRichSnippets\Block\JsonLd">
    <arguments>
        <argument name="variableProviders" xsi:type="array">
            <item name="customProvider" xsi:type="object">
                Vendor\Module\Model\VariableProvider\CustomVariableProvider
            </item>
        </argument>
    </arguments>
</type>

Example Implementation:

<?php
namespace Vendor\Module\Model\VariableProvider;

use Qoliber\SeoRichSnippets\Api\VariableProviderInterface;

class OrderVariableProvider implements VariableProviderInterface
{
    public function getLabel(): string
    {
        return (string)__('Order Variables');
    }

    public function getVariables(): array
    {
        return [
            'order.incrementId' => [
                'label' => 'order.incrementId',
                'description' => 'Order Number',
                'explanation' => 'The order increment ID',
                'type' => 'string'
            ],
            'orderItems' => [
                'label' => 'orderItems',
                'description' => 'Order Items Collection',
                'djsonTemplate' => '@djson for orderItems as item',
                'explanation' => 'Iterates through order items',
                'type' => 'collection'
            ]
        ];
    }

    public function renderVariables(array $context = []): array
    {
        $order = $context['order'] ?? null;
        if (!$order) {
            return [];
        }

        // Provide simple variables (dot notation)
        $variables['order.incrementId'] = $order->getIncrementId();
        $variables['order.grandTotal'] = (string)$order->getGrandTotal();

        // Provide collections (flat key)
        $items = [];
        foreach ($order->getAllVisibleItems() as $item) {
            $items[] = [
                'name' => $item->getName(),
                'sku' => $item->getSku(),
                'qty' => (string)$item->getQtyOrdered(),
                'price' => (string)$item->getPrice()
            ];
        }
        $variables['orderItems'] = $items;

        return $variables;
    }

    public function getSortOrder(): int
    {
        return 80;
    }

    public function getColorClass(): string
    {
        return 'order';
    }
}

Built-in Value Resolvers

Resolver Class Prefix Handles Examples
ProductValueResolver product Product data, attributes, reviews {{product.name}}, {{product.reviewCount}}
ConfigValueResolver config Magento configuration values {{config.general.store_information.name}}
StoreValueResolver store Current store information {{store.baseUrl}}, {{store.currencyCode}}
EnumValueResolver enum Schema.org enumeration values {{enum.availability.InStock}}

Built-in Variable Providers

Provider Class Label Provides Type
ProductVariables Product Variables Product data Simple variables
ConfigVariables Configuration Variables Magento config Simple variables
StoreVariables Store Variables Store data Simple variables
EnumVariables Enum Variables Schema.org enums Simple variables
CollectionVariables Data Collections Reviews, breadcrumbs, gallery Collections
BreadcrumbsVariableProvider Breadcrumbs Collection Breadcrumb navigation Collection

Core Components

Block Layer

Class Role
Qoliber\SeoRichSnippets\Block\JsonLd Main rendering block, orchestrates snippet loading and rendering

Model Layer

Class Role
Qoliber\SeoRichSnippets\Model\SnippetExpander Handles nested snippet expansion with circular dependency protection
Qoliber\SeoRichSnippets\Model\VariableExtractor Extracts variables from templates and resolves them using registered resolvers
Qoliber\SeoRichSnippets\Model\Snippet Snippet entity model
Qoliber\SeoRichSnippets\Model\ResourceModel\Snippet Snippet resource model

API Layer

Interface Role
Qoliber\SeoRichSnippets\Api\SnippetRepositoryInterface CRUD operations for snippets
Qoliber\SeoRichSnippets\Api\ValueResolverInterface Contract for value resolvers
Qoliber\SeoRichSnippets\Api\VariableProviderInterface Contract for variable providers
Qoliber\SeoRichSnippets\Api\Data\SnippetInterface Snippet data interface

DJson Integration

Class Role
Qoliber\DJson\DJson Processes DJson templates (conditionals, loops)

Database Schema

Table: qoliber_rich_snippets

Column Type Description
snippet_id int Primary key
name varchar(255) Snippet name (admin display)
identifier varchar(255) Unique identifier for referencing
active tinyint Enable/disable snippet
json_schema text JSON template with variables and DJson syntax
custom_url varchar(255) URL pattern matching (supports wildcards)
custom_handle varchar(255) Layout handle matching
created_at timestamp Creation timestamp
updated_at timestamp Last update timestamp

Table: qoliber_rich_snippets_store

Column Type Description
snippet_id int Foreign key to snippets table
store_id int Store view ID (0 = all stores)

Composite Primary Key: snippet_id, store_id

Table: qoliber_rich_snippets_page_type

Column Type Description
snippet_id int Foreign key to snippets table
page_type varchar(50) Page type (homepage, product, category, etc.)

Composite Primary Key: snippet_id, page_type

Table: qoliber_rich_snippets_cms_page

Column Type Description
snippet_id int Foreign key to snippets table
page_id int CMS page ID

Composite Primary Key: snippet_id, page_id

Configuration Paths

Path Default Description
qoliber_seo_rich_snippets/settings/enabled 0 Enable/disable module
qoliber_seo_rich_snippets/settings/debug_mode minified Rendering mode (minified, pretty, disabled)

Layout Handles

Handle XML Role
default qoliber_seorichsnippets.xml Adds JsonLd block to all pages

Caching

Cache Tags

Tag Pattern Description
qoliber_richsnippets_{snippet_id} Individual snippet cache tag

Cache Invalidation: - Automatic when snippet is saved/deleted - Manual via bin/magento cache:flush

Cache Key

The JsonLd block includes snippet IDs in its cache key for proper cache segmentation:

public function getCacheKeyInfo(): array
{
    $info = parent::getCacheKeyInfo();
    $snippets = $this->getActiveSnippets();

    $snippetIds = array_map(
        fn ($snippet) => $snippet->getSnippetId(),
        $snippets
    );
    $info['snippet_ids'] = implode('_', $snippetIds);

    return $info;
}

Events

Currently, the module does not dispatch custom events. Extension via observers is not supported.

Use dependency injection to extend value resolvers and variable providers instead.

Snippet Nesting

Configuration

Constant Value Description
SnippetExpander::MAX_NESTING_LEVEL 5 Maximum snippet nesting depth

Circular Dependency Protection

The SnippetExpander tracks processed snippet identifiers to prevent infinite loops:

private function expand(
    string $template,
    array $processedIdentifiers = [],
    int $level = 0
): string {
    if ($level >= self::MAX_NESTING_LEVEL) {
        throw new \RuntimeException('Maximum nesting level reached');
    }

    if (in_array($identifier, $processedIdentifiers)) {
        throw new \RuntimeException('Circular dependency detected');
    }

    // Continue expansion
}

Common Extension Scenarios

Scenario 1: Add Custom Product Attribute

No code required! Just use the variable in your snippet:

{
    "@type": "Product",
    "customAttribute": "{{product.your_attribute_code}}"
}

The ProductValueResolver automatically handles all product attributes.

Scenario 2: Add Order Confirmation Schema

Step 1: Create Value Resolver (handles {{order.*}} variables)

class OrderValueResolver implements ValueResolverInterface
{
    public function getPrefix(): string { return 'order'; }

    public function resolve(string $path, array $context = []): ?string
    {
        // Resolve order data
    }
}

Step 2: Create Variable Provider (provides order items collection)

class OrderVariableProvider implements VariableProviderInterface
{
    public function renderVariables(array $context = []): array
    {
        return [
            'order.incrementId' => $order->getIncrementId(),
            'orderItems' => $this->getOrderItems($order)
        ];
    }
}

Step 3: Register via di.xml

<type name="Qoliber\SeoRichSnippets\Model\VariableExtractor">
    <arguments>
        <argument name="valueResolvers" xsi:type="array">
            <item name="order" xsi:type="object">
                Vendor\Module\Model\ValueResolver\OrderValueResolver
            </item>
        </argument>
    </arguments>
</type>

<type name="Qoliber\SeoRichSnippets\Block\JsonLd">
    <arguments>
        <argument name="variableProviders" xsi:type="array">
            <item name="orderProvider" xsi:type="object">
                Vendor\Module\Model\VariableProvider\OrderVariableProvider
            </item>
        </argument>
    </arguments>
</type>

Step 4: Create snippet via admin with Custom Handle: checkout_onepage_success

{
    "@context": "https://schema.org/",
    "@type": "Order",
    "orderNumber": "{{order.incrementId}}",
    "orderDate": "{{order.createdAt}}",
    "orderedItem": {
        "@djson for orderItems as item": {
            "@type": "OrderItem",
            "orderQuantity": "{{item.qty}}",
            "orderedItem": {
                "@type": "Product",
                "name": "{{item.name}}",
                "sku": "{{item.sku}}"
            }
        }
    }
}

Scenario 3: Add Blog Post Schema

Step 1: Create BlogValueResolver for {{blog.*}} variables

Step 2: Create BlogVariableProvider for blog post data and comments collection

Step 3: Register both via di.xml

Step 4: Create BlogPosting snippet assigned to blog post handle

Testing

Unit Testing Value Resolvers

class CustomValueResolverTest extends TestCase
{
    private CustomValueResolver $resolver;

    protected function setUp(): void
    {
        $this->resolver = new CustomValueResolver();
    }

    public function testGetPrefix(): void
    {
        $this->assertEquals('custom', $this->resolver->getPrefix());
    }

    public function testResolve(): void
    {
        $context = ['custom_data' => 'test_value'];
        $result = $this->resolver->resolve('field', $context);

        $this->assertEquals('expected_value', $result);
    }
}

Integration Testing Snippets

/**
 * @magentoDataFixture Magento/Catalog/_files/product_simple.php
 */
public function testProductSnippetRendering(): void
{
    $block = $this->objectManager->create(JsonLd::class);
    $output = $block->toHtml();

    $this->assertStringContainsString('application/ld+json', $output);
    $this->assertStringContainsString('"@type":"Product"', $output);
}

Performance Considerations

Optimization Tips

  1. Minimize variable providers - Only register providers that supply data for active snippets
  2. Efficient data loading - Load collections once, not per variable
  3. Use caching - Leverage Magento's cache system
  4. Limit nesting depth - Keep snippet nesting shallow when possible
  5. Avoid N+1 queries - Use collections instead of loading entities in loops

Query Optimization

Bad:

foreach ($items as $item) {
    $product = $this->productRepository->getById($item->getProductId());
}

Good:

$collection = $this->productCollectionFactory->create();
$collection->addIdFilter($productIds);
$products = $collection->getItems();

Security

XSS Prevention

  • All variables are automatically escaped during DJson processing
  • Use htmlspecialchars() when manually building output

Input Validation

  • Snippet identifiers must match pattern: /^[a-z0-9-]+$/
  • URL patterns are validated before regex compilation
  • SQL queries use parameter binding

Access Control

  • Admin ACL resource: Qoliber_SeoRichSnippets::snippets
  • Permissions required for snippet management

Debugging

Enable Debug Logging

Set logger in di.xml and use throughout code:

$this->logger->debug('Variable resolution', [
    'variable' => $variable,
    'resolved_value' => $value,
    'context_keys' => array_keys($context)
]);

Log Location: var/log/debug.log

Debug Mode

Enable pretty-print: Stores → Configuration → Qoliber → SEO: Rich Snippets → Debug Mode → pretty

Xdebug Breakpoints

Key methods to debug: - JsonLd::renderSnippet() - Main rendering - SnippetExpander::expand() - Snippet expansion - VariableExtractor::resolveVariables() - Variable resolution - DJson::processToJson() - DJson processing

CLI Commands

Currently, the module does not provide CLI commands. Snippet management is done via:

  1. Admin UI (Content → SEO Rich Snippets → Snippets)
  2. Direct database manipulation (not recommended)
  3. Data patches (for module installation)

Best Practices

  1. Use dependency injection - Always inject dependencies via constructor
  2. Follow SOLID principles - Single responsibility for resolvers
  3. Return null for missing data - Let DJson handle optional data
  4. Document your extensions - Add PHPDoc comments
  5. Handle errors gracefully - Catch exceptions, log errors, return null
  6. Write tests - Unit test resolvers and providers
  7. Use type hints - PHP 8.1+ features (readonly, match expressions)

Resources