Markus Michalski FAQ

Sylius SEO Suite Pro: Extending

Extension points of the Sylius SEO plugin: six service tags with interfaces and examples, sitemap integration, translation class opt-in, Twig hooks, Twig functions and internal classes that are not extension API.

Deutsche Version

Back to overview

Sylius SEO Suite Pro: Extending

This page describes every place where you extend the plugin with your own logic: six service tags with interfaces, the sitemap integration, the opt-in of the translation classes, the Twig hooks and the Twig functions. At the end it lists which classes are internal details and not part of the extension API.

Contents

Basic principle: implement the interface, done

Each of the six extension points is an interface. Every service that implements it gets the matching service tag automatically (autoconfiguration). You do not add a tag by hand. The prerequisite is that the class is registered as a service with autoconfigure enabled, which is the case in a standard Symfony setup with services: _defaults: autoconfigure: true for src/.

Extension point Interface Service tag
schema.org type Mmd\SyliusSeoSuiteProPlugin\Schema\SchemaProviderInterface mmd_sylius_seo_suite_pro.schema_provider
Fields in the Product node Mmd\SyliusSeoSuiteProPlugin\Schema\ProductSchemaEnricherInterface mmd_sylius_seo_suite_pro.product_schema_enricher
hreflang for your own page types Mmd\SyliusSeoSuiteProPlugin\Hreflang\HreflangAlternatesProviderInterface mmd_sylius_seo_suite_pro.hreflang_provider
Your own audit check Mmd\SyliusSeoSuiteProPlugin\Audit\AuditCheckInterface mmd_sylius_seo_suite_pro.audit_check
OG image for your own object types Mmd\SyliusSeoSuiteProPlugin\OgImage\OgImageProviderInterface mmd_sylius_seo_suite_pro.og_image_provider
Your own placeholders for SEO templates Mmd\SyliusSeoSuiteProPlugin\Seo\Template\PlaceholderProviderInterface mmd_sylius_seo_suite_pro.placeholder_provider

The built-in implementations run through the same tags. Your class therefore stands on equal footing with them, and the priority decides the order.

After adding a new class, run bin/console cache:clear once so the container picks it up.

Setting a priority

The default is 0. Higher numbers come first. How you set a priority depends on how the service is registered.

With #[AsTaggedItem] you set the priority on the class. The tag stays autoconfigured, so there is only one entry in the container.

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(priority: 50)]
final class MyProvider implements SchemaProviderInterface
{
    // ...
}

Explicit tag

If you configure the service yourself in config/services.yaml, turn autoconfigure off for it. Otherwise it carries the tag twice (once automatically without a priority, once by hand with one).

services:
    App\Seo\MyProvider:
        autoconfigure: false
        tags:
            - { name: 'mmd_sylius_seo_suite_pro.schema_provider', priority: 50 }

Without a priority the service is sorted in with the default 0. With equal priority, the order of registration decides.

Schema provider

A schema provider produces a schema.org node for the page's JSON-LD, for example FAQPage or LocalBusiness. The built-in providers (Product, BreadcrumbList, Organization, WebSite) use the same tag. Basics are on the Structured data page.

interface SchemaProviderInterface
{
    public function supports(SchemaContext $context): bool;

    /**
     * @return array<string, mixed>
     */
    public function provide(SchemaContext $context): array;
}

SchemaContext has the properties subject (product or taxon, null on the home page), homepage (bool), locale, channel and meta (the already resolved MetaData with title, description and canonical). An empty array from provide() means: emit nothing.

<?php

declare(strict_types=1);

namespace App\Seo;

use Mmd\SyliusSeoSuiteProPlugin\Schema\SchemaContext;
use Mmd\SyliusSeoSuiteProPlugin\Schema\SchemaProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(priority: 3)]
final class LocalBusinessSchemaProvider implements SchemaProviderInterface
{
    public function supports(SchemaContext $context): bool
    {
        return $context->homepage;
    }

