Custom JSON-LD providers
Add FAQ, Article, Video, LocalBusiness, Recipe, or any other Schema.org type by implementing one interface and registering it in DI.
The interface
namespace Byte8\SeoSuite\Model\StructuredData;
interface ProviderInterface
{
public function getCode(): string;
public function isApplicable(): bool;
/** @return array<string, mixed>|array<int, array<string, mixed>>|null */
public function getData(): ?array;
}
Returning a single associative array → one <script> tag. Returning a list of arrays → one <script> tag per item. Returning null → no output.
Recipe — FAQ schema for CMS pages
Suppose your CMS pages embed FAQ content in a structured way (custom widget, Page Builder block, or content blocks tagged with data-faq). You can extract them and emit FAQ schema:
namespace Vendor\Module\StructuredData;
use Byte8\SeoSuite\Model\StructuredData\ProviderInterface;
use Magento\Cms\Api\Data\PageInterface;
class FaqProvider implements ProviderInterface
{
public function __construct(
private readonly PageInterface $page
) {
}
public function getCode(): string
{
return 'faq';
}
public function isApplicable(): bool
{
return $this->page->getId() && $this->extractQuestions() !== [];
}
public function getData(): ?array
{
$questions = $this->extractQuestions();
if (!$questions) {
return null;
}
return [
'@context' => 'https://schema.org/',
'@type' => 'FAQPage',
'mainEntity' => array_map(static fn ($q) => [
'@type' => 'Question',
'name' => $q['question'],
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $q['answer'],
],
], $questions),
];
}
/**
* @return array<int, array{question: string, answer: string}>
*/
private function extractQuestions(): array
{
// Parse $this->page->getContent() — implementation up to you.
// E.g. dom-load and select [data-faq-question] / [data-faq-answer] pairs.
return [];
}
}
Register in etc/frontend/di.xml:
<type name="Byte8\SeoSuite\Model\StructuredData\ProviderPool">
<arguments>
<argument name="providers" xsi:type="array">
<item name="faq" xsi:type="object">Vendor\Module\StructuredData\FaqProvider</item>
</argument>
</arguments>
</type>
The named-array merge means built-in providers (product, breadcrumb, organization, website) keep working — you're adding to the pool, not replacing it.
Multi-document providers
If your provider returns:
return [
['@type' => 'Recipe', 'name' => 'Recipe A', /* … */],
['@type' => 'Recipe', 'name' => 'Recipe B', /* … */],
];
…the renderer detects the list-of-arrays shape (numeric 0 key + nested array) and emits one <script> per item.
Surfacing custom providers via GraphQL
The current GraphQL resolvers (Resolver/Product/Seo, Resolver/Category/Seo, Resolver/Cms/Seo) call specific stateless Builder classes directly — they don't iterate the ProviderPool. So a custom Provider registered in the pool will show up on the storefront but not in the seo.structured_data GraphQL field.
If you need GraphQL parity, two options:
Option A — extend the resolver. Use a plugin around the resolver's resolve() method that appends your JSON to the structured_data array.
Option B — make your provider stateless and call it directly. Build a stateless Builder class for your schema, then both:
- A
Providerthat wraps it for the storefront (registered in the pool), and - A plugin/extension on the GraphQL resolver that calls the same
Builder
Option B is the cleaner long-term pattern and matches how the four built-in providers are structured.
Best practices
- Keep
isApplicable()cheap — it's called on every page render even when the schema won't ultimately emit - Validate against Google's Rich Results Test before shipping a new schema type — Google's tolerance for invalid schema varies by type
- Fail closed — if you can't build a complete document, return
nullrather than a partial one. Partial JSON-LD is worse than no JSON-LD.
Next
- GraphQL → SEO Metadata — the resolver layer your custom builder might extend