Skip to content

Advanced Topics

This document covers advanced features and developer extension points for the SEO Rich Snippets module.

Architecture Overview

Request Flow

  1. Page LoadJsonLd block renders
  2. Snippet Discovery → Find active snippets for current page
  3. Snippet Expansion → Expand all nested {{snippet.identifier}} references
  4. Variable Extraction → Find all {{variable.name}} patterns in template
  5. Variable Resolution → Resolve variables using registered resolvers
  6. DJson Processing → Process conditionals and loops
  7. Output → Render as <script type="application/ld+json">

Key Components

Block Layer: - Qoliber\SeoRichSnippets\Block\JsonLd - Main rendering block

Model Layer: - Qoliber\SeoRichSnippets\Model\SnippetExpander - Handles snippet nesting - Qoliber\SeoRichSnippets\Model\VariableExtractor - Extracts and resolves variables

Variable System: - VariableProviderInterface - Provides variable metadata and collections - ValueResolverInterface - Resolves variable values from data sources

DJson Processing: - Qoliber\DJson\DJson - Processes conditionals and loops

Extending the Module

Creating Custom Variable Providers

Variable providers define available variables and supply collection data.

1. Create Provider Class

<?php
declare(strict_types=1);

namespace Vendor\Module\Model\VariableProvider;

use Qoliber\SeoRichSnippets\Api\VariableProviderInterface;

class CustomVariableProvider implements VariableProviderInterface
{
    public function __construct(
        // Inject dependencies
    ) {
    }

    public function getLabel(): string
    {
        return (string)__('Custom Variables');
    }

    public function getVariables(): array
    {
        return [
            'customField' => [
                'label' => 'customField',
                'description' => 'Custom Field Description',
                'explanation' => 'Detailed explanation of this variable',
                'type' => 'string'
            ],
            'customCollection' => [
                'label' => 'customCollection',
                'description' => 'Custom Collection',
                'djsonTemplate' => '@djson for customCollection as item',
                'explanation' => 'Iterates through custom items',
                'type' => 'collection'
            ]
        ];
    }

    public function renderVariables(array $context = []): array
    {
        $variables = [];

        // Provide simple variables (dot notation for nested)
        $variables['custom.field'] = 'Some value';

        // Provide collections (flat key)
        $variables['customCollection'] = [
            ['name' => 'Item 1', 'value' => '100'],
            ['name' => 'Item 2', 'value' => '200']
        ];

        return $variables;
    }

    public function getSortOrder(): int
    {
        return 100; // Display order in admin
    }

    public function getColorClass(): string
    {
        return 'custom'; // CSS class for admin display
    }
}

2. Register via di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <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>
</config>

3. Usage in Snippets

{
    "@type": "CustomType",
    "customField": "{{custom.field}}",
    "items": {
        "@djson for customCollection as item": {
            "name": "{{item.name}}",
            "value": "{{item.value}}"
        }
    }
}

Creating Custom Value Resolvers

Value resolvers handle variable resolution for specific prefixes.

1. Create Resolver Class

<?php
declare(strict_types=1);

namespace Vendor\Module\Model\ValueResolver;

use Qoliber\SeoRichSnippets\Api\ValueResolverInterface;

class CustomValueResolver implements ValueResolverInterface
{
    public function __construct(
        // Inject dependencies
    ) {
    }

    public function getPrefix(): string
    {
        return 'custom'; // Handles {{custom.*}} variables
    }

    public function resolve(string $path, array $context = []): ?string
    {
        // $path is the part after the prefix
        // e.g., for {{custom.order.total}}, $path = "order.total"

        // Example: resolve order data
        if (str_starts_with($path, 'order.')) {
            return $this->resolveOrderData($path, $context);
        }

        // Example: resolve customer data
        if (str_starts_with($path, 'customer.')) {
            return $this->resolveCustomerData($path, $context);
        }

        return null;
    }

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

        return match ($path) {
            'order.total' => (string)$order->getGrandTotal(),
            'order.number' => $order->getIncrementId(),
            'order.date' => $order->getCreatedAt(),
            default => null
        };
    }

    private function resolveCustomerData(string $path, array $context): ?string
    {
        // Implementation
        return null;
    }
}

2. Register via di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <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>
</config>

3. Usage in Snippets

{
    "@type": "Order",
    "orderNumber": "{{custom.order.number}}",
    "orderTotal": "{{custom.order.total}}",
    "orderDate": "{{custom.order.date}}"
}

Creating Custom Enum Providers

Provide reusable enumeration values.

