Markus Michalski FAQ

Sylius SEO Suite Pro: OG Images

Generated OG images (1200x630) with name, price and logo: per-channel settings, hash URL delivery, cache, WebP and fonts, local and CDN image sources, console commands and limits.

Deutsche Version

Back to overview

Sylius SEO Suite Pro: OG Images

By default the og:image of a product page is the product's own image and that of a category page is the category's image. The OG images feature is optional and off by default: when you switch it on for a channel, the og:image (and twitter:image) of its pages is a generated 1200x630 card instead, with the name, the price, the image and the channel's logo. It is meant for shops whose product photos make poor share previews (white backgrounds, odd proportions, no name on them).

You reach the settings with the "OG images" button at the top of the "SEO Suite Pro" page. The plugin still has a single sidebar entry, and the screen has a button back to "SEO Suite Pro".

The card is drawn with PHP's GD and FreeType, nothing else: no intervention/image, no headless browser, no new Composer dependency. GD therefore has to be installed with FreeType support, otherwise no card can be produced.

Settings per channel

There is one row per channel (admin screen "OG images").

Setting Default Description
Generate a social card (enabled) off Without a row, or switched off, every page is byte-identical to before
Cards for products (products_enabled) on Whether products get a card
Cards for categories (taxons_enabled) on Whether categories get a card
Background colour #FFFFFF Format #RRGGBB
Text colour #111827 Format #RRGGBB
Accent colour #2563EB Format #RRGGBB; the bar on the left and the price
Logo empty A path below the shop's public directory (/media/logo.png) or an absolute https:// URL, at most 1024 characters; PNG, JPEG, GIF or WebP; optional
Slogan (tagline) empty One short line (at most 120 characters) next to the logo; optional
Show the price (show_price) on The lowest price of the enabled variants in the channel, formatted in the channel's default language and base currency. A product without a price gets a card without one
Template version (template_version) 1 Part of every card's content hash. The checkbox "Regenerate the cards" raises it on save

A card is written in the channel's default language: there is one card per product and channel, not one per page language. The screen has a "Preview" button: a sample card of the first enabled product of the channel (a category when there is none) with the saved settings. It is rendered on the spot and never cached.

Settings in the project (format, sources, limits)

All keys are optional and live under mmd_sylius_seo_suite_pro.og_image:

Setting Default Description
og_image.format auto auto, webp, jpeg or png
og_image.quality 85 Quality of WebP and JPEG cards, 1 to 100
og_image.cache_dir %kernel.project_dir%/var/mmd_seo_og Where the cards are stored, outside the public directory
og_image.images_path %kernel.project_dir%/public/media/image Directory local product images are read from
og_image.public_dir %kernel.project_dir%/public Public directory a logo path is read from
og_image.font_regular shipped DejaVu Sans TrueType font for the texts
og_image.font_bold shipped DejaVu Sans Bold TrueType font for the name and the price
og_image.remote_hosts [] Allow-list for CDN hosts; empty means any host that resolves to a public address
og_image.fetch_timeout 5 Seconds a remote image may take to answer (twice that to download)
og_image.render_budget 15 Seconds one card may spend loading its image and logo together
og_image.max_source_bytes 8388608 Largest source image in bytes, local or remote
og_image.max_source_pixels 25000000 Largest source image in pixels; in addition at most 8000 pixels per side

A realistic configuration for a shop whose images come from a CDN:

# config/packages/mmd_sylius_seo_suite_pro.yaml
mmd_sylius_seo_suite_pro:
    og_image:
        format: auto
        quality: 85
        cache_dir: '%kernel.project_dir%/var/mmd_seo_og'
        remote_hosts: ['cdn.example.com', '*.example.org']
        fetch_timeout: 5
        render_budget: 15
        max_source_bytes: 8388608
        max_source_pixels: 25000000

The entries of remote_hosts are plain host names (cdn.example.com or *.example.org), without scheme and path. The configuration rejects anything else.

How a card is delivered

