Skip to main content

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 Provider that 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 null rather than a partial one. Partial JSON-LD is worse than no JSON-LD.

Next