1. Create Enum Provider

<?php
declare(strict_types=1);

namespace Vendor\Module\Model\ValueResolver;

use Qoliber\SeoRichSnippets\Api\ValueResolverInterface;

class CustomEnumResolver implements ValueResolverInterface
{
    public function getPrefix(): string
    {
        return 'enum';
    }

    public function resolve(string $path, array $context = []): ?string
    {
        return match ($path) {
            'customStatus.active' => 'https://schema.org/ActiveStatus',
            'customStatus.inactive' => 'https://schema.org/InactiveStatus',
            'deliveryMethod.pickup' => 'https://schema.org/OnSitePickup',
            'deliveryMethod.shipping' => 'https://schema.org/ParcelService',
            default => null
        };
    }
}

2. Register via di.xml

Same pattern as value resolvers.

3. Usage

{
    "@type": "Offer",
    "deliveryMethod": "{{enum.deliveryMethod.shipping}}"
}

Advanced Snippet Techniques

Multi-Level Snippet Nesting

The module supports up to 5 levels of nesting:

Level 1: Product

{
    "@type": "Product",
    "offers": "{{snippet.offer}}"
}

Level 2: Offer

{
    "@type": "Offer",
    "seller": "{{snippet.organization}}"
}

Level 3: Organization

{
    "@type": "Organization",
    "address": "{{snippet.organization-address}}",
    "sameAs": "{{snippet.organization-social-links}}"
}

Level 4: Address & Social Links

{
    "@type": "PostalAddress",
    "streetAddress": "..."
}

Total Depth: 4 levels (Product → Offer → Organization → Address)

Circular Dependency Protection

The system prevents infinite loops:

// In SnippetExpander
private const MAX_NESTING_LEVEL = 5;

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');
    }

    // Process snippet expansion
}

Conditional Snippet Embedding

Embed snippets only when conditions are met:

{
    "@type": "Product",
    "@djson if product.reviewCount": {
        "review": "{{snippet.review-list}}"
    },
    "@djson if product.hasVideo": {
        "video": "{{snippet.product-video}}"
    }
}

Performance Optimization

Caching Strategy

The JsonLd block implements Magento's cache system:

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

    // Add snippet IDs to cache key
    $snippetIds = array_map(
        fn ($snippet) => $snippet->getSnippetId(),
        $snippets
    );
    $info['snippet_ids'] = implode('_', $snippetIds);

    return $info;
}

public function getIdentities(): array
{
    $tags = [];
    $snippets = $this->getActiveSnippets();

    foreach ($snippets as $snippet) {
        $tags[] = 'qoliber_richsnippets_' . $snippet->getSnippetId();
    }

    return $tags;
}

Cache Invalidation: - Automatic when snippets are updated - Manual via bin/magento cache:flush

Reducing Database Queries

Bad Practice:

// Don't load data inside loops
public function renderVariables(array $context = []): array
{
    foreach ($products as $product) {
        $product->load($product->getId()); // ❌ N+1 query problem
    }
}

Good Practice:

// Load collections efficiently
public function renderVariables(array $context = []): array
{
    $collection = $this->productCollectionFactory->create();
    $collection->addAttributeToSelect(['name', 'sku', 'price']);
    // Single query for all products ✅
}

Optimizing Variable Resolution

public function resolve(string $path, array $context = []): ?string
{
    // Use match for faster lookups
    return match ($path) {
        'name' => $product->getName(),
        'sku' => $product->getSku(),
        'price' => (string)$product->getFinalPrice(),
        default => $this->getAttributeValue($product, $path)
    };
}

Debugging

Enable Debug Logging

// In di.xml
<type name="Qoliber\SeoRichSnippets\Block\JsonLd">
    <arguments>
        <argument name="logger" xsi:type="object">Psr\Log\LoggerInterface</argument>
    </arguments>
</type>

// In your code
$this->logger->debug('Context data', [
    'has_product' => isset($context['product']),
    'review_count' => $reviewCount
]);

Log Location: var/log/debug.log

Debug Mode

Enable pretty-print mode:

StoresConfigurationQoliberSEO: Rich SnippetsDebug Modepretty

Xdebug Integration

Set breakpoints in: - JsonLd::renderSnippet() - Snippet rendering - VariableExtractor::resolveVariables() - Variable resolution - SnippetExpander::expand() - Snippet expansion

Testing

Unit Testing Value Resolvers

<?php
declare(strict_types=1);

namespace Vendor\Module\Test\Unit\Model\ValueResolver;