A card is never rendered while a page is built. A page only needs the card's URL, and that is worked out from metadata without reading a file, fetching an image or drawing anything:

/seo/og/<channel>/<type>/<code>-<hash>.<ext>      e.g. /seo/og/shop/product/SOFA-1-3f9c...e1.webp

The hash is a sha256 (cut to 32 hex characters) over everything that changes how the card looks: the layout version of the plugin, the template version of the channel, the type, the name, the price text, the identity of the image (its stored path or its URL), the logo, the three colours, the slogan, the format, the quality and the content of the font files.

graph TD
    A["Page build: compute the hash only, draw nothing"] --> B["Page names the card URL with the hash"]
    B --> C["Crawler requests the card URL"]
    C --> D{"Hash and format current?"}
    D -->|"no, subject has a card"| E["301 to the current URL, cache 300 seconds"]
    D -->|"subject or channel unknown or off"| F["404"]
    D -->|"yes"| G{"Card in the cache?"}
    G -->|"yes"| H["Send file: immutable, ETag, 304 on revalidation"]
    G -->|"no"| I{"Render lock free within 2 seconds?"}
    I -->|"no"| J["503 with Retry-After 5"]
    I -->|"yes"| K["Render, store atomically, send"]
    K -->|"image or logo temporarily not loadable"| L["Degraded card: 300 seconds cache, not stored"]
    K -->|"render fails for good"| M["302 to the normal product image, else 404"]

The route works out the current hash of the subject, so nobody can make the shop render cards for hashes it never published. In detail:

A card is only announced on a page for a subject the route would serve: a product must be enabled and sold in the channel, a category must have a page there. The route is part of the shop routes, imported at the top level (see Installation). A page with a card also gets og:image:width, og:image:height and og:image:type.

Changing a product's name, price or image, or any setting, changes the hash: the new URL appears on the page, the old one redirects to it, and the old file of that product is deleted when the new one is written.

An image replaced in place under the same path or URL cannot be noticed without reading it, which a page request must not do. Use "Regenerate the cards" after replacing a logo, or a CDN image behind an unchanged URL. (Sylius stores a re-uploaded product image under a new path, so that case is picked up automatically.)

Degraded cards

A source image that cannot be loaded does not prevent the card: it is drawn without the image, and the failure is logged with the card and the source. If the failure may be gone on the next try, or depends on the environment and your configuration rather than on the card, the card is degraded: it is delivered with Cache-Control: public, max-age=300 (not immutable) and not stored, so the next request draws the complete card and only that one is kept for a year. That covers a timeout, a connection error, an HTTP 5xx, 408 or 429 answer, the time budget used up, a host that does not resolve (DNS), and a host or address refused by the address policy (not on remote_hosts, or a loopback, private, link-local or reserved address).

A permanent failure (the image does not exist, is not an image, is too large, a local path outside the allowed directories, a stored URL that is not http(s) or carries credentials) gives a normal, cached card without the image instead.

Local CDN setups (a media server on loopback or in the Docker network, as in a dev shop): such addresses are refused on purpose, so the card stays without the image and is drawn again on every request instead of being kept for a year. Remedy: let the images come from a host on a public address (and on og_image.remote_hosts if you use the allow-list). The allow-list only narrows the hosts, it never permits a private or loopback address.

Format, WebP and fonts

A forced format: webp on a server whose GD cannot write WebP is not silently changed: rendering fails with a message that says so. The visitor gets a 302 or a 404, and the message is in the log, in the console command and in the admin preview. Set format to auto or jpeg in that case.

The console and the web server should have the same GD configuration. With auto and a different WebP support on each side, they write cards in different formats (and so with different hashes). prune leaves a card in the other format alone, and the next write of the subject's card removes it. Set og_image.format explicitly if the two differ.

Image sources: local files and CDN URLs

A product or category image is either a path relative to images_path or an absolute URL (a CDN). Both are loaded when a card is rendered.

Local files or CDN

Local files

A local path is resolved with realpath and must end up inside images_path (a logo inside public_dir): no .., no symbolic link out of the directory, no absolute path. You need no further configuration.

