SEOMatic
Comprehensive SEO toolkit by nystudio107. Auto-renders HTML meta tags, JSON-LD structured data, OpenGraph, Twitter Cards, XML sitemaps, robots.txt, and humans.txt. Used on every project.
nystudio107/craft-seomatic — $99
Documentation
- Overview: https://nystudio107.com/docs/seomatic/
- Configuration: https://nystudio107.com/docs/seomatic/configuring/
- Twig templating: https://nystudio107.com/docs/seomatic/using/
- Advanced usage: https://nystudio107.com/docs/seomatic/advanced.html
- Content SEO: https://nystudio107.com/docs/seomatic/content.html
- Site settings: https://nystudio107.com/docs/seomatic/site.html
When unsure about an SEOMatic feature, WebFetch the relevant docs page.
Common Pitfalls
- Putting SEOMatic Twig variables inside
{% cache %}tags — SEOMatic dynamically generates meta on each request using its own cache. Wrapping in{% cache %}freezes the output after the first render. - Mutating a meta tag at runtime (e.g.
seomatic.tag.get('twitter:creator').include(false)from aView::EVENT_END_PAGEhandler) and expecting it to stick on prod — SEOMatic caches each container's rendered tag-data and only re-reads a tag'sinclude/contenton a cache miss. Behind a warm cache it's a silent no-op; it "works" locally only because devMode shortens the cache to 30s. See Rendered Tag-Data Cache. - Using
entryas the variable name in custom element config files — custom element types use their ownrefHandle()(e.g.,jobfor a Job element, notentry). Check the element class'srefHandle()method. - Setting SEO values in Twig that are already mapped in Content SEO — Content SEO mappings (
{seoField}→ entry fields) take precedence unless you override in Twig with{% do seomatic.meta.seoTitle("...") %}. - Forgetting that
seomatic-configfiles only apply on initial bundle creation — changes after the bundle exists have no effect unless you bumpbundleVersioninBundle.php. - Not configuring Content SEO per-section — SEOMatic's power is in automatic field mapping. If you rely only on the SEO Settings field, you're doing extra work.
- Missing
{% hook 'seomaticRender' %}when headless — auto-rendering works for traditional templates. Headless/hybrid setups need explicit rendering or the GraphQL API. - Hardcoding JSON-LD in templates instead of using SEOMatic's container system — breaks the cascade and loses CP configurability. (Exception: complex per-entry structured data with real field values is often cleaner as a Twig partial — see "Managing DB-Backed Settings via Content Migrations".)
- Assuming Site Settings, the robots.txt template, and Content SEO sync via project config — they're DB-backed (the
seomatic_metabundlestable), not project config. Change them reproducibly with a content migration (JSON_SET+clearAllCaches()) on any host. See "Managing DB-Backed Settings via Content Migrations". fromFieldContent SEO source pointed at a field that's empty on some entries — SEOMatic falls back to the global/homepage meta, giving every such entry the same description (sitewide duplicate meta). UsefromCustomwith an object-template fallback chain instead.- Confusing
mainEntityOfPage(per-page schema type, e.g.Article) withsiteType/siteSubType/siteSpecificType(the globalOrganization/LocalBusinessidentity) — they live in different columns and do different jobs.
Meta Cascade
SEOMatic's meta values cascade from broad to specific. Each level can override the previous:
- Global SEO — site-wide defaults (identity, creator, social, sitemap)
- Content SEO — per-section/category/product-type mappings (fields → meta)
- SEO Settings Field — per-entry overrides (optional field, not required)
- Twig Overrides — template-level
{% do seomatic.meta.seoTitle("...") %}
In most cases, Global SEO + Content SEO mapping covers everything. The SEO Settings field is only needed when editors need per-entry override capability.
Template Integration
Auto-Rendering (Default)
SEOMatic auto-injects meta into <head> on every front-end request. No Twig code needed. This is how most projects work — including all of ours.
Explicit Rendering
If you need control over placement or are using headless:
{# Render all SEOMatic meta in one call #}
{% hook 'seomaticRender' %}Disable Auto-Rendering
In config/seomatic.php:
return [
'renderEnabled' => false,
];Then render manually via the hook or GraphQL.
Twig API
Reading Values
{{ seomatic.meta.seoTitle }}
{{ seomatic.meta.seoDescription }}
{{ seomatic.meta.seoImage }}
{{ seomatic.meta.canonicalUrl }}
{{ seomatic.meta.robots }}Setting Values
{% do seomatic.meta.seoTitle("Custom Title") %}
{% do seomatic.meta.seoDescription("Custom description for this page.") %}
{% do seomatic.meta.seoImage(entry.heroImage.one().url ?? '') %}
{% do seomatic.meta.robots("noindex,nofollow") %}Parsed Values (Final Output)
{{ seomatic.meta.parsedValue('seoDescription') }}Accessing Tag Objects Directly
{# Get a specific meta tag #}
{% set robotsTag = seomatic.tag.get('robots') %}
{% do robotsTag.content("noindex") %}
{# Disable a tag entirely #}
{% do seomatic.tag.get('description').include(false) %}
{# Add custom data attributes #}
{% set tag = seomatic.tag.get('description') %}
{% if tag|length %}
{% do tag.tagAttrs({ 'data-type': 'seo' }) %}
{% endif %}Accessing JSON-LD
{# Modify the main entity JSON-LD #}
{% set jsonLd = seomatic.jsonLd.get('mainEntityOfPage') %}
{% do jsonLd.setAttributes({
'name': 'Custom Name',
'description': 'Custom description',
}) %}Helper Functions
{# Sanitize user input before setting SEO values #}
{% set safeValue = seomatic.helper.sanitizeUserInput(someUnsafeInput) %}
{# Truncate text to SEO-friendly length #}
{% set truncated = seomatic.helper.truncate(longText, 160) %}
{# Extract text from a CKEditor/rich text field #}
{% set plainText = seomatic.helper.extractTextFromField(entry.body) %}Rendered Tag-Data Cache (Runtime Mutation Gotcha)
SEOMatic caches each container's rendered tag-data array, not just its inputs — so flipping a tag off at runtime can be a silent no-op on production.
MetaTagContainer::includeMetaData() (models/MetaTagContainer.php) wraps the render in Craft::$app->getCache()->getOrSet(…, Seomatic::$cacheDuration, $dependency), and each tag's ->include flag is evaluated only inside the miss closure. On a warm cache the closure never runs, so a tag you disabled at runtime is still emitted.
Cache lifetime (Seomatic::$cacheDuration, set in Seomatic.php):
- devMode OFF — the
metaCacheDurationsetting, which defaults to0; Yii treats duration0as "no expiry," so it's cached until explicitly invalidated (SEOMatic'sconfig.phpcomment: "Null means always cached until explicitly broken"). On Cloud the data cache is Redis, so it survives deploys. - devMode ON —
DEVMODE_CACHE_DURATION= 30 seconds.
That gap is the classic "works on my machine, no-op on prod": locally, devMode keeps the cache ~always-miss so a runtime mutation re-renders within 30s; on prod the warm cache persists across requests and deploys. Handler ordering is a red herring — a handler prepended to craft\web\View::EVENT_END_PAGE (append: false) runs before SEOMatic's container include and still no-ops against a warm cache. The cache is the only blocker.
Two-step fix (e.g. via the Cloud Console command runner)
clear-caches/seomatic-metabundle-caches— its label reads "SEOmatic metadata caches", but it maps tometaContainers->invalidateCaches()(services/MetaContainers.php), which invalidates theGLOBAL_METACONTAINER_CACHE_TAG('seomatic_metacontainer') tag → the origin re-renders the containers clean. The other SEOMatic options (-frontendtemplate-,-schema-,-sitemap-) don't touch rendered tags, and this one leaves the sitemap cache alone.clear-caches/craft-cloud-caches— purges the Cloudflare edge so the re-rendered HTML is actually served. Data-cache clears never reach the edge — see thecraft-cloudskill'scaching-and-edge.md("Two cache layers"). (Cloud-specific; on other hosts, purge whatever static/CDN layer fronts the origin.)
Suppress the tag — don't blank the field
SEOMatic's vendor tag template hard-codes the @: 'content' => '@{{ seomatic.meta.twitterCreator }}' (seomatic-config/globalmeta/TagContainer.php). Clearing the Twitter Creator field value therefore still emits a bare @. Stop the tag from rendering (include(false), then bust the caches above) — don't just empty the handle.
Verified against nystudio107/craft-seomatic 5.1.21.
Config File
Copy the plugin's default settings to config/seomatic.php for environment-aware overrides:
// config/seomatic.php
return [
'*' => [
'pluginName' => 'SEO',
'renderEnabled' => true,
'sitemapsEnabled' => true,
'headlessMode' => false,
'lowMemoryMode' => false,
],
'production' => [
],
'dev' => [
'sitemapsEnabled' => false,
],
];Custom Element SEO Bundles
For custom element types (plugins like Cockpit), create per-element config overrides in config/seomatic-config/{handle}meta/:
config/seomatic-config/
└── jobpostmeta/
├── Bundle.php # Bundle version, source template
├── BundleSettings.php # Field mappings
├── GlobalVars.php # Meta variable templates
├── JsonLdContainer.php # JSON-LD structured data
├── LinkContainer.php # Link tags (canonical, etc.)
├── ScriptContainer.php # Script tags
├── SitemapVars.php # Sitemap settings
├── TagContainer.php # Meta tags
└── TitleContainer.php # Title tag templateThe directory name must match {refHandle}meta — where refHandle comes from the element class's refHandle() method (e.g., job → jobpostmeta).
GlobalVars.php Example (Custom Element)
return [
'*' => [
'mainEntityOfPage' => 'WebPage',
'seoTitle' => '{{ job.title }}', // Uses refHandle, NOT 'entry'
'seoDescription' => '{{ job.description }}',
'canonicalUrl' => '{{ job.url }}',
'robots' => 'all',
'ogType' => 'website',
'ogTitle' => '{{ seomatic.meta.seoTitle }}',
'ogDescription' => '{{ seomatic.meta.seoDescription }}',
'twitterCard' => 'summary_large_image',
// ... remaining OG/Twitter fields cascade from seomatic.meta
],
];JsonLdContainer.php Example (JobPosting)
'data' => [
'mainEntityOfPage' => [
'type' => 'JobPosting',
'name' => '{{ seomatic.meta.seoTitle }}',
'description' => '{{ seomatic.meta.seoDescription }}',
'url' => '{{ seomatic.meta.canonicalUrl }}',
'datePosted' => '{{ job.postDate|date("c") }}',
'hiringOrganization' => [
'type' => 'Organization',
'name' => '{{ job.department.one().title ?? "" }}',
],
'jobLocation' => [
'type' => 'Place',
'address' => [
'type' => 'PostalAddress',
'addressLocality' => '{{ job.city ?? "" }}',
'addressCountry' => 'BE',
],
],
],
],These files are only read when initially creating a meta bundle (plugin install or new section created). Bump bundleVersion in Bundle.php to force a re-read.
Managing DB-Backed Settings via Content Migrations
Many SEOMatic settings are DB-backed, not project config — they live in the seomatic_metabundles table and do not sync via project config:
- Site Settings — Identity (
genericUrl, organization/person name, social profiles) - The robots.txt template (and humans.txt)
- Per-section Content SEO — field mappings, sources, per-section meta templates
Because they're not in project config, the reproducible way to change them — version-controlled, repeatable across every environment, no manual CP clicking — is a content migration that edits the JSON columns with MySQL JSON_SET, then clears SEOMatic's caches with Seomatic::$plugin->clearAllCaches().
This is host-independent: the migration is identical on self-hosted, shared hosting, Servd, Craft Cloud, and locally — SEOMatic stores these settings in the database the same way everywhere. It's simply the only option on a host that gives you no production CP access (e.g. Craft Cloud), and the right approach everywhere else for the same reasons you'd migrate any other configuration rather than hand-editing prod.
Project-config-backed settings (
config/seomatic.phpand theseomatic-config/bundle files above) deploy normally. This section is only about the settings that aren't in project config.
seomatic_metabundles Table Shape
sourceBundleType |
Keyed by | Rows | Holds |
|---|---|---|---|
__GLOBAL_BUNDLE__ |
sourceSiteId |
one per site | metaSiteVars (identity, genericUrl, social), frontendTemplatesContainer (robots.txt, humans.txt) |
section |
sourceHandle |
two per section — section-level (typeId NULL) and entry-type-level (typeId set) |
metaGlobalVars, metaBundleSettings (Content SEO) |
The columns metaGlobalVars, metaSiteVars, metaBundleSettings, and frontendTemplatesContainer are JSON stored in text columns, but MySQL's JSON_SET / JSON_EXTRACT operate on them fine.
Two things that bite:
- Per-section bundles exist at two levels. A section usually has a section-level row (
typeId IS NULL) and an entry-type-level row per entry type (typeIdset), sharing the samesourceHandle. Both normally need the same edit — filtering onsourceHandlealone (notypeIdpredicate) hits all of them, which is usually what you want. frontendTemplatesContainerholds the robots.txt template as a JSON-escaped string, and its line endings vary per site.JSON_EXTRACTit first to confirm the exact path and current value before youJSON_SETa replacement.- Confirm every JSON path first. SEOMatic's internal nesting shifts between versions (e.g. exactly where
genericUrland the social profiles sit undermetaSiteVars).SELECT JSON_EXTRACT([[<column>]], '$') FROM {{%seomatic_metabundles}} WHERE …and eyeball the structure before composingJSON_SETpaths — don't trust a path from memory.
Worked Migration
<?php
namespace craft\contentmigrations;
use Craft;
use craft\db\Migration;
use nystudio107\seomatic\Seomatic;
/**
* Updates DB-backed SEOMatic settings (the seomatic_metabundles table) via a
* migration, then clears caches so the change is reproducible across every
* environment. Host-independent — same on self-hosted, Servd, Cloud, and local.
*
* @author Acme
*/
class m260610_090000_seomatic_settings extends Migration
{
// Public Methods
// =========================================================================
/**
* @inheritdoc
*/
public function safeUp(): bool
{
$db = Craft::$app->getDb();
// --- Global bundle (per site): Identity genericUrl ---
// metaSiteVars is JSON; JSON_SET edits keys in place. One row per site,
// so scope by sourceSiteId (genericUrl usually differs per site).
$db->createCommand(
<<<SQL
UPDATE {{%seomatic_metabundles}}
SET [[metaSiteVars]] = JSON_SET([[metaSiteVars]], '$.identity.genericUrl', :url)
WHERE [[sourceBundleType]] = '__GLOBAL_BUNDLE__' AND [[sourceSiteId]] = :siteId
SQL,
[':url' => 'https://example.com', ':siteId' => 1]
)->execute();
// --- Per-section Content SEO: fix the empty-field fallback (see gotcha) ---
// No typeId predicate → updates BOTH the section-level row (typeId NULL)
// and the entry-type-level row(s), matched by handle.
$db->createCommand(
<<<SQL
UPDATE {{%seomatic_metabundles}}
SET [[metaBundleSettings]] = JSON_SET([[metaBundleSettings]], '$.seoDescriptionSource', 'fromCustom'),
[[metaGlobalVars]] = JSON_SET([[metaGlobalVars]], '$.seoDescription', :tmpl)
WHERE [[sourceBundleType]] = 'section' AND [[sourceHandle]] = :handle
SQL,
[
':handle' => 'blog',
// Object-template with its own fallback chain (see gotcha below).
':tmpl' => '{{ entry.seoSummary ?: entry.summary ?: entry.title }}',
]
)->execute();
// robots.txt lives in frontendTemplatesContainer as a JSON-escaped string.
// Inspect the exact path + current value first (line endings vary per site):
// SELECT JSON_EXTRACT([[frontendTemplatesContainer]], '$') FROM {{%seomatic_metabundles}}
// WHERE [[sourceBundleType]] = '__GLOBAL_BUNDLE__' AND [[sourceSiteId]] = 1;
// then JSON_SET the template key you find with the full replacement string.
// Reset SEOMatic's caches so the edited bundles take effect.
Seomatic::$plugin->clearAllCaches();
return true;
}
/**
* @inheritdoc
*/
public function safeDown(): bool
{
echo "m260610_090000_seomatic_settings cannot be reverted.\n";
return false;
}
}Scaffold the stub with ddev craft migrate/create seomatic_settings (a content migration in migrations/), commit it, and it runs on every environment's next migrate — ddev craft up locally, the migrate step of your deploy in prod, on whatever host. clearAllCaches() invalidates the in-DB meta containers. If a host runs an edge or static cache in front (Cloud's Cloudflare layer, Servd's static cache, any CDN), its post-deploy purge clears the rendered meta; locally there's nothing to purge. For multi-site, loop sites (or drop the sourceSiteId predicate when the value is genuinely site-agnostic — genericUrl rarely is).
Gotcha — Empty-Field Fallback (Sitewide Duplicate Descriptions)
metaBundleSettings.seoTitleSource / seoDescriptionSource / seoImageSource are each one of sameAsSeo | fromField | fromCustom. With fromField pointed at a field that's empty on an entry, SEOMatic falls back to the global/homepage meta — so every entry with an empty SEO field gets the same description. Classic sitewide-duplicate-meta bug, and a real ranking problem.
Fix: set the source to fromCustom and put an object-template in metaGlobalVars.seoTitle / seoDescription with its own fallback chain, so an empty primary field cascades to a sensible per-entry value instead of the global:
{{ entry.seoDescription ?: entry.summary ?: entry.title }}The object-template variable is commonly
entry, but verify per install — custom element types use their ownrefHandle()(e.g.job), notentry. Check before relying on it.
Gotcha — Per-Page Schema Type vs Global Identity
Two different settings, easily confused:
metaGlobalVars.mainEntityOfPage— a string (WebPage,Article,NewsArticle, …). The per-page schema type, consumed byMetaContainersto build the page'smainEntityOfPageJSON-LD. This is what you set to make a blog entry anArticle.metaBundleSettings.siteType/siteSubType/siteSpecificType— the global identity schema (theLocalBusiness/Organizationcascade for Site Settings → Identity). Not per-page.
Setting siteType when you meant mainEntityOfPage (or vice versa) is a common reason the rendered structured data isn't what you expect.
Complex Per-Entry Structured Data — Prefer a Twig Partial
For rich, field-driven structured data — a real-estate Product/Offer/Place, an event, a recipe — don't fight SEOMatic's JSON-LD container. Build a Twig <script type="application/ld+json"> partial: assemble a hash from real field values and json_encode it. It's more testable, version-controlled, and far easier to reason about than templating JSON through container settings:
{# _partials/schema/listing.twig #}
{% set schema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: entry.title,
description: entry.summary ?: entry.title,
offers: {
'@type': 'Offer',
price: entry.price,
priceCurrency: 'EUR',
availability: 'https://schema.org/' ~ (entry.isAvailable ? 'InStock' : 'SoldOut'),
},
} %}
<script type="application/ld+json">{{ schema|json_encode|raw }}</script>Use SEOMatic for the meta-tag cascade (title/description/OG/Twitter) and the global identity JSON-LD; reach for a partial when per-entry structured data needs real field values.
MetaBundles Service Methods
Seomatic::$plugin->metaBundles exposes the bundle plumbing if you'd rather go through the service than raw SQL:
| Method | Use |
|---|---|
updateMetaBundle($metaBundle, $siteId) |
Persist an in-memory bundle back to its row |
syncBundleWithConfig(...) |
Reconcile a bundle against its seomatic-config files |
invalidateMetaBundleById($sourceBundleType, $sourceId, $siteId) |
Invalidate one bundle's cache |
createContentMetaBundles() |
(Re)build per-section/content bundles |
createGlobalMetaBundles() |
(Re)build the global per-site bundles |
Cache reset after any bundle edit: Seomatic::$plugin->clearAllCaches();.
Raw
JSON_SETmigrations are usually simpler and more predictable than reconstructing full bundle objects through the service — but the service methods are there when you need a full rebuild from config.
Verified against nystudio107/craft-seomatic ^5.1.16.
Sitemaps
SEOMatic auto-generates XML sitemaps for all sections, category groups, and Commerce product types with public URLs. Configure per-section in Content SEO:
- Sitemap Enabled — include/exclude from sitemap
- Change Frequency — how often the content changes
- Priority — relative priority (0.0–1.0)
- Image Sitemaps — include images in sitemap entries
Disable Sitemaps in Dev
// config/seomatic.php
return [
'dev' => [
'sitemapsEnabled' => false,
],
];GraphQL API
{
seomatic(uri: "/", siteId: 1) {
metaTitleContainer
metaTagContainer
metaLinkContainer
metaScriptContainer
metaJsonLdContainer
metaSiteVarsContainer
}
}Pass asArray: true for structured data instead of rendered HTML strings.
Events
use nystudio107\seomatic\events\IncludeContainerEvent;
use nystudio107\seomatic\base\Container;
use yii\base\Event;
// Conditionally exclude a container
Event::on(Container::class, Container::EVENT_INCLUDE_CONTAINER,
function(IncludeContainerEvent $event) {
if ($event->container->handle === 'script') {
$event->include = false;
}
}
);Multi-Site
All settings are multi-site aware. Content SEO, Global SEO, and sitemaps can be configured independently per site. The seomatic-config directory applies globally — use Content SEO in the CP for per-site overrides.
Pair With
- Retour — redirect management and 404 tracking (SEOMatic does not handle redirects)
- Typogrify — smart typography for SEO title/description rendering