    public function provide(SchemaContext $context): array
    {
        return [
            '@context' => 'https://schema.org',
            '@type' => 'LocalBusiness',
            'name' => (string) $context->channel->getName(),
            'url' => $context->meta->canonicalUrl,
        ];
    }
}

Ordering: a higher priority puts the node further forward in the JSON-LD. Built in are Product 30, BreadcrumbList 20, Organization 10 (home page only) and WebSite 5 (home page only). If a provider throws an exception, it is logged and the provider is skipped. The remaining nodes are kept.

Every property a provider emits needs a visible counterpart on the page. Search engines ignore structured data that visitors cannot see, and invented ratings can lead to a manual action. The plugin does not enforce this.

Product schema enricher

An enricher adds fields to the existing Product node instead of building a node of its own. Typical uses are a GTIN per variant or an energy label.

interface ProductSchemaEnricherInterface
{
    /**
     * @param array<string, mixed> $node the Product node built so far
     *
     * @return array<string, mixed> the node to emit (the unchanged node when there is nothing to add)
     */
    public function enrich(array $node, ProductInterface $product, SchemaContext $context): array;
}
<?php

declare(strict_types=1);

namespace App\Seo;

use Mmd\SyliusSeoSuiteProPlugin\Schema\ProductSchemaEnricherInterface;
use Mmd\SyliusSeoSuiteProPlugin\Schema\SchemaContext;
use Sylius\Component\Core\Model\ProductInterface;

final class ItemConditionEnricher implements ProductSchemaEnricherInterface
{
    public function enrich(array $node, ProductInterface $product, SchemaContext $context): array
    {
        $node['itemCondition'] = 'https://schema.org/NewCondition';

        return $node;
    }
}

Ordering: a higher priority runs first, and a later enricher sees the result of the earlier ones and may change it. The built-in enricher for attributes (GTIN, MPN, brand, additionalProperty) has priority 0. If an enricher throws, it is logged and skipped, and the node stays as it was before.

hreflang provider

An hreflang provider only says in which languages of a channel a page exists. Grouping of channels, duplicate values, x-default and the hostname come from the builder. See also hreflang.

interface HreflangAlternatesProviderInterface
{
    public function supports(HreflangContext $context): bool;

    /**
     * @return list<HreflangTarget>
     */
    public function targets(HreflangContext $context, ChannelInterface $channel): array;
}

HreflangContext has subject (null on the home page), homepage, channel and locale. A HreflangTarget consists of locale, route and parameters (the route parameters, including _locale); the builder turns it into the absolute URL on the channel's host. Only return languages that have a usable page, never an entry with an empty slug.

Built in are providers for product, category and home page. The SeoDataProvider only calls the builder for these three page types. A custom page type has to ask the builder itself (service mmd_sylius_seo_suite_pro.hreflang_alternates_builder).

<?php

declare(strict_types=1);

namespace App\Seo;

use App\Entity\BrandInterface;
use Mmd\SyliusSeoSuiteProPlugin\Hreflang\HreflangAlternatesProviderInterface;
use Mmd\SyliusSeoSuiteProPlugin\Hreflang\HreflangContext;
use Mmd\SyliusSeoSuiteProPlugin\Hreflang\HreflangTarget;
use Sylius\Component\Core\Model\ChannelInterface;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(priority: 10)]
final class BrandHreflangProvider implements HreflangAlternatesProviderInterface
{
    public function supports(HreflangContext $context): bool
    {
        return $context->subject instanceof BrandInterface;
    }

    public function targets(HreflangContext $context, ChannelInterface $channel): array
    {
        $brand = $context->subject;
        $targets = [];

        foreach ($channel->getLocales() as $locale) {
            $slug = $brand->getSlug((string) $locale->getCode());
            if (null !== $slug) {
                $targets[] = new HreflangTarget((string) $locale->getCode(), 'app_brand_show', ['slug' => $slug, '_locale' => (string) $locale->getCode()]);
            }
        }

        return $targets;
    }
}

BrandInterface, getSlug() and the route app_brand_show are placeholders for your own classes.

