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.
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 that exists is sent as it is:
Cache-Control: public, max-age=31536000, immutable, anETag(the hash) and304on revalidation. - A missing card is rendered once, under a lock, written atomically (temporary file plus rename) and sent.
- A stale hash of a subject that has a card (a page cached before the name, price or image changed) is answered with a 301 to the current card URL (
Cache-Control: public, max-age=300, built on the server from the subject, never from the request). The redirect renders nothing. - Invalid parameters, an unknown subject, a subject without a card, a channel that is switched off and a card type that is switched off give a plain 404.
- Only one process renders a given card at a time. A request that finds the card being drawn waits at most two seconds and then gets 503 with
Retry-After: 5instead of piling up behind the lock. - If a card cannot be produced (no space left, GD problem), the visitor of the card URL is redirected (302) to the product's normal image, or gets a 404 when it has none. The reason is logged as a warning, once per process and problem.
- A file that vanishes between the check and the send (a prune, a deployment) is drawn again once.
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_hostsif you use the allow-list). The allow-list only narrows the hosts, it never permits a private or loopback address.
Format, WebP and fonts
autowrites WebP where GD can write it and JPEG otherwise. A PHP build whose GD was compiled without WebP (gd_info()['WebP Support']isfalse; some Docker images are) therefore gets JPEG cards. The host can enable WebP with--with-webpwhen it builds GD. The format is part of the hash and of the URL (.webp,.jpg,.png).- The card always has the fixed size 1200x630.
- The plugin ships its own font (DejaVu Sans and DejaVu Sans Bold, in
Resources/fonts/, with its licence inResources/fonts/LICENSE-DejaVu.txt), because a typical container has no system fonts. It covers the Latin, Cyrillic and Greek alphabets. Pointfont_regularandfont_boldat any other TrueType file to change the look. A missing file is a configuration error with the option's name in the message. cache_diris outside the public directory because the cards are served through the route. It must be writable by the web server and should persist across deployments (a sharedvar/directory). If it is lost, the cards are simply rendered again on demand.
A forced
format: webpon 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. Setformattoautoorjpegin that case.
The console and the web server should have the same GD configuration. With
autoand a different WebP support on each side, they write cards in different formats (and so with different hashes).pruneleaves a card in the other format alone, and the next write of the subject's card removes it. Setog_image.formatexplicitly 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
- Content type
image/jpeg,image/png,image/giforimage/webp. A WebP source is only accepted when GD can read WebP. fetch_timeoutseconds to answer, and the download is aborted as soon as it exceedsmax_source_bytes. The image and the logo of one card also share the time budgetrender_budget.- The pixel dimensions are read from the file header before anything is decoded. More than
max_source_pixelsor more than 8000 pixels on a side is refused, as is an image that would not fit in what is left ofmemory_limit.
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
- After updating the plugin, run
mmd:seo-suite-pro:setup(see Installation): it createsmmd_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. - Make sure the shop routes are imported at the top level: the card route is part of them.
- 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
- No right-to-left text and no complex scripts (Arabic, Hebrew and Indic scripts need shaping that GD does not do).
- The shipped font has no glyphs for Chinese, Japanese or Korean. Configure another font for that and check the result yourself.
- No animation, one layout with your colours, logo and slogan, not a template language.
- The card has no
og:image:altof its own (the page title is used). - One card per subject and channel, in the channel's default language.