Extending the template engine
Add custom variables (e.g. {{review.average_rating}}, {{warehouse.region}}) by registering a new VariableResolverInterface.
The interface
namespace Byte8\SeoSuite\Model\MetaTemplate;
interface VariableResolverInterface
{
public function isApplicable(): bool;
public function getNamespace(): string;
/** @return array<string, string|int|float|null> */
public function getVariables(): array;
}
The keys returned by getVariables() are automatically prefixed with {namespace}. — return ['average_rating' => 4.7] from a resolver with namespace review and your template can reference {{review.average_rating}}.
Recipe — adding a review.* namespace
namespace Vendor\Module\SeoSuite\VariableResolver;
use Byte8\SeoSuite\Model\MetaTemplate\VariableResolverInterface;
use Magento\Framework\Registry;
use Magento\Review\Model\ResourceModel\Review\SummaryFactory;
class Review implements VariableResolverInterface
{
public function __construct(
private readonly Registry $registry,
private readonly SummaryFactory $summaryFactory
) {
}
public function isApplicable(): bool
{
return $this->registry->registry('current_product') !== null;
}
public function getNamespace(): string
{
return 'review';
}
public function getVariables(): array
{
$product = $this->registry->registry('current_product');
$summary = $this->summaryFactory->create();
$obj = new \Magento\Framework\DataObject();
$summary->appendSummaryFieldsToObject($obj, $product->getId(), 0);
$count = (int) $obj->getReviewsCount();
return [
'count' => $count,
'average_rating' => $count
? round(((float) $obj->getRatingSummary()) / 20, 1)
: null,
];
}
}
Register it in etc/frontend/di.xml:
<type name="Byte8\SeoSuite\Model\MetaTemplate\TemplateRenderer">
<arguments>
<argument name="resolvers" xsi:type="array">
<item name="review" xsi:type="object">Vendor\Module\SeoSuite\VariableResolver\Review</item>
</argument>
</arguments>
</type>
The named array merges into the existing pool — you don't replace the built-in resolvers, you add to them.
Now your templates can use:
{{product.name}} ({{review.count}} reviews · ★ {{review.average_rating}})
Adding to the GraphQL path too
The same logic should work in headless context. Two approaches:
Approach A — Stateless context builder. Extend EntityContextBuilder with a method like fromProductReviews(ProductInterface $product, int $storeId): array and have your VariableResolver\Review delegate to it. Then the GraphQL resolver layer can also call it directly.
Approach B — Make the resolver stateless. Drop the Registry dependency and accept the product as a constructor argument injected via DI. Use a virtualType to bind one instance per context.
Approach A matches the pattern used by the built-in EntityContextBuilder and is generally cleaner.
Empty-value handling
If your resolver returns null for a key, the template engine treats it like an empty string and collapses any surrounding separators. So {{review.count}} reviews · ★ {{review.average_rating}} becomes 5 reviews · ★ 4.2 when there are reviews and just disappears when there aren't.
Testing your resolver
Quick sanity check via bin/magento dev:profiler or a one-shot script:
$context = $objectManager->get(\Byte8\SeoSuite\Model\MetaTemplate\TemplateRendererInterface::class)
->getContext();
print_r($context);
// ['product.name' => 'Acme Widget', ..., 'review.count' => 12, 'review.average_rating' => 4.7]
Performance
Resolvers are called once per page render and the context is memoized by the renderer for that request. Heavy database lookups inside getVariables() are fine for the frontend path (one product per page) but should be avoided for catalog-wide CLI runs — the AI Meta Generator currently calls the renderer per-entity, so a slow resolver multiplies linearly across a batch.
Pluggable structured data
The same pluggability pattern applies to JSON-LD providers — see Custom JSON-LD providers.