Ordering: the first provider (highest priority first) whose supports() matches is used, all others are not asked. The built-in providers have priority 0. With a priority above 0, your provider is asked before them. Unlike schema providers, no single provider is skipped here: if anything goes wrong while building the alternates, the plugin logs the error and the page renders without any hreflang tags.

Audit check

A check delivers findings for exactly one code and runs with every audit run. For checks that decide per subject, extend AbstractAuditCheck, which implements start() and finish() as empty methods. See also Audit.

interface AuditCheckInterface
{
    /** Lower-case letters, digits and underscores, at most 64 characters. */
    public function code(): string;

    public function start(string $channelCode): void;

    /** @return list<AuditFinding> */
    public function inspect(AuditSubject $subject): array;

    /** @return list<AuditFinding> */
    public function finish(): array;
}

The runner calls start() once per run and channel, inspect() for every product and category in every language of the channel, and finish() at the end. A check that has to see all subjects first (duplicates) collects in inspect() and reports in finish(). The check object is reused across runs, so start() must drop any old state.

AuditSubject has type, id, code, locale, title, description, titleFromName, noIndex and hasImage (null for categories). AuditFinding::forSubject($subject, $checkCode, $severity, $detail) builds a finding. The severities are AuditIssueInterface::SEVERITY_ERROR, SEVERITY_WARNING and SEVERITY_INFO. The detail is a short, language-neutral value (at most 255 characters).

<?php

declare(strict_types=1);

namespace App\Seo;

use Mmd\SyliusSeoSuiteProPlugin\Audit\AbstractAuditCheck;
use Mmd\SyliusSeoSuiteProPlugin\Audit\AuditFinding;
use Mmd\SyliusSeoSuiteProPlugin\Audit\AuditSubject;
use Mmd\SyliusSeoSuiteProPlugin\Entity\AuditIssueInterface;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(priority: 5)]
final class DescriptionTooShortCheck extends AbstractAuditCheck
{
    public function code(): string
    {
        return 'description_too_short';
    }

    public function inspect(AuditSubject $subject): array
    {
        $length = mb_strlen($subject->description);

        if ($length > 0 && $length < 50) {
            return [AuditFinding::forSubject($subject, $this->code(), AuditIssueInterface::SEVERITY_INFO, (string) $length)];
        }

        return [];
    }
}

For the display in the admin, add two translations to your translations/messages.<locale>.yaml. Without them the admin shows the code and the raw detail value.

mmd_sylius_seo_suite_pro:
    audit:
        check:
            description_too_short: 'Description too short'
        detail:
            description_too_short: '%detail% characters'

Ordering: a higher priority runs first. Built in are title_missing 80, title_too_long 70, title_duplicate 60, description_missing 50, description_too_long 40, description_duplicate 30, product_without_image 20 and noindex 10. Unlike providers, checks are not skipped: if a check throws, the run for that channel aborts, the error is logged and the run is marked as failed.

OG image provider

An OG image provider makes a further object type (brands, CMS pages) available for generated social cards. Built in are product and category. See also OG images.

interface OgImageProviderInterface
{
    public function getType(): string;
    public function supports(object $subject): bool;
    public function isEnabled(OgImageSettingsInterface $settings): bool;
    public function identify(object $subject): ?string;
    public function find(string $code, ChannelInterface $channel): ?object;
    public function describe(object $subject, OgImageContext $context): ?OgImageSpec;
    public function fallbackImageUrl(object $subject): ?string;

    /** @return iterable<list<string>> */
    public function codes(ChannelInterface $channel, int $pageSize): iterable;
}
Method Responsibility
getType() Type segment of the card URL and the cache path: lower-case letters, digits, underscores, starting with a letter (product, taxon)
supports() Does the object belong to this provider?
isEnabled() Does the channel switch cards of this type on? A custom type has no switch of its own in the settings, $settings->isEnabled() is a sensible choice
identify() The stable code the card is addressed by (the product code for products); null for objects without a card
find() The object for a code, otherwise null
describe() What the card says, as an OgImageSpec (title, price text, image source, extra line). null when the object has no page in the channel
fallbackImageUrl() Absolute URL of the normal image when no card can be made
codes() All codes of the channel in pages of $pageSize, for pre-rendering

