Sylius SEO Suite Pro: Shop API
Shop API of the Sylius SEO plugin in dual mode: endpoints, fields, product and taxon examples, error codes, mapping to head tags, and reading and writing SEO fields through the Sylius Admin API.
Sylius SEO Suite Pro: Shop API
The plugin works in dual mode: a Twig storefront gets its SEO tags through a Twig hook in the <head>, a headless frontend fetches the same data through the Shop API. Both paths call the same service (SeoDataProviderInterface), so they cannot drift apart. In addition, the per-product and per-category SEO fields can be read and written through the Sylius Admin API.
Contents
- Dual mode at a glance
- Endpoints
- Response fields
- Example: product
- Example: category
- Mapping: storefront tag to API field
- Errors and status codes
- Channel, language and access
- Admin API: reading and writing SEO fields
- Not available through the API
Dual mode at a glance
graph TD
A["Storefront: Twig hook metatags in sylius_shop.base"] --> B["Twig function mmd_seo_suite_pro_seo"]
C["Headless: GET /api/v2/shop/seo/products/code"] --> D["SeoResourceProvider"]
B --> E["SeoDataProviderInterface"]
D --> E
E --> F["SeoData"]
F --> G["Template metatags.html.twig: meta tags, canonical, hreflang, JSON-LD in the head"]
F --> H["SeoResource: JSON response"]
The plugin delivers the data, not a frontend: a headless client builds its own <head> tags from the JSON response. The response deliberately contains finished values (title, canonical, robots directives, JSON-LD nodes), so the client does not have to rebuild any SEO logic.
Endpoints
The resource is called Seo and is read-only. The paths sit below the Sylius Shop API prefix (/api/v2).
| Method | Path | Description |
|---|---|---|
| GET | /api/v2/shop/seo/products/{code} |
SEO data of a product, keyed by the product code |
| GET | /api/v2/shop/seo/taxons/{code} |
SEO data of a category (taxon), keyed by the taxon code |
For the taxon endpoint, pass the filter and sort parameters of the listing page on in the query string, for example ?sorting[name]=asc. The plugin applies the same facet rule as in the storefront: a filtered or sorted listing request gets noindex, follow and a canonical without filter parameters. Details are on the Configuration page.
There is no endpoint for the home page, no list endpoints and no write operations.
Response fields
All fields belong to the serialization group mmd_seo:read.
| Field | Type | Description |
|---|---|---|
code |
string | The requested product or taxon code |
title |
string | Page title after the priority chain (manual value, SEO template, fallback) |
description |
string | Meta description after the same chain, may be an empty string |
canonicalUrl |
string or null | Absolute canonical URL of the page |
robots |
string or null | Finished value for the robots meta tag, for example index, follow; null when no directive is set |
openGraph |
object or null | Open Graph data; null when social tags are switched off for the channel |
twitterCard |
object or null | Twitter card data; null when social tags are switched off for the channel |
structuredData |
array | List of JSON-LD (schema.org) nodes, each an object with @context and @type; empty when nothing is emitted |
alternates |
array | List of hreflang alternates as {hreflang, href}; empty when the page gets none |
Structure of openGraph:
| Field | Type | Description |
|---|---|---|
title |
string | og:title |
description |
string | og:description |
url |
string | og:url, equal to the canonical URL |
type |
string | og:type, product for products, website otherwise |
image |
string or null | og:image, absolute URL |
imageAlt |
string or null | og:image:alt |
siteName |
string or null | og:site_name (channel setting, otherwise the channel name) |
locale |
string or null | og:locale, the locale code of the request |
imageWidth |
int | og:image:width, only present when a generated OG image is used |
imageHeight |
int | og:image:height, only with a generated OG image |
imageType |
string | og:image:type, only with a generated OG image |
Structure of twitterCard:
| Field | Type | Description |
|---|---|---|
card |
string | summary or summary_large_image (as soon as an image exists) |
title |
string | twitter:title |
description |
string | twitter:description |
image |
string or null | twitter:image |
imageAlt |
string or null | twitter:image:alt |
site |
string or null | twitter:site (channel setting) |
creator |
string or null | twitter:creator (channel setting) |
Size and type of the image (
imageWidth,imageHeight,imageType) are only known for a generated OG image. Without a generated card these three keys are missing entirely, they do not appear asnull. More about the cards on the OG images page.
The Structured data and hreflang pages describe which nodes and alternates are produced and when.
Example: product
The examples use Accept: application/ld+json. That is the format Sylius serves the API in by default, and the keys @context, @id and @type come from API Platform. The values are made up, the structure matches the real response.
curl -s "https://shop.example/api/v2/shop/seo/products/BLUE-SOFA" \
-H "Accept: application/ld+json" \
-H "Accept-Language: en_US"
{
"@context": "/api/v2/contexts/Seo",
"@id": "/api/v2/shop/seo/products/BLUE-SOFA",
"@type": "Seo",
"code": "BLUE-SOFA",
"title": "Buy the blue sofa | Example Shop",
"description": "The blue fabric sofa, in stock and ready to ship.",
"canonicalUrl": "https://shop.example/en_US/products/blue-sofa",
"robots": "index, follow",
"openGraph": {
"title": "Buy the blue sofa | Example Shop",
"description": "The blue fabric sofa, in stock and ready to ship.",
"url": "https://shop.example/en_US/products/blue-sofa",
"type": "product",
"image": "https://shop.example/media/image/blue-sofa.jpg",
"imageAlt": "Blue sofa",
"siteName": "Example Shop",
"locale": "en_US"
},
"twitterCard": {
"card": "summary_large_image",
"title": "Buy the blue sofa | Example Shop",
"description": "The blue fabric sofa, in stock and ready to ship.",
"image": "https://shop.example/media/image/blue-sofa.jpg",
"imageAlt": "Blue sofa",
"site": "@exampleshop",
"creator": null
},
"structuredData": [
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Buy the blue sofa | Example Shop",
"description": "The blue fabric sofa, in stock and ready to ship.",
"sku": "BLUE-SOFA",
"image": ["https://shop.example/media/image/blue-sofa.jpg"],
"offers": {
"@type": "Offer",
"url": "https://shop.example/en_US/products/blue-sofa",
"sku": "BLUE-SOFA",
"price": "799.00",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
}
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Sofas", "item": "https://shop.example/en_US/taxons/sofas" },
{ "@type": "ListItem", "position": 2, "name": "Blue sofa", "item": "https://shop.example/en_US/products/blue-sofa" }
]
}
],
"alternates": [
{ "hreflang": "en-US", "href": "https://shop.example/en_US/products/blue-sofa" },
{ "hreflang": "de-DE", "href": "https://shop.example/de_DE/products/blaues-sofa" },
{ "hreflang": "x-default", "href": "https://shop.example/en_US/products/blue-sofa" }
]
}
Example: category
A sorted listing request is recognised as a facet: robots is then noindex, follow, the canonical stays the parameter-free listing URL, and there are no alternates.
curl -s "https://shop.example/api/v2/shop/seo/taxons/SOFAS?sorting%5Bname%5D=asc" \
-H "Accept: application/ld+json"
{
"@context": "/api/v2/contexts/Seo",
"@id": "/api/v2/shop/seo/taxons/SOFAS",
"@type": "Seo",
"code": "SOFAS",
"title": "Sofas | Example Shop",
"description": "All our sofas at a glance.",
"canonicalUrl": "https://shop.example/en_US/taxons/sofas",
"robots": "noindex, follow",
"openGraph": {
"title": "Sofas | Example Shop",
"description": "All our sofas at a glance.",
"url": "https://shop.example/en_US/taxons/sofas",
"type": "website",
"image": null,
"imageAlt": null,
"siteName": "Example Shop",
"locale": "en_US"
},
"twitterCard": {
"card": "summary",
"title": "Sofas | Example Shop",
"description": "All our sofas at a glance.",
"image": null,
"imageAlt": null,
"site": null,
"creator": null
},
"structuredData": [
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Sofas", "item": "https://shop.example/en_US/taxons/sofas" }
]
}
],
"alternates": []
}
The examples show the structure of the response. Which nodes appear in
structuredDataand which valuestitle,descriptionandcanonicalUrlget depends on your SEO templates, channel settings and product data.
Mapping: storefront tag to API field
The template metatags.html.twig renders the same SeoData the API fills its fields from. The <title> is the exception: in the storefront it is not output by the template but replaced in the finished HTML by a response listener. The API still delivers the value in title.
Storefront <head> element |
API field |
|---|---|
<title> |
title |
<meta name="description"> |
description |
<link rel="canonical"> |
canonicalUrl |
<meta name="robots"> |
robots |
<link rel="alternate" hreflang="..."> |
alternates[].hreflang and alternates[].href |
<meta property="og:type"> |
openGraph.type |
<meta property="og:title"> |
openGraph.title |
<meta property="og:description"> |
openGraph.description |
<meta property="og:url"> |
openGraph.url |
<meta property="og:site_name"> |
openGraph.siteName |
<meta property="og:locale"> |
openGraph.locale |
<meta property="og:image"> |
openGraph.image |
<meta property="og:image:alt"> |
openGraph.imageAlt |
<meta property="og:image:width">, og:image:height, og:image:type |
openGraph.imageWidth, openGraph.imageHeight, openGraph.imageType |
<meta name="twitter:card"> |
twitterCard.card |
<meta name="twitter:title"> |
twitterCard.title |
<meta name="twitter:description"> |
twitterCard.description |
<meta name="twitter:image"> |
twitterCard.image |
<meta name="twitter:image:alt"> |
twitterCard.imageAlt |
<meta name="twitter:site"> |
twitterCard.site |
<meta name="twitter:creator"> |
twitterCard.creator |
<script type="application/ld+json"> (one per node) |
structuredData[] (one object per node) |
A headless client writes each JSON-LD node as JSON.stringify(node) into a <script type="application/ld+json">. The storefront escapes character sequences that would end the script element early (Twig function mmd_seo_suite_pro_json_ld). If you serialize the nodes yourself, you take over that escaping.
Errors and status codes
| Status | When |
|---|---|
| 200 | The page exists and is visible in the current channel |
| 404 | Unknown code |
| 404 | Product disabled |
| 404 | Product is not sold in the current channel, or the channel cannot be determined |
| 404 | Category disabled |
| 404 | No SEO data can be derived for the subject |
All cases answer with the same message Not found.. API Platform decides the format of the error body based on the requested format. A client that gets a 404 simply renders no extra SEO tags.
The plugin itself raises no other status codes, not even 401 or 403.
Channel, language and access
The plugin does not determine channel and language itself. It asks Sylius' ChannelContextInterface and LocaleContextInterface and uses the result. Which properties of the request (hostname, headers) decide there depends on the channel and locale configuration of your Sylius installation. The API paths contain neither channel nor locale.
A product is only served in the current channel if it is sold there. In a headless setup, make sure the request to the Shop API hits the right channel, otherwise valid products answer with a 404.
The endpoints live under /api/v2/shop and are publicly accessible in the standard Sylius setup (PUBLIC_ACCESS). The plugin adds no authentication of its own. If you lock down the Shop API, that applies to these endpoints as well.
The hreflang URLs in alternates use the hostnames of the channels and the scheme of the API request. A headless frontend on another host has to map these URLs to its own addresses.
Admin API: reading and writing SEO fields
The per-product and per-category fields (metaTitle, canonicalPathOverride, robotsIndex, robotsFollow and, for categories, metaDescription) are part of the translation objects of the Sylius Admin API. A catalogue client (automation, MCP server) can read and set them without the admin form. The plugin ships no Admin API endpoints of its own.
Technically this works through serialization groups (sylius:admin:product:*, sylius:admin:taxon:*, sylius:admin:taxon_translation:show) attached directly to the properties of the traits SeoAwareTranslationTrait and MetaDescriptionAwareTranslationTrait. The plugin has no configuration that extends Sylius' Admin API. The fields therefore only appear if your translation classes use the traits (opt-in, see Extending).
| Method | Path | Description |
|---|---|---|
| GET | /api/v2/admin/products/{code} |
Product; translations.{locale} contains metaTitle, canonicalPathOverride, robotsIndex, robotsFollow |
| GET | /api/v2/admin/taxons/{code} |
Category; as for the product, plus translations.{locale}.metaDescription |
| PUT | /api/v2/admin/products/{code} |
Update a product, SEO fields in translations.{locale} |
| PUT | /api/v2/admin/taxons/{code} |
Update a category, SEO fields in translations.{locale} |
{
"translations": {
"en_US": {
"@id": "/api/v2/admin/products/P1/translations/en_US",
"metaTitle": "Blue sofa | Shop",
"canonicalPathOverride": "/sofas/blue",
"robotsIndex": false
}
}
}
- An existing translation needs its
@id(taken from the GET), exactly like Sylius' own translated fields. Without it Sylius answers that a translation for the locale code already exists. - Fields you leave out keep their value. A PUT without
robotsIndexorrobotsFollowdoes not reset them. canonicalPathOverrideis validated like in the admin: a path with exactly one leading slash. A full URL or//host/...answers422. Maximum 255 characters.metaTitleis limited to 255 characters,metaDescription(category) to 512.- The category
metaDescriptiononly exists if yourTaxonTranslationusesMetaDescriptionAwareTranslationTrait. The product meta description is Sylius' own field. - Access follows Sylius' Admin API security (token, role
ROLE_API_ACCESS).
After updating the plugin or adding the traits, run
bin/console cache:clearonce: the serializer keeps its metadata in the cache.
Not available through the API
| Topic | Why |
|---|---|
robots.txt |
A plain-text document for crawlers, served at /robots.txt on the shop host identically for both frontends. Content per channel in the admin, see Configuration. |
| Redirects | They act at the HTTP level before a page is rendered and only answer paths of the shop host. There is no API resource. A headless frontend on another host handles its own 404 pages. See Redirects. |
| OG image bytes | The API only returns the card URL in openGraph.image. The client fetches the image itself from the shop's card route (/seo/og/...). See OG images. |
| SEO audit | A back office feature that is started and read in the admin. There is no API resource. See Audit. |
| Home page | There is no endpoint for it. Its hreflang alternates and its Organization and WebSite nodes are only produced in the storefront through Twig. |
| Channel settings, SEO templates | They are maintained in the admin and only affect the values of the response. There is no API for them. |
Common problems and error patterns are on the Troubleshooting page.