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:
- Snippet Expansion - Nested snippets are resolved and merged into a final template
- Variable Resolution - All variables are extracted and resolved to actual values
- 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:
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
- Minimize variable providers - Only register providers that supply data for active snippets
- Efficient data loading - Load collections once, not per variable
- Use caching - Leverage Magento's cache system
- Limit nesting depth - Keep snippet nesting shallow when possible
- Avoid N+1 queries - Use collections instead of loading entities in loops
Query Optimization
Bad:
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:
- Admin UI (
Content → SEO Rich Snippets → Snippets) - Direct database manipulation (not recommended)
- Data patches (for module installation)
Best Practices
- Use dependency injection - Always inject dependencies via constructor
- Follow SOLID principles - Single responsibility for resolvers
- Return null for missing data - Let DJson handle optional data
- Document your extensions - Add PHPDoc comments
- Handle errors gracefully - Catch exceptions, log errors, return null
- Write tests - Unit test resolvers and providers
- Use type hints - PHP 8.1+ features (readonly, match expressions)