CDN URLs

A URL must be http(s), without credentials, and on the remote_hosts allow-list when there is one.

SSRF protection: the host is resolved and every address must be public (no loopback, private, link-local, carrier-grade NAT, documentation or reserved range, IPv4-mapped IPv6 judged by the IPv4 address). The checked address is pinned for the request, redirects are followed by hand (at most 3) and every hop is checked again. Neither a second DNS answer nor a redirect can lead into the shop's own network.

The loader has an HTTP client of its own (service mmd_sylius_seo_suite_pro.og_image.http_client), not the framework's shared one. It never uses a proxy from the environment (HTTP_PROXY, HTTPS_PROXY), because a proxy resolves the host name itself and the pinned, checked address would mean nothing. A shop that can only reach the CDN through a proxy has to replace that service consciously.

Limits when loading an image

Console commands

bin/console mmd:seo-suite-pro:og-image:generate [--channel=CODE] [--type=product|taxon] [--subject=CODE] [--force]
bin/console mmd:seo-suite-pro:og-image:prune [--days=0] [--no-orphans] [--dry-run]

generate renders the cards ahead of time, so the first visitor (or crawler) does not wait. Products and categories are read page by page (100 per page) and the entity manager is cleared after every page. Per channel and type it shows progress and a count of rendered, unchanged, skipped and failed cards. A card that exists is left alone unless you give --force. Besides product and taxon, --type accepts the type of an own provider, and --subject needs --type. Channels with cards switched off are skipped. The exit code is 1 when a card failed (a degraded card counts as failed and is not stored; the command says why: temporary failure, host did not resolve, or refused by the address policy), and 2 for an unknown channel or type.

Unless --no-orphans is given, prune deletes cards no product or category points at any more (subject gone or disabled, card switched off, another hash now in the current format) and leftover temporary and lock files of crashed processes. By default it does not look at the age. --days=N additionally deletes cards not written for N days, current ones included (they are drawn again on demand). With --dry-run it only counts what would be deleted.

A dry run first, then the real run:

bin/console mmd:seo-suite-pro:og-image:prune --dry-run
bin/console mmd:seo-suite-pro:og-image:prune

Pre-render the cards after a catalogue import and tidy up weekly, as cron jobs:

15 4 * * * cd /var/www/shop && bin/console mmd:seo-suite-pro:og-image:generate --no-interaction
30 4 * * 0 cd /var/www/shop && bin/console mmd:seo-suite-pro:og-image:prune --no-interaction

If your import runs at another time, chain og-image:generate directly behind the import job.

Own types

Implement Mmd\SyliusSeoSuiteProPlugin\OgImage\OgImageProviderInterface (or extend AbstractOgImageProvider). Any service implementing it is tagged mmd_sylius_seo_suite_pro.og_image_provider automatically. A provider names the type that ends up in the URL (getType()), finds a subject by its code (find()), and describes it (describe()) as an OgImageSpec: title, price text, image source and an optional extra line. describe() runs while a page is built and must not read a file or use the network. The example and the details are on the Extending page.

Host steps

  1. After updating the plugin, run mmd:seo-suite-pro:setup (see Installation): it creates mmd_sylius_seo_suite_pro_og_image_settings. Without the table the feature is off and everything else is unchanged (a warning is logged once per process). Only the OG image settings screen needs the table.
  2. Make sure the shop routes are imported at the top level: the card route is part of them.
  3. Make the cache directory writable and persistent, switch the feature on per channel and, if you like, run mmd:seo-suite-pro:og-image:generate.

If something goes wrong, see Troubleshooting.

Shop API / Headless

The card URL is part of the same SEO data that the Twig hook renders and the Shop API delivers. The API returns the URL in openGraph.image and twitterCard.image and adds imageWidth, imageHeight and imageType to openGraph and twitterCard. These three keys only exist when a card is used, so a shop without cards gets the response it always got. The image route itself is an HTTP-level endpoint and has no API resource.

Limits