Sylius SEO Suite Pro: hreflang
hreflang in Sylius SEO Suite Pro: which pages belong together, channel groups, rules for listed languages, x-default, admin settings and output in the storefront and Shop API.
Sylius SEO Suite Pro: hreflang
The plugin renders <link rel="alternate" hreflang="..." href="..."> in the <head> of product pages, category listings and the home page. The same list is available in the Shop API in the field alternates. A page with fewer than two alternates gets nothing, so a shop with one language per channel renders exactly what it rendered before.
<link rel="alternate" hreflang="de-DE" href="https://shop.example.de/de_DE/products/blaues-sofa">
<link rel="alternate" hreflang="en-US" href="https://shop.example.com/en_US/products/blue-sofa">
<link rel="alternate" hreflang="x-default" href="https://shop.example.de/de_DE/products/blaues-sofa">
Which pages belong together
Channels are often separate shops with an assortment of their own, not translations of each other. The plugin therefore never guesses across channels:
- Without a group, the alternates are the languages of the page's own channel: the same product, category or home page in every language of the channel, on the channel's host.
- With a group (SEO Suite Pro > hreflang, one row per channel, the same
group_codein each row), the same product or category code is linked across all active channels of the group, every channel on its own hostname, using the scheme of the current request. A product or category only takes part in a channel where it is available. The languages of every channel count.
Products and categories are matched by their code. For the home page, the home page of every channel in the group counts.
The set is always built from the group, never from the page that is being rendered. Every page of a set outputs the same complete list, itself included.
Example: two channels in one group
The channel shop_de (hostname shop.example.de, language de_DE) and the channel shop_en (hostname shop.example.com, language en_US) share the same group_code:
| Channel | Enabled | hreflang group | x-default language |
|---|---|---|---|
shop_de |
yes | main |
empty |
shop_en |
yes | main |
empty |
The product is sold in both channels and has the slugs blaues-sofa (de_DE) and blue-sofa (en_US). Both product pages then carry the same block:
<link rel="alternate" hreflang="de-DE" href="https://shop.example.de/de_DE/products/blaues-sofa">
<link rel="alternate" hreflang="en-US" href="https://shop.example.com/en_US/products/blue-sofa">
<link rel="alternate" hreflang="x-default" href="https://shop.example.de/de_DE/products/blaues-sofa">
The x-default points at the default language of the first channel in the group (lowest row ID), here shop_de.
Decision flow
graph TD
A["Page is rendered"] --> B{"Channel setting enabled?"}
B -->|"No"| Z["No hreflang links"]
B -->|"Yes"| C{"Page is canonical: no noindex, no facet, not page 2 or later, no differing canonical?"}
C -->|"No"| Z
C -->|"Yes"| D["Build the set: languages of the channel or of all active group channels"]
D --> E["One page per hreflang value: duplicates are dropped"]
E --> F{"Is the page itself in the set?"}
F -->|"No"| Z
F -->|"Yes"| G{"Enough alternates and enough distinct URLs?"}
G -->|"No"| Z
G -->|"Yes"| H["Determine x-default and append it"]
H --> I["Output alternates in the head and in the Shop API"]
Rules: when a language is listed
- Value: the Sylius locale with a hyphen.
de_DEbecomesde-DE, the same rule as the sitemap plugin. Search engines only accept an ISO 3166-1 alpha-2 region, so a numeric region is dropped:es_419becomeses. Withhreflang.format: language, only the language remains (de). - One page per value: if two channels of a group would claim the same value (two channels with
de_DE, orde_DEandde_ATin thelanguageformat), that is a configuration error. The channel whose row has the lower ID keeps the value, the other one drops out of the set, and a warning is logged. The page of the dropped channel then gets no hreflang at all, because it would not be part of its own set. - Pages that exist: a disabled product or category, a product that is not sold in the channel, a category outside the channel's menu category (or below a disabled one), and a language without a translation or without a slug are left out. The root of a category tree is a grouping node without a page of its own.
- Pages that are indexable: a translation with
noindexor with a canonical that points elsewhere stays out of every set (the same rule asSitemapExclusionInterface), and such a page gets no alternates itself. The same applies to filtered or sorted listings (any request with a facet parameter) and to page 2 and later of a listing. - Hostname: every channel of a group needs a hostname, otherwise no other channel can link to it. It then drops out of their alternates, and a warning is logged once.
- Minimum:
hreflang.min_alternates(default 2). x-default is not counted. There must also be at least that many distinct URLs: a shop that serves every language from one URL (no locale prefix) gets nothing.
A page has to be part of its own set (self-reference). If its own entry is missing, for example because it is noindex or its value was taken by another channel, no alternates are output at all, not even for the other languages.
x-default
There is exactly one x-default per set. It is determined like this:
- Each channel of the group is asked in turn, starting with the lowest row ID.
- If the channel has an x-default language set, the page in that language counts. It may also belong to another channel of the group (the form accepts any of them), the channel's own page is preferred.
- If no language is set, the channel's default language counts.
- If that page does not exist, the next channel is asked.
- If no channel has a matching page, the first alternate is used as x-default.
Handling duplicate values
| Situation | Format | Result |
|---|---|---|
Two channels of a group have de_DE |
any | The channel with the lower row ID keeps de-DE, the other one drops out. Saving in the admin is refused. |
One channel has de_DE, another de_AT |
language-region |
Two values de-DE and de-AT, all fine |
One channel has de_DE, another de_AT |
language |
Both become de: a conflict as above |
Settings
Two keys under mmd_sylius_seo_suite_pro.hreflang:
| Setting | Default | Description |
|---|---|---|
hreflang.format |
language-region |
How the value is written: language-region turns de_DE into de-DE, language keeps only the language (de) |
hreflang.min_alternates |
2 |
A page only gets hreflang links when its set has at least this many alternates (x-default not counted). At least 1. |
# config/packages/mmd_sylius_seo_suite_pro.yaml
mmd_sylius_seo_suite_pro:
hreflang:
format: language-region
min_alternates: 2
More on this on the Configuration page.
Settings in the admin
Open SEO Suite Pro > hreflang button. There is one optional row per channel, a channel without a row uses the defaults (hreflang on, no group, the channel's default language as x-default). There is no sidebar entry of its own, use Back to SEO Suite Pro to return.
| Field | Default | Description |
|---|---|---|
| Channel | One row per channel, each channel only once | |
| Output hreflang links | on | Off: the channel outputs no links and is not linked by the other channels of its group either |
| hreflang group | empty | Optional code of lower-case letters, digits, - and _, starting with a letter or digit, at most 64 characters. The same code means the channels are alternates of each other. |
| x-default language | empty | Language for visitors without a matching language. Must belong to the channel (or, in a group, to a channel of the group). Empty means the channel's default language. In a group, the setting of the first channel (lowest row) counts. |
On save, ValidHreflangSettings checks:
- The x-default language must be offered by this channel or, in a group, by a channel of the group.
- Every active channel of a group needs a hostname.
- No hreflang value may occur twice (calculated with the configured
hreflang.format). This also applies to the languages of a single channel without a group, for examplede_DEandde_ATin thelanguageformat.
The priority in a group is the row ID. The lowest ID wins on duplicates and decides the x-default. A newly created row always has the highest ID and therefore comes last.
Own page types
If you want to add hreflang to your own page types (landing pages, for example), implement Mmd\SyliusSeoSuiteProPlugin\Hreflang\HreflangAlternatesProviderInterface. The provider only says in which languages of a channel a page exists. Grouping, duplicates, x-default and host are handled by the plugin. The details are on the Extending page. Product, category and home page are built in.
Storefront and Shop API
In the storefront, the template renders a link for every entry of seo.alternates. This covers product pages, category listings and the home page.
The Shop API returns the same list in the alternates field of the Seo resource, built by the same service:
GET /shop/seo/products/{code}GET /shop/seo/taxons/{code}
Both paths sit under your shop's API prefix (Sylius uses /api/v2 by default). The field is a list of {hreflang, href}, empty when the page gets no alternates:
{
"alternates": [
{ "hreflang": "de-DE", "href": "https://shop.example.de/de_DE/products/blaues-sofa" },
{ "hreflang": "en-US", "href": "https://shop.example.com/en_US/products/blue-sofa" },
{ "hreflang": "x-default", "href": "https://shop.example.de/de_DE/products/blaues-sofa" }
]
}
The URLs use the channels' hostnames and the scheme of the API request. A headless frontend on another host maps them itself. There is no API endpoint for the home page, its alternates are rendered by Twig only. More about the API is on the Shop API page.
Cost
The settings of all channels are read with one query per request, one more query fetches the page's translations, and all channels with locales and menu category come in a single query. Neither the number of languages nor the number of channels in a group adds queries. If the table is missing (migration not run yet), the defaults apply and a warning is logged once. If building the alternates fails, the page is rendered without them, never with an error.
More help: Troubleshooting.