describe() runs while the page is built. It must therefore not read an image file, fetch a URL or render anything. AbstractOgImageProvider is available as a base with the helper methods name() and firstImageSource().

<?php

declare(strict_types=1);

namespace App\Seo;

use App\Entity\BrandInterface;
use App\Repository\BrandRepositoryInterface;
use Mmd\SyliusSeoSuiteProPlugin\Entity\OgImageSettingsInterface;
use Mmd\SyliusSeoSuiteProPlugin\OgImage\AbstractOgImageProvider;
use Mmd\SyliusSeoSuiteProPlugin\OgImage\OgImageContext;
use Mmd\SyliusSeoSuiteProPlugin\OgImage\OgImageSpec;
use Sylius\Component\Core\Model\ChannelInterface;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(priority: 5)]
final class BrandOgImageProvider extends AbstractOgImageProvider
{
    public function __construct(private BrandRepositoryInterface $brands)
    {
    }

    public function getType(): string
    {
        return 'brand';
    }

    public function supports(object $subject): bool
    {
        return $subject instanceof BrandInterface;
    }

    public function isEnabled(OgImageSettingsInterface $settings): bool
    {
        return $settings->isEnabled();
    }

    public function identify(object $subject): ?string
    {
        return $subject instanceof BrandInterface ? $subject->getCode() : null;
    }

    public function find(string $code, ChannelInterface $channel): ?object
    {
        return $this->brands->findOneBy(['code' => $code]);
    }

    public function describe(object $subject, OgImageContext $context): ?OgImageSpec
    {
        return $subject instanceof BrandInterface ? new OgImageSpec($subject->getName()) : null;
    }

    public function fallbackImageUrl(object $subject): ?string
    {
        return null;
    }

    public function codes(ChannelInterface $channel, int $pageSize): iterable
    {
        yield array_map(static fn (BrandInterface $brand): string => $brand->getCode(), $this->brands->findAll());
    }
}

BrandInterface and BrandRepositoryInterface are placeholders for your own classes. The card route and pre-rendering with mmd:seo-suite-pro:og-image:generate work with every registered provider. The plugin only wires the card into the <head> for product and category pages. For pages of your own type, you ask the planner yourself (service mmd_sylius_seo_suite_pro.og_image.planner, interface OgImagePlannerInterface, method plan(object $subject, ChannelInterface $channel)).

Ordering: for supports(), the first matching provider in priority order wins (product 20, category 10). Resolving by the type in the URL (getType()) finds the first provider with that type.

Placeholder provider

A placeholder provider resolves a family of {token} placeholders in SEO templates. A token is accepted in the admin if and only if a provider supports it, so validation always matches what renders. See also SEO templates.

interface PlaceholderProviderInterface
{
    /**
     * @param string $token       the text between the braces, for example `product.name`
     * @param string $subjectType `product`, `taxon` or `other`
     */
    public function supports(string $token, string $subjectType): bool;

    /** Null or a blank string means "no value": the template is then not used at all. */
    public function resolve(string $token, PlaceholderContext $context): ?string;
}

PlaceholderContext has subject, subjectType, channel and locale. With $context->remember($key, $factory) you memoise an expensive lookup per render. Return plain text: you strip the HTML of a rich-text source yourself (PlainText::fromHtml()), and you do not escape anything, the output channel does that.

<?php

declare(strict_types=1);

namespace App\Seo;

use Mmd\SyliusSeoSuiteProPlugin\Entity\SeoTemplateInterface;
use Mmd\SyliusSeoSuiteProPlugin\Seo\Template\PlaceholderContext;
use Mmd\SyliusSeoSuiteProPlugin\Seo\Template\PlaceholderProviderInterface;
use Sylius\Component\Core\Model\ProductInterface;

