3.6.0
Total CMS 3.6 is a publishing release. Every site gets search-engine and social metadata from one call in its layout, a video field, an undo history on every record, and public forms that no longer load the admin. Operators get podcasting, Markdown storage and inline editing in the collection table, and extension authors get Composer packages, agent skills and JavaScript field types.
Highlights
- Core SEO —
{{ cms.seo.head(page) }}emits the title, description, canonical, robots, Open Graph, Twitter and JSON-LD for any page, driven by an SEO card on each record and a Site SEO record for the defaults: templated titles and descriptions, favicons from one upload, free-form head tags for site verification, IndexNow submissions so Bing and other engines recrawl changes quickly, and a generator tag you can switch off - Video field — paste a YouTube, Vimeo, Bunny, Cloudflare Stream, Loom, Wistia, Livid, Publitio or Jet-Stream URL, or a direct file, and render it responsively with
cms.render.video(), inside cards and decks and in CSV too - Undo history — every save and delete keeps the version it replaced;
tcms backup:listandtcms backup:restoreput any of them back as an ordinary save - Public forms without the admin bundle —
cms.form.builder()on a public page now loads a runtime under 50 KB compressed instead of about 300 KB, with heavier fields loading their own code only when a form uses them - Podcasting — a bundled extension with show and episode schemas and a feed Apple and the directories accept, several shows per site
- Markdown storage — a collection can keep each object as a Markdown file with YAML frontmatter, and
tcms collection:convertmoves an existing one either way - Editing in place — edit a single value from the collection table, gated by a site switch and an access-group permission, plus HTMX fragment helpers and recipes for building the same on your own pages
- Typesetting — the
|typographyfilter sets quotes, dashes, ellipses and non-breaking spaces the way the site's locale expects, without touching markup or code - Extensions — install one with
composer require, ship an agent skill with it, and give a custom field type its own JavaScript - WebMCP (experimental) — make forms callable by an AI agent in the visitor's browser and hand it the MCP server's read tools, in Chrome 149+ during the origin trial; off by default
Upgrade notes
- Site Builder page descriptions and images moved to the SEO card. Existing pages are migrated automatically on the first request after updating, files included. Templates that read
page.descriptionorpage.imagemust switch topage.seo.descriptionandpage.seo.image; a layout that only callscms.seo.head(page)needs nothing. Update both ends before runningtcms pushortcms pullbetween sites. See the Changed section for detail - Dynamic OAuth client registration is now on by default, so AI clients such as Claude and ChatGPT can connect without a static client. The consent screen shows where each client sends the code. Turn it off under Settings → OAuth Server if you only use static clients
tcms collection:export --format=csvwrites the admin's CSV shape, with cards flattened into dot-notation columns. Scripts that parsed the old JSON-in-one-column shape need updating- RSS and WordPress imports slugify like the rest of Total CMS. Entries with non-ASCII titles get a new id once, so a feed polled after updating may import those entries again
- The
sortByfilter now sorts strings case-insensitively, matchingsortByKey. Lists that relied on capitalized names sorting first will reorder - A fieldset nested inside another fieldset now renders nested. Schemas that nested two fieldsets by accident will see a layout change
- The SEO head prints
<meta name="generator" content="Total CMS">. White-labeled sites can turn it off with Emit generator tag on the Site SEO record - Object backups are on by default, under
tcms-data/.system/backups/objects/, keeping the ten newest versions for up to 30 days. They are JSON only, never uploaded files. Tune or disable them under Settings → Backups composer updatenow runstcms deployon Composer installs: it clears the compiled container and every cache and runs pending migrations. Deploy scripts that already run it can drop the step- Extension authors: a custom field type that reached a service through its form now uses
$this->form->services(), and the HTTP client is Guzzle 8, which wraps exceptions thrown from progress callbacks
Security
-
One permission engine.
AccessControlServicecarried a second, session-flavoured copy of every rule inUserAuthority(fourteen near-identical methods) and the two had drifted. The session methods now resolve the caller'sUserAuthorityand ask it, so a rule exists once. Two rules that only the session side had now apply to OAuth and MCP callers as well: the super-admin-only utils (jumpstart,permission-matrix) cannot be granted by an access group on any path, and per-extension access follows the group'sextensionspermission -
The OAuth client and grant files are written like the API-key file. Both repositories hand-rolled their JSON store: no lock around a read-modify-write, an ignored write failure, and a file that no longer parsed was silently replaced on the next write. They now sit on
AtomicJsonStorethrough a sharedJsonListRepository: locked, atomic, private (0600), and a corrupt file is refused rather than overwritten. The access-groups file gets the same treatment -
The session lifecycle policy lives in
SessionActivityTracker. Activity tracking, id rotation every quarter of the lifetime, and idle expiry (with the remember-me exemption) were a private method duplicated in the two auth middlewares, untested; the tracker is shared and covered -
A revoked OAuth token stays revoked for its whole lifetime. The revocation list and the replay detector sized their caches at a fixed one hour and 30 days, so a token issued with a longer lifetime became valid again once its id fell out of the cache. Both now derive their retention from the configured access, refresh and auth-code lifetimes
Added
-
Image and file columns in a deck table. An
imageorfileproperty in a deck table's item schema now works: a saved row shows a small square thumbnail (or the file's type icon and name), an empty cell is a drop target, and hovering shows a 2×2 grid of edit, links, download and delete, with the field's usual edit dialog behind the pencil; replacing a file is a drop onto the cell. Uploads into a row that has not been saved yet wait for the save, as in a deck item; a row deleted from the table has its files cleaned up on save. Four things stood in the way: the upload path resolver only knew deck items with a dialog, a new row never deferred its uploads, a saved row's thumbnail rendered from the wrong path, and a cloned row's dialog kept the template's ids — which also broke validation on any cloned row whose dialog held a password field. Both decks now share one id-regeneration routine -
An extension can ship an agent skill. A
skill/directory next toextension.json—SKILL.mdplus optional references, the core skill's layout — is installed to.claude/skills/{vendor}-{name}/while the extension is enabled, by the sametcms skill:installthat installs the core skill: same fingerprint stamp, same zip-layout path rewrite, same--check, refreshed by the Composer plugin on everycomposer update. Enabling or disabling the extension installs or removes the folder at once; folders the command did not write are never touched. A skill is instructions to an agent, so the pre-enable review now shows its full text next to the source-code findings before the operator consents -
composer requireinstalls an extension. A Composer package of typetotalcms-extensionis discovered from its directory undervendor/and loaded like any other extension: Composer autoloads its classes,composer updatemoves it,composer removetakes it out, and nothing is copied intotcms-data/. It reports the version Composer installed, shows a Composer badge in the admin and acomposersource intcms extension:list, and can be disabled but not removed here —extension:removenames thecomposer removecommand instead. On an id collision the project copy wins over Composer, which wins overtcms-data/extensions/, which wins over bundled. The extension-starter repo already carries thecomposer.jsonshape; publishing to Packagist is all an author adds -
composer updaterunstcms deployfor you.totalcms/cmsis a Composer plugin, and it now runs the deploy cleanup — wipe the compiled DI container, clear every cache, run pending migrations — after everycomposer update, printing what it cleared. Until now the operator had to remember, and a forgotten deploy after a version bump meant a stale compiled container and aTypeErroron the first request. It does not run after a plaincomposer installof an unchanged lockfile, which needs neither; it never aborts the composer run; and the PHP-FPM reload remains the deploy script's job -
The SEO head names Total CMS as the generator.
cms.seo.head()andcms.seo.meta()print<meta name="generator" content="Total CMS">, the tag Wappalyzer, BuiltWith and the CMS market-share surveys read to know what a site runs on. The name only, never a version number: the name is what a directory needs, a version is what a vulnerability scanner reads. A new Emit generator tag toggle on the Site SEO record, on by default, turns it off for white-labeled sites -
A
|typographyTwig filter sets prose the way a typesetter would. Straight quotes become curly in the site locale's style (English, German, Swiss/Italian/Spanish guillemets, French with its narrow no-break space, Polish, Scandinavian), apostrophes and inch marks are told apart from quotes,--and---become dashes,...an ellipsis,1024x768gets a real ×,(c)a ©, numbers stay glued to their units and titles, and the last two words of every paragraph and heading are joined so no lone word hangs on the last line. It is HTML-aware — attributes,code,pre,scriptand comments are never touched — so it runs on styledtext,|markdownoutput and plain strings alike, and it is idempotent, so hand-typed proper glyphs survive. Fractions, ordinals and Typogrify-style CSS hooks are there but off, since they change markup. Nothing is stored: the change is render-time only -
The image meta dialog has Save and Discard Changes buttons. Save keeps your edits — the image field autosaves when the dialog closes — and until now that was the only way out. Discard Changes puts every field in the dialog back to what it held when the dialog opened, as already-saved values, so the close-time autosave has nothing to send and a later Save of the form has nothing of the abandoned edit to sweep up. That last part is why a simple "don't autosave" would not have done: the edit would still have been sitting in the fields. Escape discards too; clicking outside the dialog saves, like Save. Same buttons on the gallery's shared dialog. A featured-star click is not a dialog edit (it saves itself), so discarding leaves it alone. Requested as #111
-
Every object now has an undo history. Each save keeps the version it replaced and each delete keeps the final state, under
tcms-data/.system/backups/objects/— records only, never uploaded files, so the footprint stays the size of the JSON. It is on by default and needs no setup. Two commands read it back:tcms backup:list <collection> <id>shows the snapshots newest first, andtcms backup:restore <collection> <id> <snapshot>(or--latest) puts one back as an ordinary save — the index rebuilds, listeners fire, and the state being replaced is itself snapshotted, so a restore is never a one-way door. A deleted object's history survives the delete, and restoring recreates it. Retention is count and age (defaults: the ten newest, nothing older than 30 days) — count alone let one busy afternoon evict last week's version; age alone let a never-edited record hold a snapshot forever. Tune or disable under Settings → Backups, or underbackupsinconfig/tcms.php. Sync's pre-overwrite snapshots (which already existed) now land in the same tree, and the class behind them,SyncBackupService, has become the sharedBackupStore. Imports never write snapshots. Underneath it,object.deletednow carries the deleted record asprevious— until now every listener on that event was blind to what had been deleted. See Backups -
cms.render.picture()renders a responsive image. The same three arguments ascms.render.image(), returning a<picture>with one<source>per modern format — AVIF and WebP by default — each carrying asrcsetof ImageWorks candidates at 480, 768, 1024, 1440 and 1920 pixels, then an<img>fallback in the image's own format that carries the samesrcsetso a browser without<picture>support still picks a size. Everywdescriptor is the width ImageWorks will actually deliver, not the one asked for: ImageWorks never upscales, so a candidate wider than the source would deliver the source's own width and lie to the browser about it — those are dropped, and the source width joins as the largest candidate.widths,formatsandsizesride in the options, or site-wide underimageworks.picture; awin the transforms is a ceiling on the largest candidate. A GIF gets no<source>at all, since re-encoding would drop its animation. See Render → picture() -
IndexNow tells search engines what just changed. A toggle in the SEO Site Collection (Collections → Seo Site, beside Emit JSON-LD — not the admin Settings groups); with it on, every publish, edit and delete of a sitemap-listed URL is submitted to the IndexNow network, which one submission reaches in full — Bing, Yandex, Seznam, Naver. Google does not take part, so it complements the sitemap rather than replacing it. What goes out is decided by the sitemap's own rules, applied to the one record that changed — sitemap on, include/exclude filters, no No Index — so a crawler is never told two different things about one URL, and a post moved back to draft or deleted is submitted too, so it is recrawled and dropped quickly. Imports count too — a CSV that publishes a hundred posts submits them together when it completes. Submissions are queued for
tcms jobs:process, never sent during a save, and coalesced: everything that changed between two runs goes out as one request (up to the protocol's 10,000 URLs), and a record saved five times in that window is submitted once; a rate limit puts the unsent URLs back for the next run, a rejection is logged once, and clearing the job queue discards the pending submissions with it. The verification key is generated on the first save with the toggle on and served at/{key}.txt. See Core SEO → IndexNow -
Accordions in the form grid. A schema's
formgridcan now collapse sections of its admin form:>>opens a panel,<<closes the group, and consecutive panels before a<<form one accordion. The<<is what defines the group, and its size decides the resting state - one panel renders closed, which is how you tuck advanced fields out of the way, while two or more render with the first open and only one open at a time. Two<<-terminated runs are therefore two independent accordions, so both shapes come out of one construct with no flags. A panel's interior is its own mini-grid: dividers, headers and[[ ]]fieldsets all work inside one. Panels cannot nest, not by rule but by grammar ->>always ends the panel it appears in - and an unterminated group simply runs to the end of the formgrid. Fields that build a widget reading the DOM at construction (alistfield's chips, for one) rebuild themselves the first time their panel opens, once, so a collapsed panel does not leave them half-rendered; a custom field type opts into the same treatment by overridingreinit(). See Form Grid Layout -
A public form no longer needs the admin bundle.
cms.form.builder()on a public page used to needcms.adminAssetsHead()andcms.adminAssetsBody()in the layout, because the form script lived in the admin bundle with every field editor the dashboard can show — around 300 KB compressed for a four-field form. The form runtime is now theformscore frontend feature thatcms.assetsHead()andcms.assetsBody()already emit:forms.cssand a smallforms.jscarrying the light field classes, with a heavier field (styled text, uploads, code, lists, decks) loading its own module the first time a form renders it; the whole feature is under 50 KB compressed. Field classes reach the form runtime through a registry the entry point fills, so the dashboard keeps building every field up front, exactly as before. A site with no public forms drops the pair withformsinfrontendAssets.except;assetsBody()emits the translation catalog and config the script reads whenever the feature is on the page; the admin helpers keep working where a layout still calls them. See What a public form needs -
WebMCP extension (experimental). Makes forms callable by browser-resident AI agents through the WebMCP origin trial in Chrome 149+:
webmcp_form('contact', {name: 'send_message', description: '…'})renders a form annotated with the declarative WebMCP attributes and answers an agent's submit with the form's own save result; tool descriptions come only from schema metadata and the operator's words, autosubmit is opt-in per form, and registration forms are refused. Read tools are the MCP server's own, registered from a statelesstools/listcall — see the Changed entry below for the shipped design. Bundled, off by default. Core gained three generic seams for it: anattributesform option,settings.attributeson any field control, and per-property options on auto-built forms; andsave()in the form runtime now returns its promise. See WebMCP -
Extension field types have a JavaScript half.
addFieldType()made a field type first class on the PHP side, but the admin bundle built every unknowndata-typeas a plain text field, so an extension's field could render but not behave.window.TotalCMS.registerFieldType(type, class)registers a class extendingTotalField(also onwindow.TotalCMS) and the form factory builds it, so the field takes part in unsaved-state tracking, saving and the action chain like a core field. Core type names cannot be replaced -
The agent skill can tell when it has gone stale. An agent reads
.claude/skills/totalcms/once, at the start of a session, so a copy left behind by an update keeps steering it with last version's conventions — silently.tcms skill:installnow stamps the installed copy with a sha256 of the shipped skill files, in a.skill-manifest.jsonsidecar and inSKILL.md's own frontmatter, andtcms skill:install --checkcompares the two: exit 0 and "Agent skill is current", or exit 1 naming the files that differ. Freshness is the skill's content, not the release number — the skill text changes far less often than the version does, so a release that leaves it alone leaves the check passing, and the fingerprint is taken before the zip path rewrite so both layouts agree. The skill itself now opens by telling the agent to run the check, re-install when it is stale, and ask for a fresh session, since text already loaded cannot replace itself. Wire it to a Claude CodeSessionStarthook to have it run for you. See CLI -
SEO for pages the router did not render.
cms.seo.head(page)only knew the page when the page router rendered the template, so a Stacks page or a hand-written front end got the site defaults. Two ways in:cms.builder.page()returns whatever routes the current request (or a path you pass) — a Stacks site keeps a record per page as an SEO carrier and callscms.seo.head(cms.builder.page()); on a collection URL such as/blog/{id}the call returns the object itself, tagged with its collection, so the same line gives a post page its article head with no record at all — andcms.seo.head()now accepts a literal array ({title, description, image, url, seo: {…}}) as an ad-hoc page, the shape a stack would render from per-page fields. A stringimageis emitted as given; an array with anidis still a collection object. Two things make the carrier record honest: a page's Page Template is no longer required — leave it empty on a URL Total CMS does not serve and the router never tries to render it, whilecms.builder.page()still finds it — and the router treats/blog/index.phpand/blog/as the same address, so a record routed at/bloganswers under either spelling instead of/blog/{id}capturingindex.phpas a post id -
Three more MCP prompts:
tcms_model_collection,tcms_write_content,tcms_audit_seo. The bundled docs extension already shipped five workflow prompts; these three carry the judgment the terminal agent skill carries, for the many people who only ever reach Total CMS through claude.ai, Claude Desktop or ChatGPT and will never runtcms skill:install. Modelling: match the field to the shape of the value, let the field decide the type, help text on every property because agents read it, timestamps are not automatic, the SEO card needs a full formgrid row and an index entry. Writing: the schema's help text is the brief, read a neighbour for house style, patch rather than replace, never invent an id, never touch a secret. Auditing: walk the three SEO layers bottom-up — Site SEO record, collection mapping and URL, the record's own card — and report the exact field to change. Each one grounds its claims in this install's own docs throughdocs_lookup/docs_search/docs_get, so the advice matches the version that is actually running. See Documentation Tools -
integeris a recognised property type. JSON Schema has always allowed it, and the object validator has always enforced it, but the schema editor did not know it: the type dropdown fell through and the schema page drew the question-mark icon beside the twointegerproperties on the MCP tool schema. It now sits in the type list, the dropdown offers it, and it shares the number icon -
Site SEO: Contact Email and Contact URL. Two fields in the Site SEO record's Organization section; set either and the Organization node gains
emailand acustomer supportContactPoint, which search engines and AI answer engines read as a legitimacy signal. Both also appear incms.seo.data().site. Nothing is emitted while they are empty -
frontendAssets.except: leave core frontend features a site never renders out ofcms.assetsHead()/cms.assetsBody()by name —['icons', 'cms-grid', 'gallery', 'pagination', 'htmx']on a marketing site drops some 50 KB of stylesheets and script from every page. A stylesheet and a script for the same feature share one name, sogalleryremoves both files and the preload hint rather than half a pair; unknown names are ignored and extension assets are never affected. It is an exclude list on purpose: a newly enabled extension or a new core asset still arrives. The same names work per call —cms.assetsHead({except: [...]})for a Stacks page with no config file; pass the body helper the same list. See Frontend Assets -
Core SEO:
{{ cms.seo.head(page) }}in a layout emits the title, description, canonical, robots, Open Graph, Twitter, the site's own meta tags and one JSON-LD graph (Organization, WebSite, WebPage, BreadcrumbList, Article) for any Site Builder page or collection object, with zero template work: values come from a new SEO card on pages (opt-in for your own schemas), a per-collection field mapping, and a new Site SEO record, in that order. Blog collections get BlogPosting markup by default. The site-wide values live in a reserved single-object collection rather than a settings panel, which makes the default social image and the organization logo real uploads — the share image cropped to 1200×630 through ImageWorks, the logo never cropped or upscaled, only bounded to 600px wide so its aspect ratio survives — and its record readable in Twig like any other object;seo-siteis a reserved schema, so you cannot save over it, but you can extend it — a custom schema with"inheritFrom": ["seo-site"]plus your own properties, bound to a singletonseo-sitecollection, puts extra site-wide fields on the same record while core keeps reading the ones it knows — and a collection's Settings → Schema Overrides (and Object Specific Overrides) can relabel and reconfigure the fields it does have; Project Setup → Setup Default Collections creates it, on an existing site as well as a new one. The SEO card's Title accepts${property}placeholders, so one card value can compose a title out of the record (${name} — ${city}, plus${site}for the site name, and dot paths into cards); a collection has a Title Template and a Social Title Template of its own, with the same placeholders, for content whose headline is not calledtitleor that wants every object's title shaped the same way (${title} | Reviews). In the schema sidebar the schemas Total CMS manages for you — the embedded sub-schemas that only ever appear as cards inside another schema (seo,seo-collection,sitemap-meta, the MCP and automation pieces) together with the reserved schemas it provisions collections from (seo-site,builder-page,automations,dataviews,mailer,playground, thetotalcmspair) — are now grouped under Internal instead of sitting among the built-in schemas. They are only grouped, not hidden: they remain listed, selectable and editable exactly as before. A detail page passes its object instead —cms.seo.head(post, {collection: 'blog'})— and the post gets its own title, canonical, share image and Article node.title(),meta(),og(),canonical()andjsonld()emit one slice each when a layout wants to place the pieces itself. The four bundled starters now carry{% block seo %}in place of their old{% block title %}/{% block description %}pair; a hand-written layout adoptinghead()should delete its own<title>and<meta name="description">lines, or the page ships two of each. Pages and objects marked noindex leave the sitemaps, so a crawler is never toldnoindexin the head and handed the same URL in/sitemap.xml. Nothing has to be rebuilt to adopt this: an existing builder page picks up itsseocard in the index the next time the page is saved, no index rebuild is required, and a page without the card is treated as indexable exactly as before. See SEO -
Sidebar More menu: the admin sidebar can get crowded, more so now that extensions add icons of their own. Under Settings → Dashboard → More Menu, check the sidebar items you rarely use and they move into a three-dots menu at the bottom of the sidebar — still one click away, still in Quick Navigation, just out of the way. Any item can be moved, extension items included. The sidebar, the More menu and Quick Navigation now read from one registry, so a page added to one shows up in all three
-
Video field: paste a YouTube, Vimeo, Livid, Bunny, Cloudflare Stream, Loom, Wistia, Publitio or Jet-Stream URL (or a direct MP4/WebM link), optionally upload a poster, and
{{ cms.render.video(post, {property: 'promo'}) }}renders the right player — a click-to-play poster by default (the iframe loads on click;facade: falsefor an eager iframe), or<video>for direct files. The provider, video id, vendor thumbnail and title are recorded once on save; nothing is downloaded and no video bytes go through T3.youtu.belinks now work in the existingembed/youtubeTwig functions too. See Video Setup Default Collections now also creates avideocollection, one hosted video per object, alongsideimageandfile. -
Video inside cards and decks, and in CSV: a
videoproperty works inside a card or a deck item exactly as at the top level — provider, thumbnail, title and ratio are derived on save, and the poster uploads to the nested path. In CSV export and import a video is one column holding its URL; the save pipeline fills in the rest. The collection table shows a video column as its thumbnail. In the admin, the poster saves itself like a top-level image field — upload, alt or focal-point edit, delete — with no separate Save of the object. Poster transforms are a trailing argument,cms.render.video(post, {property: 'promo'}, {w: 800}), mirroringcms.render.image(). The click-to-play facade preconnects to the player's hosts on the first hover or touch, so DNS and TLS are done before the click, and keeps the poster in place until the player's iframe has actually loaded -
Markdown object storage: a collection can store each object as
{id}.md— YAML frontmatter for every property, the schema'scontentproperty as the body — instead of JSON, for content people would rather edit in a text editor or a git repo. Choose Storage Format when creating the collection; everything above the repository (API, MCP, Twig, sync, JumpStart, exports, the admin) works unchanged.tcms collection:convert {collection} --to=markdown|jsonrewrites an existing collection in place and is safe to interrupt. Hand-edited files show up aftertcms repair:index. See Storage Format -
Inline editing in the collection table: hover a cell and click its pencil to edit that one value in place — Enter saves, Escape cancels, the cell shows the new value without a page load. The value is saved exactly as the object form would save it, because the swapped-in fragment is a real one-field form: a toggle stays a boolean, a list stays a list, styled text opens the editor. Text, number, date, choice, list and styled-text fields offer it; identity fields, secrets, readonly timestamps and composites such as images and decks still open the object. See Admin Dashboard
-
An inline-editing gate. Inline editing in the collection table now answers to a site-wide switch and an access-group permission, so it can be turned off for a site or narrowed to the people who should have it. Settings → Dashboard → Inline Editing is the master switch — off, the pencil disappears from every collection table and an inline save is refused for everyone, super admins included. With it on, the new Inline Editing permission on each access group decides who may use it, and the user must still be able to update that collection: the pencil never appears for an edit that would come back forbidden. Groups saved before the permission existed read as granted, so nothing changes until you turn something off. The check is one question with no notion of surface —
AccessControlService::canInlineEdit()— so a future live-site editor is gated by exactly the same switch and permission -
HTMX fragment helpers and recipes: the query endpoint has always been able to return rendered HTML (
format=html&template=…) — Load More is one consumer of it. Fivecms.renderhelpers now build those URLs for your own elements —queryUrl(),viewQueryUrl(),objectFragmentUrl(),saveUrl()andincrementUrl()— and a new HTMX Recipes page walks live search, faceted filtering, sort toggles, lazy sections, polling widgets, boosted navigation, member-only fragments, quick-view modals, a contact form with no JavaScript, newsletter signup through a webhook, and likes. The fragment templates are ordinary Twig files edited in the admin -
HTMX-aware API responses: when a request carries the
HX-Requestheader, an API error comes back as an HTML fragment rather than JSON — same status, same message, and for a validation failure one<li data-field>per field — because htmx swaps error responses like any other and a JSON body would land in the page as text.GET /api/collections/{c}/{id}acceptsformat=html&template=…to render one object. Object create, update and patch answer an htmx request that names atemplatewith that template rendered against the saved object. A synchronous automation webhook whose handler returns a string answers an htmx request with it as HTML -
Public counters: a number field with
publicIncrement: truein its settings may be incremented or decremented by anonymous callers — likes, "was this helpful", download counts — without opening anything else on the object. The grant sits on the field, not in the collection'spublicOperations, so a product can openlikesand keepstockclosed. Anonymous counter writes are limited to 60 per minute per IP -
Podcasting, as a bundled extension. Enable Podcast under Extensions (Standard edition and above) and it ships two schemas —
podcastfor the show, created as a Single Object Collection, andpodcast-episodefor the episodes — and serves a feed the directories accept at/api/ext/totalcms/podcast/feed, with no page or template involved, so it works the same on a Stacks site as on Site Builder. A show names its own episodes collection on the record, which is what lets a site host several shows: each is its own singleton collection with its own feed at/feed/{show}.podcast_feed(show)renders the same feed inside a page for sites that want it at an address of their own. The feed's address is wherever it is served — the route, or the page — so there is no Feed URL field to fill in: theselflink and the Podcast Index GUID derive from it. Drafts and future-dated episodes are held back, newest first, artwork and categories come from the show; each episode's audio is either uploaded, served through the streaming route with every listen counted, or linked to your own host with an Audio URL and size, and transcripts and chapter files work the same way. The category field selects from Apple's list, and everything Apple requires is a required field, so the form will not save a show the directories would reject. Most sites do not have a podcast, which is why none of this lives in core any more: the schemas were reserved schemas and the call wascms.feed.podcast()in an unreleased build. See Podcasts. The extension ships an agent skill, the first bundled one: enable it and.claude/skills/totalcms-podcast/teaches coding agents the collections, the feed and the pitfalls -
propertyOptions: "schemaCollections:<id>": a select that lists the collections whose schema is<id>or inherits from it — the picker the podcast show uses to name its episodes collection, generalised from the Site Builder pages picker (pageCollections, which still works) -
Podcast feeds:
cms.feed.rss()takes an optionalpodcastblock on the feed details and on each item and writes the iTunes and Podcast Index tags Apple Podcasts, Spotify and the open directories read — author, owner, artwork, categories, explicit, episode and season numbers, duration, transcripts, chapters, people, soundbites, funding and a stablepodcast:guidderived from the feed URL. Without the block the feed is unchanged. Apple's required fields are required here too, and a category that is not on Apple's list fails with the closest matches named, so a feed that renders is a feed the directories accept. See Feeds -
tcms collection:create: create one collection from the command line. For a reserved id —seo-site,automations,podcastand the rest — leave--schemaoff and the collection is provisioned with its shipped name and singleton flag, which is the piece an existing site was missing when a new reserved collection ships:tcms collection:create seo-sitegives you just that one, where Project Setup → Setup Default Collections creates every default. Any other id is a custom collection and takes--schema, with optional--nameand--singleton, and--jsonfor scripts. -
A Social Title on the SEO card: a shorter, punchier title for share cards. It replaces
og:titleandtwitter:titleonly —<title>is left alone, and the site title template does not apply to it, because "Bistro — book a table" reads better on a link preview than "About | Bistro" does. Leave it empty and the share tags keep using the page title exactly as before. Page-level only: there is no collection mapping for it. -
A Social Title Template on the Site SEO record. A share card and a browser tab want different shapes, so the share title has a template of its own:
${title}and${site}, applied toog:titleandtwitter:titleand nothing else. The default is the bare${title}, which is what the share tags emitted before, and${title} — ${site}on a blog gives every post a share title of its own structure without touching<title>. A card's Social Title goes through it the same way the card's Title goes through the title template. The Title Separator setting is gone — the Title Template already takes any separator you type, so a second field that rewrote the|in it was one knob too many; a value stored on an existing record is ignored. -
A Social Description, everywhere the description has one.
og:descriptionandtwitter:descriptionare what a link preview sells the click with, and they rarely want the sentence a search result wants. All three SEO layers now carry a social description beside the description they already had: Social Description on the SEO card, a Social Description Property in a collection's mapping, and a Default Social Description on the Site SEO record. It resolves exactly the way the title does — card, then collection, then site — and falls back to whatever description resolved, so a site that never fills any of them emits precisely the tags it emitted before. The collection's is a${property}template like its title pair, rendered over the record, stripped of markup and markdown and capped at 160 characters; text written on the card or as the site default goes out as written.<meta name="description">, the JSON-LDdescriptionand the feeds are untouched by it, andcms.seo.data()carries the resolved value associalDescription. -
A collection's Image Property takes a gallery. The mapping lists every property the schema has, so a gallery was always selectable there — it just emitted no share image, because a gallery is a list of images rather than an image and nothing in the chain looked inside it. Point the mapping at one now and its first image becomes
og:image/twitter:image, with that image's own alt text, for a collection whose objects carry a gallery instead of a single hero. The first image on purpose: ImageWorks also servesfeaturedandrandom, and both pick witharray_rand(), so a share image built from either would change between two scrapes. The gallery has to be a property of the object itself — one nested inside a card falls through as before. -
A collection's description mapping is a template, not a property picker. Description Property is now a Description Template, joined by Social Description Template, so all four values a collection maps — title, social title, description, social description — speak the one
${property}syntax the rest of Total CMS speaks. A description can now compose (${cuisine} in ${city}), carry${site}, and reach a dot path into a card, none of which a select could express. A bare property name still means that property, sosummaryand${summary}are the same mapping and nothing typed the old way stops working; a value with a space in it is literal text, one description for every object in the collection. The blog and feed defaults are now written${summary}and${content}. -
Meta Tags on the Site SEO record replace the three verification fields. Search Console, Bing and Pinterest each hand you a whole
<meta>tag; the old fields wanted just itscontentvalue, in the right one of three boxes, and had no room for a fourth service. Meta Tags is one code field printed in the<head>exactly as written, after the SEO tags and before the JSON-LD, on every page that callscms.seo.head()orcms.seo.meta(): paste the verification tag as given, or anymeta,linkorscriptthe site needs everywhere. Nothing is filtered — editing the Site SEO record is the same trust as editing a template.cms.seo.data().metaTagscarries the raw string; theverificationkey is gone. -
One Structured Data Type, one placeholder syntax. The Site SEO templates now use
${title}and${site}, the same${...}every other title in Total CMS uses. The collection's Title Property select is now a Title Template, joined by a Social Title Template, so a collection can shape every object's title (${title} | Reviews) without touching each card. And the Structured Data Type is one list in both places —Webpage,Article,Blog post, or Automatic (the collection's setting for an object, Webpage for a Site Builder page) — that drivesog:typeand the JSON-LD node together instead of the JSON-LD alone: blog and feed collections default toBlog post(aBlogPostingnode, which is what Google's guidance asks of a blog), a Site Builder page can opt into Article from its card, the oldNoneis simplyWebpage, and article types emitarticle:published_timeandarticle:modified_timefrom the record'sdate(elsecreated) andupdated. -
og:image:altandtwitter:image:alt: when the share image carries alt text it is now emitted alongside the image, so a link preview is described for anyone reading it with a screen reader. The alt is read from whichever image actually won the fallback chain — the SEO card's, the collection's mapped image property, or the Site SEO default image — so it always describes the picture on the card rather than a runner-up. An image with no alt simply omits the tags. -
A color field can be empty. A native color picker always holds a color — black until someone picks one — so an optional color was stored as
#000000whether or not anyone meant it. A color property with"settings": {"clearable": true}now gets a No color button beside the swatch; a cleared field stores'',object.color.hexis empty, and the swatch becomes a transparency checkerboard so the black the picker insists on painting is not mistaken for the value. Off by default: a field that never asked for it behaves exactly as before. The Site SEO record's Theme Color is the first to use it. See Color Field -
Favicons from the Site SEO record. Upload one square PNG as Icon and
cms.seo.head()prints the whole set on every page — the 32px tab icon, the 192 and 512 sizes Android and Google's result pages read, and the 180px Apple touch icon — all cut from that one upload by ImageWorks, and/favicon.ico, which browsers and crawlers request whether or not the head names an icon, is served from the same 32px image wrapped as an ICO (a 22-byte header around the PNG, so it works on GD-only hosts). The Apple touch icon is the Icon inset on a tile filled with the Theme Color — iOS paints transparent pixels black, so the tile matches the site's chrome instead, and the mark does not run edge to edge; black when there is no Theme Color — or a Touch Icon upload of its own; either is served at/apple-touch-icon.png, the root path iOS requests on a page whose head has no touch-icon link. An optional SVG icon is afileupload served at/favicon.svgand listed ahead of the PNGs, and a Theme Color printsmeta theme-color. Nothing is emitted without an Icon. A web app manifest is a page rather than a setting: give a Site Builder page the route/manifest.webmanifestand a template that renders the JSON — the router already serves that extension asapplication/manifest+json— and the head links it.cms.seo.icons()prints just these tags for a layout that places the pieces itself. See Icons -
cms.seo.data(subject, options): the same valuescms.seo.head()prints, as a plain array instead of markup —title,rawTitle,socialTitle,description,socialDescription,canonical,robots,noindex,ogType,ogImage,ogImageAlt,twitterCard,siteName,twitterHandleandmetaTags, plus asiteblock carrying the Site SEO record's ownname,baseUrl,defaultImage,defaultImageAlt,organizationName,organizationLogoandsameAs. For the share button that needs the title, the preview card that needs the image, or a JSON-LD node of your own that should say the same thing as the head rather than a hand-maintained copy of it. -
Your own JSON-LD in the same graph:
cms.seo.head(page, {jsonld: [node, …]})— andcms.seo.jsonld()with the same option — merges template-supplied nodes into the single@graphcore already emits, instead of leaving a second<script>describing an unrelated island. The extra nodes land after the core ones and the first node for any@idwins, so a node of yours can reference{base}/#organization,{base}/#websiteor{url}#webpageand link to the real node rather than replacing it with a stub. Nodes are JSON-encoded with the same</script>protection as core's, never interpolated. Each entry must be a hash; anything else is dropped. -
Extensions can put a meta tag in the head.
addFrontendMeta()andaddAdminMeta()emit an escaped<meta>throughcms.assetsHead()/cms.adminAssetsHead()before any stylesheet — an origin-trial token, a verification tag — under the existing asset capabilities
Changed
- WebMCP registers the MCP server's own read tools. The bundled extension no longer carries two tools of its own over the REST API; the browser is a stateless MCP client of
/mcp, calling as whoever is signed in, and registers only the tools the server marks read-only from whatevertools/listreturns —query_collection,get_object,search_collections, saved-query tools, data views and the rest. Core recognizes a same-origin session on/mcp(an administrator's session is the admin persona; any other signed-in user is the authenticated persona, reaching only collections whose MCP Access is Authenticated or Public and that their access groups grant read on, never one marked Admin only) and makes every signed-in session read-only, so no write tool is ever listed or callable for it; a plain visitor stays the anonymous client it always was, so it's the script's own filter — tools markedreadOnlyHint: trueonly — that keeps a public write tool an extension registered off the page. What an agent may read is therefore the MCP tab's rule — MCP Access per collection, the per-property expose flag,mcp.enabled,mcp.publicAccess, and the edition (Standard and above; Data Views tools stay Pro and come back empty below it) — in one place, and a field hidden from MCP is hidden from the browser agent too. The extension's "Collections exposed as read tools" and "Search results per call" settings are gone, the origin-trial token is rendered in the head rather than injected by script, and a browser without the WebMCP API no longer makes a request on every page. API-key, OAuth and anonymous MCP clients see no change - Dynamic client registration is on by default, and the consent screen names where the code goes. With OAuth on and registration off, every AI client's connect flow failed out of the box, so
oauth.dynamicRegistrationnow defaults to true. The risk it guarded against is consent phishing, since a self-registered client picks its own name: the consent screen now shows the redirect host for every client, sorted into a known AI client, an app on this computer or an unknown host, marks self-registered clients as such, and warns when one sends the code to an unknown host./oauth/registerallows 60 registrations per IP per hour instead of 10, because claude.ai registers from Anthropic's shared servers on every connect attempt and a few retries used to lock a site out for the hour. The access, refresh and auth-code lifetimes are select fields in Settings; a custom value set intcms.phpstays selectable tcms collection:export --format=csvwrites the same CSV as the admin export. Cards and localized fields flatten into dot-notation columns (mycard.label) — the shapetcms collection:importand the admin importer read back. The command used to build its own CSV with nested values JSON-encoded into one column, so a CLI export could not round-trip. Scripts that parsed the old shape need updating- RSS and WordPress imports mint the same ids as the rest of Total CMS. Both importers used a private slugifier that kept accents and handled symbols differently from the
idfield; they now useSlugData::slugify(). Ids for entries with non-ASCII titles change once, so a feed polled after upgrading may import those entries again - The
sortByfilter sorts strings case-insensitively, matching thesortByKeyfunction. The two were separate implementations with different comparators, so{{ items|sortBy('name') }}and{% for x in sortByKey(items, 'name') %}ordered mixed-case names differently. Templates relying on capitalised names sorting first will see the new order - A fieldset nested inside another fieldset now renders as a real nested fieldset. Its fields used to render flat in the outer grid while the inner
<fieldset>was never emitted at all, because only top-level containers were resolved when fields were placed. Field placement now walks the whole container tree, which is also what lets a[[ ]]sit inside an accordion panel. Schemas that nested two fieldsets unintentionally will see a layout change; schemas that never nested them are unaffected TotalFormFactoryis the form runtime plus acms.formfacade, not everything at once. The factory kept the builders the runtime is about — object, collection, schema, template and deck item forms, a form around pre-rendered markup, one field — and hands the rest to four helpers it delegates to: the admin's own page forms (login, reports, imports and exports, the job queue, dev mode), the settings and extension-settings forms, the preset layouts for blog, feed, mailer, playground and data views, and the one-field forms, which are now a table of default collection, property and field type instead of twenty near-identical methods. Everycms.form.*call and every rendered form is unchanged; the golden suite proves it- The form runtime is built from three objects instead of forty-one arguments.
TotalFormtook 41 constructor parameters, five more through setters after construction, andTotalFormFactorytook 31 of its own mostly to forward them; three subclasses called the parent positionally, so a parameter added in the wrong place silently shifted their arguments. A form is nownew ObjectForm(FormServices, FormOptions): the services object carries the fourteen collaborators every form shares, the options object carries the per-form settings (an undeclared option key is an error that names the key), and the option lists fields ask for — collections, pages, views, locales, Apple's podcast taxonomy — live inFormOptionSources, reached through$form->optionSources(). Rendered HTML is unchanged: 87 golden snapshots of every form the factory can build, taken before the change, pass after it. A custom field type that reaches a service through its form now uses$this->form->services(); the same-named list methods on the form still work - The MCP server's connect-time instructions now carry the skill's judgment. Every client receives
instructionsin the initialize response and keeps them in context, so this is where people who only ever reach Total CMS through claude.ai, Claude Desktop or ChatGPT get what the terminal skill gives an agent: discover before acting, look things up with the docs tools instead of guessing, patch rather than replace, respect field shapes, never invent ids, and — for admin connections — how to model a schema well. Persona-aware: a read-only connection is told it can only read and what writing needs, not how to write - The Base URL fallback keeps the request's scheme. With no Base URL saved on the Site SEO record, canonicals, Open Graph URLs and sitemap entries were built as
https://{domain}regardless of how the site was reached, so a local site over plain http claimed https addresses. The fallback is now the scheme the request arrived on (https when there is no request, as on the CLI) on the site's configured domain; a saved Base URL still wins, which remains the answer for a proxy that hides TLS or awww/apex choice cms.adminAssetsHead()now carries the dashboard accent. The--totalform-accentrule that applies the Settings → Dashboard accent color was an inline<style>in two admin templates; it is now emitted by the head helper after its stylesheets (and bycms.adminAccentStyle()on its own for the login and setup layout), so a customer admin page built on the helper gets the configured accent rather than the default bluecms.adminAssetsBody()now defines the admin globals.window.TCMS_TRANSLATIONS(the JS translation catalog) andwindow.TCMS_CONFIG(the dashboard settings the admin scripts read, such asconfirmCountdown) used to be two inline scripts in the dashboard template, so a customer admin page that followed the docs and called the helper gotadmin.jswithout its inputs: translations fell back to the raw English keys and the confirm countdown to a hard-coded value. The helper emits both ahead of its script tags, the dashboard template no longer does, and the publiccms.assetsBody()is unchanged- Twitter card tags are explicit again.
twitter:title,twitter:descriptionandtwitter:imageare emitted alongside theog:tags rather than relying on every scraper's Open Graph fallback - Authored meta descriptions are no longer truncated. A description typed on the SEO card, or the site default, ships exactly as written; the 160-character cap now applies only to a description derived from a mapped property (which may be a whole summary or body). Search engines index the full tag even though they display part of it
- Guzzle 8: the HTTP client dependency moved from Guzzle 7 to 8 (with PSR-7 3 and Promises 3). No API changes for sites; the only internal adjustment is that a download stopped by the maximum-size guard reports its own reason again (Guzzle 8 wraps progress-callback failures in a generic message)
- A Site Builder page's Meta Description and Page Image now live on its SEO card. Both used to sit at the top of the page form, next to the title, duplicating fields the SEO card already had — two homes for one value, and a page could disagree with itself.
seo.descriptionandseo.imageare now the only page-level source for the meta description and the share image;titleis unchanged. Existing pages are migrated for you, once, on the first request to the site after updating — a Site Builder page request counts, so nobody has to open the admin to trigger it: an old page description moves into the card's Description and an old page image into its Social Image, the image files moving with it frombuilder-pages/{id}/image/tobuilder-pages/{id}/seo/image/. Each migrated page is re-saved silently, then the pages index is rebuilt once; if your search provider indexesseo.description, runtcms search:reindexafterwards. It is idempotent and never overwrites — a page whose card was already filled in keeps the card's value. Update both ends beforetcms push/pull: an export or sync payload from an older version still carries pagedescription/imageat the top level, which the new schema discards; the automatic migration does not run twice. Templates need one rename:page.descriptionandpage.imageno longer resolve, so readpage.seo.descriptionandpage.seo.imageinstead (a hero image nested in the card needsproperty: 'seo.image'onimagePath()). A layout that only callscms.seo.head(page)needs no change, and the bundled starters are already updated. - A
noindexpage no longer emits a canonical link. Declaring a canonical URL tells a crawler which address to index, and asking not to be indexed in the same head is a mixed signal — one that search engines are documented as resolving by ignoring the directive.cms.seo.head()andcms.seo.canonical()now drop<link rel="canonical">whenever the record's SEO card has No Index on.og:urlstill prints: it is an identity for a share card, not an instruction to a search engine. - Markdown is stripped out of mapped descriptions. A collection's mapped description property is as often markdown as it is HTML, and
strip_tagsleaves markdown alone — so a meta description could ship as**Bold** and [a link](/somewhere). The markers are now removed after the tags: links and images keep their text and lose their brackets and URL; strong, emphasis and inline code lose their wrappers; a heading marker, list bullet or>at the start of a line goes. It is deliberately narrow — a lone!, a$5 * 3or amy_varis prose, not markdown, and comes back byte-identical — and it only touches mapped properties: a description typed into the SEO card is still taken as written.
Fixed
-
The Page Inspector chip shows only to people who edit pages. It appeared for every signed-in session, so members of a portal's own auth collection saw a chip naming the page's route and template on every Site Builder page, and a link into the admin. It now requires a super admin or a user whose access groups grant the Site Builder permission, as the docs always said
-
Stacks pages load again. The PHP API could not build the session bootstrap, because its logger has no default container binding, so every Stacks page died on an uninitialized session property. It now has an explicit definition like the other services that log
-
The automations cron URL and
tcms automations:processrun the same tick. Each carried its own copy of the drain-then-fire sequence and they had drifted in timezone handling; oneAutomationTickernow serves both, and oneProcessLockholds the single-flight lock for all four cron entry points -
The password reset and verification emails are built in one place. Registration, resend-verification and forgot-password each assembled the link, the expiry and the custom-mailer-or-template choice on their own; one
AuthMailernow does it, so the three cannot drift apart -
The collection RSS endpoint and
cms.feed.rss()render through one writer./feed/rss/{collection}mapped objects to feed entries with its own copy of the Laminas plumbing; it now maps fields and hands them to the same FeedWriter the Twig helper uses. One visible fix came with it: a relative enclosure URL in the collection feed is now absolute, as RSS requires. Feed templates can also set a channelimage(a URL, or{url, title, link}) -
Byte sizes are formatted one way. The upload limits, cache advisor, post-size notice, depot browser and server info page each had their own formatter with its own rounding; they now share one, and the server info session rows read
2 hours (7200s)like the license rows instead of2h 0m (7200s) -
OAuth token revocation reads the refresh-token key from the server factory. The RFC 7009 endpoint re-derived the encryption key from the factory's internals; a change to the derivation would have turned revocation into a silent no-op behind the mandatory 200. One
TokenRevokernow asks the factory, and both paths have tests for the first time -
Every migration importer's analyze endpoint validates like its import endpoint. Each analyze action now extends its import action and parses the same request, so a rule added to one cannot miss the other
-
A palette failure during
repair:filesis logged. The rebuild path that recovers an image property from an orphaned file swallowed palette errors the upload path had always logged; both now go through one extraction and report the failure, and neither is fatal -
The file and image dialogs are translated. The depot dialog had gone through the admin catalog since 3.5 while the file and image dialogs shipped hard-coded English: every action title, section heading, label, help text and EXIF placeholder now resolves for the operator's locale in all seven shipped catalogs. Also fixes the
No Autor Foundplaceholder -
Clearing the API response cache no longer reads every file in the cache directory. The filesystem cache is now sharded by cache type, so the whole-type clear that runs on every collection write drops one directory instead of unserializing every entry to compare keys. Existing entries in the old layout are orphaned until the next full cache clear, which the version-change check runs on update
-
A rate-limited email job is deferred, not failed. When the hourly send limit is reached, the job runner now puts the job back to pending without consuming a retry and stops the run, so the remaining email jobs wait for the next tick instead of each being marked failed against the same limit. The handling existed only in an older copy of the job loop that nothing called; the live path had lost it
-
A session restored from a remember-me cookie schedules the license re-check. The restore path wrote the session keys by hand and skipped the flag every other login sets; it now goes through the same
SessionLogin::establish()as the login form, setup wizard and public registration -
A factory import no longer doubles the collection's counts. Both
countandtotalObjectswere bumped once by the index rebuild and again by an explicit increment, so five generated objects reported ten. The test that covered this was posting to a route that returns 405 and skipping its assertions; it now runs -
POST /api/import/collections/{collection}/factorywithout a JSON body returns a proper error instead of a 500 -
The RSS import endpoint validates the feed URL the same way the analyze endpoint already did
-
The WordPress importer verifies certificates and caps image downloads. Its private download routine had SSL verification switched off and no size limit; every URL download — the admin upload-from-URL, the RSS importer and the WordPress importer — now goes through one
RemoteFileDownloaderwith verification on and the configuredmaxDownloadSizeenforced -
A malformed
mcp.accessvalue on a saved query no longer locks the admin out. The persona-versus-access rule was copied into nine MCP classes and one copy treated an unknown value as "nobody"; all of them now shareMcpAccessLevel, which fails closed to admin-only -
The Alloy and Total CMS 1 importers create collections through
CollectionSaver. They wrote the record straight to storage, skipping edition validation, count initialisation and thecollection.createdevent extensions listen for. An edition that cannot use a schema now refuses the import instead of silently creating the collection -
Access-denied responses honor the Slim base path. The access, edition and dual-auth middlewares decided "admin page or API" from the raw request path, so on a sub-folder install an admin page denial came back as JSON. One
ForbiddenRenderernow makes the decision with the base path stripped -
Feed analysis shows a summary for every entry that has any text. The preview row for an RSS entry with a body but no description was blank; both feed formats now summarise the description, or the body when there is none
-
Streaming a depot subfolder path answers 404, not 500. The stream route's copy of the depot-or-nested dispatch let the missing-record exception escape where the download route's copy turned it into a not-found; both now share one
PropertyFileResolver -
Raw upload downloads release the session before streaming, as file and depot downloads already did, so a long transfer no longer holds the session lock
-
A JSON import slugifies incoming ids the way a CSV import does, so the same record lands on the same object regardless of file format
-
Collections and schemas list in the same order on every host. Both were read in directory order, which is alphabetical on a Mac and hash order on Linux, so the admin sidebar,
cms.collection.list(), the Inherit From picker and every select built from schemas could differ between a developer's machine and the server — and two CI-only test failures came from exactly that. Both listers now sort by id -
An image whose EXIF says f/2.69 no longer refuses to save. The Focal Length, Aperture and ISO inputs in the image info dialog carried a step of 0.1, so the browser rejected any value with more precision — "the two nearest valid values are 2.6 and 2.7" — on every save of a record holding such an image, deck tables and deck items included. The three inputs now accept any precision, which is what a camera writes
-
A nested delete or update whose child has no directory on disk no longer wipes the deck or card. A request at
{deck}/{item}/{child}or{card}/{child}was recognized as nested only when that directory existed. A child never uploaded, or one another tab had just deleted, has no directory, so the request fell through to the flat-file path — which treats the whole deck as one single-value property and writes it back as[], and the deck file cleaner then swept every item's files. Reproduced by deleting the same image from two tabs. The stored property's type decides now: a card or deck is nested whatever the disk says, and the flat-file remover refuses a deck or card outright rather than empty it -
Deleting a file, editing its info or starring it no longer leaves the form dirty. Each of those persists by its own request and marks the field saved, but the row, deck item or card it sits in had been marked unsaved by the edits that led there, and nothing told it the work was done — so the form asked "Leave site?" over nothing and re-sent the field on the next Save. A field that is marked saved now announces it, and the composite above it drops its own flag once nothing below is still unsaved. The delete path also clears the file's sub-fields as already-saved values, so it never dirties anything to begin with
-
Deleting an image or file from a saved record no longer destroys the file when the record cannot be saved. The trash icon on an image, file, gallery item, depot file, or a file nested in a card or deck deleted from disk first and re-saved the record second. That re-save runs whole-object validation, so any change that had since made the stored record invalid — a
maxLengthadded below the length of existing text, a field made required, a narrowed enum — refused it, and the file was already gone while the record still named it: a 404 from ImageWorks, a broken image on the page, and no way back. The record is now saved first and the file removed only once that succeeds. A refused save leaves everything as it was and returns the real validation message. Reported against 3.5.2 -
A failed delete in the admin now says why. The trash icon on an image, gallery item or file, the featured star, the clear-cache button, and the delete buttons in the Styled Text image, file and video dialogs all caught a failed API call and alerted the same "A network or timeout error occurred" text — whatever had happened. When the API had answered with the reason, a 400 "Schema Validation Failed. (/body) Maximum string length is 200, found 625" say, the alert was a lie and the reason sat unread in the console. The alert now carries the API's own message when the server answered, and keeps the network wording for the case it was written for: no answer at all. Reported against 3.5.2
-
The Twig Debugger's document-root check could be bypassed by an empty root. It read
$_SERVER['DOCUMENT_ROOT']raw and tested "is the resolved path inside the root" with a string-prefix check — and every string starts with an empty one, so an install with noDOCUMENT_ROOT(CLI-driven or misconfigured hosts) would lint any readable file on the server, including../../etc/passwd. The root now comes from Config, which always has one, an empty root denies everything, and the containment check includes the directory separator so/var/www-oldno longer counts as inside/var/www. This was also the cause of the intermittentAdminUtilsPagesTestfailure in the full parallel run: other tests legitimately repoint or unsetDOCUMENT_ROOT, and whichever landed in the same worker first decided the outcome -
Clicking an image's action-bar buttons no longer pops the field's help text. A click focuses the button, and in help-on-focus mode the field then counts as focused, so the star, link, download and delete buttons all showed the help label as though you had tabbed into the field. Mouse clicks on the action bar no longer take focus; keyboard users still reach the buttons with Tab, and for them the help is right to appear
-
Marking an image as featured no longer leaves the form dirty. The star in an image's action bar saves its change with its own request, then reflects it in the meta dialog's checkbox — through the same path a person's edit takes, which marked the checkbox unsaved and, because a change inside an image field marks the whole field unsaved, the form with it. Save would then re-send a value already on disk, and leaving the page warned about changes nobody had made. The star now reflects its value as saved. A field's saved state also moves its comparison baseline to the current value, so a stray change event after any save no longer re-marks a field dirty against the pre-save value. Same fix for the gallery star. Reported as #44
-
indexOnSavenow reaches the search provider. The listener that pushes saves and deletes to the active provider read the object id from a key the event payload has never carried, so the id was always empty and every dispatch returned before calling the provider — withindexOnSaveon and Algolia active, nothing was ever indexed on save; onlytcms search:reindexpopulated the index. Its unit test passed because it built the payload by hand with the key the listener wanted. The listener now reads the payload as dispatched, and the test drives it through a real event dispatcher so the two cannot drift apart again -
The Twig Playground's HTML output is no longer stuck at 200px tall. The CodeMirror 6 upgrade moved the output editor's sizing into CSS —
height: auto, clamped to 200–500px with a scrolling body — but left the older script sizing in place beside it. That script measured the editor's line height synchronously, before CodeMirror had laid anything out, and CodeMirror 6 answers that question with a placeholder until its first measure cycle: 14px against a real 19.6. So the container was pinned to an inline height of roughly 70% of the content — the 200px floor for anything under a dozen lines — and the CSS could never grow it past that. The script sizing is gone; CSS owns the height. Underneath it, the CodeMirror shim'sdefaultTextHeight()now reads the content element's computed line-height first and falls back to the placeholder only for a detached view, so the code field's row-based minimum height is right on first render too -
cms.form.jobqueueByStatus()andjobqueueByType()without aheaderoption no longer throw. The factory passed the missing header through as null to a parameter typed string. Either call without a header now renders the table under its default heading -
Collapsed sidebar groups stay collapsed. The memory for open/closed sidebar groups was keyed on the first three URL segments, so
/admin/schemasand/admin/schemas/blogcounted as different pages: collapse a group in the schema list, open a schema, and every group reopened. Docs sections behaved the same way, and the docs page also ran a second copy of the script that fought with the first. One script now keys on the admin section, always opens the group holding the current page, and records only the user's own clicks -
Static assets no longer start a session. Every core stylesheet and script behind
/api/assets/, and every extension asset behind/api/ext/…/assets/, was served with aPHPSESSIDcookie, and a CDN never caches a response that carriesSet-Cookie— so despite their one-year immutable cache header the files reached the origin PHP on every first visit (Cloudflare reportedBYPASSon all of them). The asset routes now skip the session, as the ImageWorks route already did, and cache at the edge -
The Schema Editor opens a schema whose property defaults are not strings: a property with an object or array
default— the Site Builder page schema's free-formdatafield is one — ended the request with a type error, so/admin/schema/builder-pagecould not be opened at all. A non-string default is now shown as its JSON (and a boolean or number as itself) in the Default box instead of being passed through as-is. -
Deleting a file from inside a deck item no longer empties the deck: when the item id in the request did not match a stored key — a case difference on a case-insensitive filesystem was enough — the nested delete nulled the whole deck through a stray reference and saved it back as
[], with the file already gone from disk. The delete now leaves the record alone when its walk finds no such item, and removes the file child instead of nulling it. Two defects underneath it are fixed too: a deck item whose id contains uppercase (a${timestamp}id, say) was slugified to lowercase on save, failed the key-equals-id check, and pushed the whole deck onto unprocessed storage — its file child was never normalized and every nested delete on it was a 400; and a deck item with no file rendered empty Size and Download Count inputs, which the form sent asnull, so the record could not be saved at all. Reported against 3.5.2; all three predate it -
Styled text keeps hand-authored HTML. Opening a record whose styled text body was written by hand, and saving it untouched, rewrote the HTML — and the save reported success. Three things the editor's document model did not describe were being normalized away: a list item had to begin with a paragraph, so a step that opened with a heading, a figure, an image or a classed
divwas emptied, its content pushed out after the list, and the following steps re-wrapped in a stray<ul>; inline<svg>had no place in the model and vanished; andclassandstylewere replaced or dropped — a figure's authored classes gave way to the editor's own, and an inline style on a link was lost. A list item may now open with any block, inline SVG passes through verbatim, style survives on every block and on links, and figures and images keep authored classes ahead of theirste-*tokens. Two older defects surfaced by the new round-trip test are fixed with it: a span carrying both a class and a color grew one more nested span on every save, and wrappers such as<section>and<aside>came back as<div>. The editor is still not an HTML pass-through —<b>becomes<strong>, a lone paragraph in a list item is unwrapped, browsers reserialize style declarations — and the Styled Text docs now say what is kept, what changes and what is dropped, and point at the code field for content that must be stored byte for byte. Reported against 3.5.2; the behaviour is as old as the Tiptap editor -
A deck whose child schema has no
idproperty now says so. The item form renders no id input in that case, so every populated deck failed to save with "Item ID cannot be empty" — a message that sent authors hunting through their data for a blank value that was never there. Both the dialog deck and the table deck now name the schema that is missing the property. The generic message still covers an id input that is actually blank. Deck item labels also rendered a toggle as the literaltruein the browser and as1from PHP; both now print a check mark for on and nothing for off -
cms.collection.objects()docs no longer advertise a filter argument the Twig adapter has never accepted; the example narrows withfilterCollectioninstead. Both the PHP API and Collections pages now also say what the signatures leave implicit:objects()returns the collection index, so a property that is not in the schema'sindexarray is absent from every entry, whileobject()returns the full record -
A list field with grouped options no longer loses them once a value is saved. A
listfield fed by an option source that groups its entries into<optgroup>s — the podcast show's Categories, from Apple's taxonomy — rendered every group on a new record and nothing at all once categories had been chosen: the field's selected-first reorder only understood a flat list, found novalueon a group, and dropped all of them. Grouped options now keep their groups and their order, with the selection rendered inside them -
A color reads back as the hex that was saved. A color property stores its hex alongside OKLCH coordinates rounded to three decimals, and every read rebuilt the hex from those coordinates — which cannot always land on the same 8-bit channel value, so
#F17724came back as#f17723and#1d9a6cas#1c9a6c: a brand color typed into the admin was not quite the color that rendered. The stored hex is now the source of truth on read; the coordinates are kept as stored and still describe the color to display precision. A color given only as OKLCH still derives its hex once, at save time -
A collection with no MCP settings no longer reads "differs" on every sync dry run.
tcms pushsends an unconfigured card as an empty block so the receiving side can clear it, and the collection saver there turned that empty block into{tools: []}; the next comparison hashed the two shapes against each other and reported the collection as changed — including right after the push that had just made both sides identical, and again after every push that followed. The saver now leaves an empty block empty, and the settings comparison treats an absent block, an empty one and one holding only empty lists as the same thing, so a remote that already stores the old shape compares clean without being re-saved. Schemas and objects are still hashed as written -
The dashboard's Recent Objects panel was always empty: it looked for
onUpdate/onCreatein the collection index, but the reserved schemas index those dates asupdated/created. It now reads either, so recently edited objects show up again. -
MCP sessions no longer dropped on every content save: the tool-surface invalidator listened to
collection.updated, which every object write and every index build fires while bumpingcount/totalObjects/lastUpdated, so any save on the site sent every connected agent a "session not found" and forced a reconnect. The event now says whether the collection's configuration actually changed, and only those updates (name, url, schema, MCP access) drop sessions. -
A corrupt state file under
tcms-data/.systemcan no longer be overwritten with an empty one. The extension state, migrations ledger, per-automation state and live-reload pulse files each answered an unreadable or malformed read with "treat it as empty", and the next save wrote that emptiness back — the same shape of bug that wipedapikeys.jsonin 3.5.1, just in files that were not credentials. OneAtomicJsonStorenow owns the temp-file-and-rename commit, the optional sidecar lock, and a corrupt-file policy every caller has to name. Extension state, migrations and automation state read as empty for the request, log an error naming the file, and refuse to write until the file reads cleanly again, so a brokenextensions.jsonshows every extension as off for one request instead of turning them all off for good. The reload pulse is disposable and starts fresh. API keys keep throwing, with their lock and 0600 mode now provided by the store -
The agent skill now describes the layout it was installed into.
tcms skill:installcopied the shipped skill verbatim, and the shipped skill is written for a Composer install — so on a zip install every command it offered an agent wasvendor/bin/tcms, which does not exist there, and every docs path it pointed at was undervendor/totalcms/cms/, which does not either. The install now rewrites those two forms as it copies (php resources/bin/tcms, and the docs atresources/docs/) whenever the target is a zip install; Composer installs are unchanged. The skill also opens with the two layouts side by side, so an agent reading it in either one knows where the CLI, the docs, the config and the content live, and that a zip update replacesconfig/ public/ resources/ src/ vendor/wholesale. Zip installs should re-runphp resources/bin/tcms skill:installafter each update, the way Composer runs it on every install and update -
A corrupt MCP session no longer locks a client out permanently. A session file that failed to parse threw on every subsequent request carrying that session id, so one bad write ended the conversation until someone deleted the file by hand. Two things produced those files, both in the session store: every write staged through one fixed temp path, and the write that stages it truncates the file before it takes its lock — so two requests on the same session, which is the normal shape for a client that pipelines calls, could blank the staging file out from under each other and then rename the truncated result into place. Writes now stage under a name of their own, and a session that cannot be read is treated as absent, which the protocol already handles by re-initializing. Orphaned staging files are swept during session collection
-
tcms object:patchcan create a property the record did not already have. Patching a property the object had never carried warned on the missing key and then died insidearray_merge(), so the command could only ever update a property that was already present. An absent value — or a scalar, where a plain text field is patched as though it were a card — is now treated as an empty base, which is how the nested-property patch had always behaved -
tcms schema:lintno longer dies on the kind of schema it exists to report. A property written with a JSON Schema type list ("type": ["string", "null"]) reachedarray_key_exists()as a non-scalar key and ended the command with a TypeError, so the one tool for finding malformed schemas crashed on one instead of naming it. Only a string can name a T3 property type; any other shape now passes through and the lint result is reported -
A collection whose
collection.jsoncarries noidreads as missing instead of ending the request. The guard that hides a stray directory — a manualblog-bkp/copy whose id still saysblog— read the id without checking it had been set, and a meta file that never carried one is a fatal error rather than a mismatch.tcms repair:index, the command most likely to be pointed at a damaged install, was where this surfaced. A meta file too broken to name itself is now the same clean miss as a mismatched one -
Downloading a depot subfolder returns 404 instead of a server error. The download route tells a card- or deck-nested file apart from a flat depot filename by asking whether the path is a real directory — but a plain depot subfolder is one too, so browsing to a folder was resolved as a nested file, found no record behind it, and returned a 500. You cannot download a folder, and the route now says so with a 404
-
Clearing the cache while another request is clearing it no longer reports errors. The recursive delete guarded its
unlink()andrmdir()with acatchthat could never fire, because PHP raises a warning here rather than throwing. An entry removed by a concurrent clear between the existence check and the delete, or a directory refilled between the scan and its removal, was therefore logged as a failure — when an entry that is already gone is the outcome the clear wanted. Both now use the same suppression the pattern-based clear already used