Version 1.0.2
August 16, 2026
Fixes for the ether/seo migration, found by running it against a real ether install. No schema change — updating is a straight composer update. If you have already migrated from ether/seo, re-run the migration after updating.
Fixed
- The ether migration no longer loses social images. Ether stores the asset under
social.<network>.imageIdand renames a legacyimagekey to it only when it loads a value, so the stored document carries either. Readingimagealone dropped every social image while reporting a clean conversion. If you already migrated, re-runcraft simple-seo/migrate/ether --applyafter updating: the run is idempotent, and it now picks up the images it skipped. Found by migrating a real ether/seo 5.0.0 install - An all-blank
titleRawarray now falls back to ether's flattitlekey, the way a blanktitleRawstring already did - Robots directives stored as one string, rather than a list, are read correctly. Reading only the list shape turned a hidden page back into an indexable one
- Applying the migration now drops the sitemap cache. Content rows are rewritten with direct SQL, so no element-save event fires, and cached sitemap files kept listing entries the migration had just marked noindex
- Per-network social titles, descriptions, and a second differing image are counted in the report. Simple SEO renders one set for every network, and dropped data is never silent
Version 1.0.1
August 16, 2026
Correctness fixes found by an adversarial review of the 1.0.0 code. No new features, no schema change — updating is a straight composer update.
Fixed
- hreflang alternates no longer advertise noindexed translations. An entry live on several sites correctly dropped out of its own site's sitemap when marked noindex, but every sibling site still linked to it as an
<xhtml:link rel="alternate">— the same index signal through a side door (#4) - Canonical query strings keep their exact shape. Filtering ran through PHP's
parse_str(), which renamed dotted params (utm.idbecameutm_id, so a dottedcanonicalAllowedQueryParamsentry could never match its own param), collapsed repeated params to the last value, and stamped=onto valueless ones (#5) - Protocol-relative canonical overrides (
//cdn.example.com/page) keep their leading//instead of degrading into a relative path that crawlers resolve against the current page (#8) - The pagination suffix now applies only to the element the request actually resolved to. Rendering meta for a different element on
/blog/p2— a featured entry, a GraphQL list item — gave it a page-two URL it does not have (#7) craft simple-seo/doctor --json --quietreports problems only. The JSON branch returned before the quiet filter ran, so a pipeline gating on.findings | lengthfired on every healthy run (#9)- The MCP tools' error responses can no longer carry a server path: an anonymous exception class embeds its defining file in its own name, which the reported exception type passed straight through
Changed
- An explicit
nulloverride now clears its value.{ description: null }and{ canonical: null }were silently ignored while{ robots: null }and{ ogImage: null }cleared their tags — one overrides array, two opposite meanings for the same input. Every key now clears onnull: description, canonical (tag,og:url, and theLinkheader), robots, and the social image render nothing at all, andogType,ogSiteName, andtwitterCardfall back to their defaults.titleis the one deliberate exception — a page always has a title, so anulltitle runs the normal fallback chain (#6)
Internal
- Consolidation with no behavior change: one shared blank-to-null helper behind the field, the MCP tools, and the services; raw responses through Craft's own
asRaw(); shared PHPStan type aliases; the last model-to-service dependency cycle removed - MCP failure logs now carry the exception class, file, line, and stack trace. The response the client receives is unchanged — the details belong in the Craft logs, not on the wire
- Test suite grown to 74 unit and 89 integration tests, including a regression case for every fix above
Version 1.0.0
August 13, 2026
First release. Simple SEO does the SEO work every Craft site needs and deliberately nothing else.
The SEO field
- Meta title, meta description, social image, canonical override, and per-entry robots, stored as a single JSON value (#2)
- A live SERP and social-share preview, rendered entirely client-side from data embedded at render time. It makes no requests, so it cannot fail to load (#3)
- Soft-limit character counters that announce limit crossings to screen readers via a polite live region — crossings only, never every keystroke (#13). The title counter measures the full formatted title — site title format applied, suffix included — because that is the string results truncate, not the raw input
- Per-entry robots on the field's own Robots tab beside the previews: noindex and nofollow switches, plus every extra directive (
noarchive,nosnippet,noimageindex,notranslate,max-image-preview, and the rest) as its own switch. A field setting chooses which directives the field offers, and hiding one never erases saved values - Each field chooses which of its seven controls editors actually see, so a landing-page section can expose just a title and description while the blog keeps the full set. Everything is on by default, and hiding a control never erases data — saved values round-trip through hidden inputs, so a hidden noindex stays noindexed
Meta rendering
{{ craft.simpleSeo.renderMeta(entry) }}outputs title, description, canonical, robots, Open Graph, and Twitter tags with the full fallback chain applied — field value, then per-site default, then entry title. Every value is overridable per call, and an unknown override key throws rather than silently doing nothing (#5)craft.simpleSeo.resolveMeta(entry)returns the identical data as an array, so headless consumers get parity by construction- Canonical URLs are hardened: idempotent UTF-8 encoding, query params stripped from element-derived canonicals (allowlist via
canonicalAllowedQueryParams), paginated pages canonicalizing to themselves, and aLink: …; rel="canonical"header carrying the identical URL as the tag (#6)
Robots
- The invariant: with default settings this plugin cannot emit a site-wide noindex. No setting, save, or template call can cause it, and it is enforced by tests (#7)
- Hiding a staging environment is one explicit
siteWideNoindexflag inconfig/simple-seo.php. It forces noindex/nofollow in meta andX-Robots-Tag, disallows everything in robots.txt, and shows a persistent CP warning banner so nobody forgets it is on. There is deliberately no CP control for it - Per-site, CP-editable
robots.txt, served exactly as written and never rendered as Twig. It warns when a physicalweb/robots.txtshadows it, when the lockdown flag is overriding it, and when its content would block every crawler from the whole site - Any site can opt out entirely with Serve this site's robots.txt. Switched off, the plugin registers no
/robots.txtroute at all rather than 404ing it, so the URL falls through to your own template or a file in the web root. Saved content is kept, so switching back on restores it.siteWideNoindexoverrides the switch — a lockdown works through the meta tag, theX-Robots-Tagheader and robots.txt, and a toggle able to remove one arm would not be a lockdown
XML sitemap
/sitemap.xmlworks with zero configuration: an index plus per-section files, every URL-having section included until excluded, noindexed entries excluded, and hreflang alternates on multi-site entries (#8)- Never silently empty — empty files carry a reason comment, and
/sitemap.xml?explaingives the full per-section diagnosis - Generation is hydration-free: a cold 1000-URL file costs roughly 200ms at half the memory of hydrating elements, which matters because every entry save invalidates the cache
- An optional per-section, per-site
<priority>, empty by default and emitting no element. Google and Bing both document that they ignore the tag, and every other Craft SEO plugin defaults it to0.5, so the usual result is every URL carrying an identical value nothing reads. Opt in per section and it ships; leave it and nothing is claimed.<changefreq>is deliberately not offered - Any site can opt out with Serve this site's sitemap, on the same terms as robots.txt: no routes registered, section choices preserved, and the shipped default robots.txt stops advertising a
Sitemap:URL the plugin no longer answers
Console commands
Every command exits non-zero on failure, so it gates a deploy rather than producing a report nobody reads. Full reference in docs/console-commands.md.
simple-seo/doctor— the pre-deploy check: a site-wide noindex left on, a robots.txt disallowing every crawler, a sitemap being served with nothing in it, a physicalweb/robots.txtshadowing the CP one, a title format with no{title}. Findings are levelled, and only problems fail — a deliberate staging lockdown is reported as a note, because a check that cries wolf about correct configuration gets deleted from the pipelinesimple-seo/sitemap/explain— the terminal twin of/sitemap.xml?explain, with--strictto fail when an included section contributes no URLs.simple-seo/sitemap/flushdrops cached files after writes that bypass element events, such as a SQL import; there is deliberately nowarmsimple-seo/meta/show <id>— the fully resolved meta for one entry, or--tagsfor the rendered HTML, from the same model the front end renders from. Each value names its source — field, site default, entry title, element URL — so "why is this page's description that" is answered directlydoctorandaudit/metatake--jsonfor pipelines that parse as well as gate: full machine-readable reports, exit codes unchangedsimple-seo/audit/meta— live pages whose meta is missing, duplicated, or over the soft limits, measured on the resolved values that actually ship. Entries with no description of their own are reported as exactly that rather than as duplicates of one another, and that stays advisory: every such page resolves to the same site default, so failing on it would punish a supported way to run a site. No score, grade, or verdict
MCP
- With craft-mcp installed, the plugin registers six MCP tools for AI agents driving Craft: the doctor, the meta audit, the sitemap diagnosis, per-entry meta resolution with provenance, and two careful writes (title/description, noindex) flagged dangerous so gating clients ask first. A soft dependency — without craft-mcp, no integration code loads (docs/mcp.md)
GraphQL
- The raw field value is queryable with sub-selections, including a resolved
socialImageUrl, and is mutable as a JSON string through the same tolerant normalization as every other input path (#9) - Every entry and category also exposes
simpleSeo— the fully resolved meta, backed by the same model as the Twig output
Settings and permissions
- Per-site title format (with
{title}and{siteName}tokens), default meta description, and default social image (#4) - Four screens in the plugin's own CP section. General, Sitemap and Robots each edit one site at a time, picked with Craft's native site breadcrumb, so a twenty-site install stays navigable; Fields is install-wide and controls which SEO controls any field may offer at all, with each field then picking from that list
- The Sitemap screen is a table of every section with its live URL count, linking straight to that section's file — the same hydration-free diagnosis as
?explain, so "how many URLs will actually ship" is answerable without leaving the CP. A section carrying no URLs says why, right in the table. Every section can be switched off, including singles and sections with no URLs yet, and choices are stored inverted so a section created later joins the sitemap on its own - First-run guidance: until an SEO field exists, the General screen walks through the three setup steps and the Fields screen offers to create the field — and
doctorreports whether one exists and sits in a field layout - Access is permission-based, not admin-only. Craft's own Access Simple SEO opens every screen read-only; Manage SEO settings saves General, Sitemap and Fields; Edit robots.txt is nested separately, because it is the one screen where a wrong value stops search engines crawling the site at all. An SEO role can own the day-to-day settings without being handed the crawling switch — or admin rights
- Portable settings live in project config and deploy with your project; the default social image reference is stored in the database instead, so
allowAdminChanges: falseenvironments keep a fully working settings screen
Migrating from ether/seo
craft simple-seo/migrate/ether— dry run by default,--applyto write — converts every ether SEO field in place, keeping the same field ID, UID, and handle, with field layouts untouched. Titles, descriptions, social images, robots, and canonicals map per site, and the field's own configuration (translation method, searchability, instructions) is carried over (#10)- Redirects export as a Retour-importable CSV, ether's settings are surfaced for review, and dropped focus keywords are reported rather than silently lost
- It reads the database directly, so it works even when ether can no longer be installed on your Craft version
Documentation and translations
- Documentation covering getting started, the field, Twig/GraphQL, canonicals, robots, sitemap, settings, console commands, and the Ether SEO migration (#11)
- German, French, Spanish, and Italian translations (machine-translated, pending native review), kept in lockstep by a coverage test that fails when a new string lacks one
- Plugin icon and quality gate from the first commit (#1, #12)