final class WarrantyPlaceholderProvider implements PlaceholderProviderInterface
{
    public function supports(string $token, string $subjectType): bool
    {
        return 'warranty' === $token && SeoTemplateInterface::SUBJECT_PRODUCT === $subjectType;
    }

    public function resolve(string $token, PlaceholderContext $context): ?string
    {
        return $context->subject instanceof ProductInterface ? '2 years warranty' : null;
    }
}

After that you can use {warranty} in a product template.

Ordering: the first provider whose supports() matches wins, higher priority first. The built-in providers have priority 0. With a priority above 0 you therefore override a built-in placeholder. A placeholder that returns null or an empty string makes the whole template drop out.

Sitemap integration

The plugin does not build an XML sitemap. It only offers the logic for which pages do not belong in one: pages with noindex and pages whose canonical points to another URL.

interface SitemapExclusionInterface
{
    /**
     * @param object $subject a product or taxon; anything else is never excluded
     */
    public function isExcludedFromSitemap(object $subject, string $locale): bool;
}

The service is called mmd_sylius_seo_suite_pro.sitemap_exclusion and is public. The interface SitemapExclusionInterface is autowirable as an alias. The default implementation SitemapExclusion checks the object's translation in the given language: it is excluded when robotsIndex = false or when a canonicalPathOverride does not point to the page itself. It never excludes objects whose translation does not implement SeoAwareTranslationInterface. The second interface TranslationExclusionInterface (isTranslationExcluded(TranslationInterface $translation): bool) provides the same rule for a translation you already hold; the alias points to the same service.

The plugin depends on no sitemap plugin, the wiring is the shop's job. The example decorates the product reader of mmd/sylius-sitemap-plugin and drops the languages that do not belong in the sitemap. The service mmd_sylius_sitemap.product_reader, the interface ProductReaderInterface::read(ChannelContext $context, bool $withImages): iterable and the fields of ProductRecord come from the sitemap plugin's code.

<?php

declare(strict_types=1);

namespace App\Sitemap;

use Mmd\SyliusSeoSuiteProPlugin\Seo\SitemapExclusionInterface;
use Mmd\SyliusSitemapPlugin\Catalog\ProductReaderInterface;
use Mmd\SyliusSitemapPlugin\Catalog\ProductRecord;
use Mmd\SyliusSitemapPlugin\Sitemap\ChannelContext;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
use Sylius\Component\Core\Repository\ProductRepositoryInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;

#[AsDecorator('mmd_sylius_sitemap.product_reader')]
final class SeoAwareProductReader implements ProductReaderInterface
{
    public function __construct(
        private ProductReaderInterface $inner,
        private ProductRepositoryInterface $products,
        private ChannelRepositoryInterface $channels,
        private SitemapExclusionInterface $exclusion,
    ) {
    }

    public function read(ChannelContext $context, bool $withImages): iterable
    {
        $channel = $this->channels->findOneByCode($context->code);
        if (null === $channel) {
            yield from $this->inner->read($context, $withImages);

            return;
        }

        foreach ($this->inner->read($context, $withImages) as $record) {
            $slugs = [];
            foreach ($record->slugs as $locale => $slug) {
                $product = $this->products->findOneByChannelAndSlug($channel, $locale, $slug);
                if (null === $product || !$this->exclusion->isExcludedFromSitemap($product, $locale)) {
                    $slugs[$locale] = $slug;
                }
            }

            if ([] !== $slugs) {
                yield new ProductRecord($record->updatedAt, $slugs, $record->names, $record->imagePaths);
            }
        }
    }
}

This costs one lookup per product and language, which does not matter because the sitemap is generated by a console command. The same idea applies to the sitemap plugin's category reader.

The example is a sketch checked against the sitemap plugin's source code, but it is not shipped as finished code. Check it against the version of the sitemap plugin you use.

Opt-in of the translation classes

The per-product and per-category SEO fields only appear if your application's translation classes bring them along. The plugin cannot wire this up on its own because the columns belong in your tables. The instructions are on the Installation page.