use PHPUnit\Framework\TestCase;
use Vendor\Module\Model\ValueResolver\CustomValueResolver;

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 testResolveOrderTotal(): void
    {
        $order = $this->createMock(\Magento\Sales\Model\Order::class);
        $order->method('getGrandTotal')->willReturn(99.99);

        $context = ['order' => $order];
        $result = $this->resolver->resolve('order.total', $context);

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

    public function testResolveReturnsNullForUnknownPath(): void
    {
        $result = $this->resolver->resolve('unknown.path', []);
        $this->assertNull($result);
    }
}

Integration Testing Snippets

<?php
declare(strict_types=1);

namespace Vendor\Module\Test\Integration;

use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
use Qoliber\SeoRichSnippets\Block\JsonLd;

class SnippetRenderingTest extends TestCase
{
    private JsonLd $block;

    protected function setUp(): void
    {
        $objectManager = Bootstrap::getObjectManager();
        $this->block = $objectManager->create(JsonLd::class);
    }

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

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

Security Considerations

XSS Prevention

Variables are automatically escaped during rendering:

// In DJson processing
$value = htmlspecialchars($resolvedValue, ENT_QUOTES, 'UTF-8');

Input Validation

Validate snippet identifiers:

private function validateIdentifier(string $identifier): bool
{
    // Only allow lowercase alphanumeric and hyphens
    return (bool)preg_match('/^[a-z0-9-]+$/', $identifier);
}

SQL Injection Prevention

Always use parameter binding:

// Bad ❌
$query = "SELECT * FROM table WHERE id = " . $snippetId;

// Good ✅
$select = $connection->select()
    ->from('table')
    ->where('id = ?', $snippetId);

Best Practices for Extension

1. Follow Naming Conventions

Variable Providers: - Class: Vendor\Module\Model\VariableProvider\{Purpose}VariableProvider - Example: CustomOrderVariableProvider

Value Resolvers: - Class: Vendor\Module\Model\ValueResolver\{Prefix}ValueResolver - Example: OrderValueResolver (handles {{order.*}})

2. Use Dependency Injection

public function __construct(
    private readonly ProductRepositoryInterface $productRepository,
    private readonly StoreManagerInterface $storeManager
) {
}

3. Return Null for Missing Data

public function resolve(string $path, array $context = []): ?string
{
    if (!isset($context['product'])) {
        return null; // Let DJson handle missing data
    }

    return $product->getData($path) ?? null;
}

4. Document Your Extensions

Add PHPDoc comments:

/**
 * Resolves custom order-related variables
 *
 * Supported variables:
 * - {{custom.order.total}} - Order grand total
 * - {{custom.order.number}} - Order increment ID
 *
 * @implements ValueResolverInterface
 */
class CustomOrderResolver implements ValueResolverInterface
{
    // ...
}

5. Handle Errors Gracefully

try {
    $value = $this->complexOperation();
} catch (\Exception $e) {
    $this->logger->error('Failed to resolve variable: ' . $e->getMessage());
    return null; // Don't break the entire snippet
}

Common Extension Scenarios

Scenario 1: Add Custom Product Attribute

Requirement: Add eco_friendly attribute to Product snippet

Solution: No code needed! Just use in template:

{
    "@type": "Product",
    "name": "{{product.name}}",
    "ecoFriendly": "{{product.eco_friendly}}"
}

The ProductValueResolver automatically handles all product attributes.

Scenario 2: Add Order Confirmation Schema

1. Create Value Resolver

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

    public function resolve(string $path, array $context = []): ?string
    {
        $order = $context['order'] ?? $this->checkoutSession->getLastRealOrder();
        // Resolve order.* variables
    }
}

2. Create Snippet

{
    "@context": "https://schema.org/",
    "@type": "Order",
    "orderNumber": "{{order.incrementId}}",
    "orderDate": "{{order.createdAt}}",
    "orderStatus": "https://schema.org/OrderProcessing",
    "customer": {
        "@type": "Person",
        "name": "{{order.customerName}}"
    },
    "orderedItem": {
        "@djson for order.items as item": {
            "@type": "OrderItem",
            "orderQuantity": "{{item.qtyOrdered}}",
            "orderedItem": {
                "@type": "Product",
                "name": "{{item.name}}",
                "sku": "{{item.sku}}"
            }
        }
    }
}

3. Assign to Success Page

Set Custom Handle: checkout_onepage_success

Scenario 3: Add Blog Post Schema

See the example in documentation for creating custom variable providers and resolvers specific to blog posts.

Resources

Next Steps