Advanced Topics
This document covers advanced features and developer extension points for the SEO Rich Snippets module.
Architecture Overview
Request Flow
- Page Load →
JsonLdblock renders - Snippet Discovery → Find active snippets for current page
- Snippet Expansion → Expand all nested
{{snippet.identifier}}references - Variable Extraction → Find all
{{variable.name}}patterns in template - Variable Resolution → Resolve variables using registered resolvers
- DJson Processing → Process conditionals and loops
- 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
Advanced Snippet Techniques
Multi-Level Snippet Nesting
The module supports up to 5 levels of nesting:
Level 1: Product
Level 2: Offer
Level 3: Organization
{
"@type": "Organization",
"address": "{{snippet.organization-address}}",
"sameAs": "{{snippet.organization-social-links}}"
}
Level 4: Address & Social Links
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:
Stores → Configuration → Qoliber → SEO: Rich Snippets → Debug Mode → pretty
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:
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:
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
- Review Variables Reference for built-in variables
- Study Default Snippets for implementation examples
- Check DJson Syntax for template techniques