Interface and trait Fields For
Mmd\SyliusSeoSuiteProPlugin\Model\SeoAwareTranslationInterface with SeoAwareTranslationTrait metaTitle, canonicalPathOverride, robotsIndex, robotsFollow Product and taxon translation
Mmd\SyliusSeoSuiteProPlugin\Model\MetaDescriptionAwareTranslationInterface with MetaDescriptionAwareTranslationTrait metaDescription Taxon translation (Sylius already has this field on the product)

The methods of the interface:

interface SeoAwareTranslationInterface
{
    public function getMetaTitle(): ?string;
    public function setMetaTitle(?string $metaTitle): static;
    public function getCanonicalPathOverride(): ?string;
    public function setCanonicalPathOverride(?string $canonicalPathOverride): static;
    public function isRobotsIndex(): bool;
    public function setRobotsIndex(bool $robotsIndex): static;
    public function isRobotsFollow(): bool;
    public function setRobotsFollow(bool $robotsFollow): static;
}

The traits create the columns with the prefix mmd_seo_suite_pro_, add the validation and attach the serialization groups of the Sylius Admin API (see Shop API). You need a migration in your application for the columns. The default for robotsIndex and robotsFollow is true, so existing rows stay indexable. The plugin only adds the form fields in the admin if the class implements the interface.

Twig hooks

The plugin works with Twig hooks and overrides no templates.

Hooked into Sylius (the hook names where the plugin registers content):

Hook Hookable Purpose
sylius_shop.base#metatags mmd_seo_suite_pro_seo Renders meta tags, canonical, hreflang, Open Graph, Twitter cards and JSON-LD in the <head>
sylius_admin.product.create.content.form.sections.translations and sylius_admin.product.update.content.form.sections.translations mmd_seo_suite_pro_seo_fields SEO fields in the product translation
sylius_admin.taxon.create.content.sections.form.translations and sylius_admin.taxon.update.content.sections.form.translations mmd_seo_suite_pro_seo_fields, mmd_seo_suite_pro_meta_description SEO fields and meta description in the taxon translation

The plugin also hooks buttons and title blocks into the list pages of its resources (for example sylius_admin.seo_template.index.content.header.title_block.actions).

Provided by the plugin: two pages without a resource of their own assemble their admin layout from hook names of their own, to which you can attach your own hookables.

Hook root Page Sub-hooks
mmd_sylius_seo_suite_pro.redirect_import.import CSV import of redirects .content, .content.header, .content.header.title_block
mmd_sylius_seo_suite_pro.seo_audit.index Audit dashboard .content, .content.header, .content.header.title_block

The plugin's hookable keys in other hooks carry the prefix mmd_seo_suite_pro_. Use your own, preferably unique key for your own hookables, because Twig hooks merge entries of the same name from all sources under one key.

Twig functions

Function Description
mmd_seo_suite_pro_seo() Returns the SeoData of the current page, or null on pages that are neither product, category nor home page. The result is cached per request.
mmd_seo_suite_pro_json_ld(node) Turns a JSON-LD node into HTML-safe JSON for a <script type="application/ld+json">. Character sequences that would end the script element early are escaped.

With mmd_seo_suite_pro_seo() you can also use the SEO data in your own templates, for example for a custom canonical link. SeoData has the properties meta, openGraph, twitterCard, structuredData and alternates.

The Twig function mmd_seo_suite_pro_plugin_version() and the filter mmd_seo_channel_name are internal helpers for the admin and are not part of the extension API.

Stability and internal classes

The plugin documents no formal backward compatibility promise (BC promise). What is binding are the statements in the source code: classes marked @internal Not part of the extension API can change in any minor release, including their constructors. The interfaces and models on this page are the extension API you can rely on.

Marked @internal and not part of the extension API are:

Methods that the source marks as @internal for tests (resetting internal flags) are likewise not meant for use in your code.

If you need to change something that is not listed here as an extension point, decorate the matching service instead of inheriting from a class. Constructors of plugin classes can change without announcement.

Technical background on the structure is on the Technical page.