All notable changes to Total CMS will be documented in this file.
- The ImageWorks and file-links tools check access to the object they open. Both tools open in a dialog from an image or file field. Before, any operator could open them for any collection, whatever their access groups. They now check that the user may read the object in the query (access groups first, then ownership). They also accept a session from any auth collection, so a member editing on a front-end form no longer gets the denied page inside the dialog
- The site's login collections can no longer be deleted. The REST delete route had no guard, so an admin could delete the
authcollection with a direct API call. The admin, REST, MCP and CLI deletes now use one guard that refuses login collections, and each delete backs up the collection's settings first. MCP still refuses all system collections
- Portals send signed-out visitors to their own login page. A new
accessCollectionsetting is on Site Builder pages (next to Access Groups, shown whenauthis on) and on collections (Access Controls, for protected files). A signed-out visitor who opens a protected page or file goes to/admin/login/{accessCollection}, so members of a portal's own auth collection (such asdealers) can sign in. The redirect keeps the requested URL, which the file-download redirect did not keep before. Leave the setting empty to keep/admin/login. A collection is refused if itsaccessCollectionis not an auth collection - JumpStart includes the access groups that an export uses. An export now has a top-level
accessGroupslist. It holds only the groups that the export's collections, pages and users refer to, without the four built-in groups. Import adds these groups first, so collections and users can find them. Import never changes an existing group and never writesadmin. Only trusted imports (super-admin or CLI) can create groups, because a group grants permissions. Auth collections are now created before other custom collections, soaccessCollectionandownerCollectionsettings that name them resolve tcms collection:deleteandtcms schema:delete.collection:deletelists what still refers to the collection (Data Views, owner and access collection settings,publicRegistration) and stops unless you pass--force. Objects stay on disk, as they do when you delete from the admin.schema:deleteuses the same checks as the admin, and it now backs up the schema file first- Markdown image captions and new-tab links. An image on its own line that has a title renders as a
<figure>with a<figcaption>. Use the title"caption"or"alt"to use the alt text as the caption. A link with the title"external"or"_blank"opens in a new tab. Untitled images and links render as before, so existing content does not change. This works in|markdown,|markdownInline,markdown_to_htmland RSS |markdown(true)lets HTML in the Markdown source through. By default both Markdown filters escape HTML, so a<mark>or<span class>that an editor wrote showed as text. Passtrue(orhtml: true) tomarkdown()ormarkdownInline()to keep it. Code blocks stay escaped. Safe mode off does not blockjavascript:links, so use it only for content from trusted editors. Usehtmlcleanfor fields that visitors can write- "Built by" badges.
cms.badge.builtBy(handle)shows a pill with "Built by {studio}", andcms.badge.builtByText(handle)shows the same credit as a 16px footer line (it also takestheme: 'mono'with a hex color). They go on a client's site, so they never addrel="me" - MCP gallery edits can set the image order. If
patch_objectorupdate_objectsends a gallery list that names every stored image exactly once, that list sets the order. A partial list, or a list with a repeated name, keeps the stored order. Images still cannot be added or removed this way - Total CMS orange is the default admin accent.
--totalform-accentnow defaults to#F97318, which is also available to sites as--totalcms-orange
- Member badges add
rel="me"by default.cms.badge.profile()andcms.badge.earned()go on the designer's own site, so the link now pairs it with their totalcms.co profile. Passme: falseto remove it - Every Site Builder starter footer ends with the "Powered by Total CMS" text badge. This applies only to new
builder:initsites. Existing sites keep their footer schema:lint --strictflags formgrid rows of uneven width. Every row ofgrid-template-areasmust have the same number of cells. One short row made the whole value invalid, so the browser dropped the form layout. Each short row is now an error. Fieldset and accordion grids are checked on their own
- A member's PUT keeps the owner and the protected fields it leaves out. A front-end edit form that sent only the fields it shows cleared the owner stamp and every protected field it did not include. The stored values are now kept. Other missing fields are still cleared by a PUT, so partial forms should use
method: 'PATCH' - JumpStart and sync keep a collection's ID and settings. A collection on a reserved schema exported as the schema ID. For example, a
dealerscollection onauthimported asauth, and everybuilder-pagesobject failed with "Collection not found". Only a collection whose ID is the reserved schema ID is now exported as reserved. Reserved collections now export with their settings (URL, sort, name, feed, sitemap, groups), not with defaults - Members can open files in owned collections. Opening a file updates its download counter, and that write ran as the visitor. In an owned collection, a member who was allowed to open the file got a 403. The counter is now written as the system
- SVG uploads in Markdown and styled text fields stay SVG. With an image preset on the field, the upload link went through ImageWorks, which converted the SVG to JPEG. SVGs are now served from the stream route with no ImageWorks parameters, in the upload link and in the dialog's Images tab
- An object with the ID
imageworksno longer breaks uploads. The sessionless-path rules matched anywhere in the URL, so that object's upload route ran without a session and the remember-me restore failed with a 500. The rules now match from the start of the route - Image Batcher finds galleries that the index leaves out. It read images from the collection index, so a gallery missing from the index stopped the batch with "No images found". It now reads full objects. Validation checks the first real image, not the example URL, and each image gets its own cache token
- Admin scripts update after an upgrade. The file-links, ImageWorks builder and Image Batcher scripts loaded without a version string, so browsers kept the old copy for up to 30 days
- Errors from a site's own scripts are no longer sent to Sentry. Exceptions from a script that loads Total CMS (such as a test harness or a
php -Srouter), with no stack frame in Total CMS, are dropped. Fatal errors are still reported
- Scripts and extensions write as the system actor. Since 3.6.3, every write has an actor. A standalone script that sets no actor runs as anonymous, and extension code runs as the signed-in member, so account fields such as
groupsandactiveare dropped from the save. The PHP API example now setsActor::system(), and the extension points page showsActorContext::runAs() - Protecting collection pages. A new recipe explains the new
accessCollectionsetting. Collection object pages (such as/blog/{id}) have no access check of their own, but a builder page on the same route takes priority and can turn onauth
- Data views over MCP follow the Data Views edition and the
dataviewspermission, as on REST. The view tools andtcms://view/{id}resources checked only the view's own MCP access, so every signed-in OAuth member could read a view opened to signed-in callers, whatever their groups, and views stayed reachable on an edition without Data Views. A member now needs a group with thedataviewspermission (public views stay open to everyone), and without the edition the view tools and resources are not offered, public views included. - An API key limited to some collections is limited over MCP too. A key that listed
/mcpreached every collection through MCP whatever else it listed. A key's paths now decide what it reaches there as they do on REST:/collections/{id},/schemasand/templatesgrant those (schemas, collection settings and templates read-only, as REST's structure routes take no API key),/mcponly opens the endpoint, and only a key with All endpoints (*) and every method is an admin over MCP. A key's methods decide its MCP tools as they decide its REST access: a GET-only key connects read-only, and a key without PUT or PATCH gets no update tools. A limited key reaches a collection or data view only when that one's MCP access is API keys, authenticated or public, never admin only. Check keys that list/mcp: one with nothing else now reaches no content. See MCP Server - Bot protection for public forms. A
botProtectionform option, and a Require Bot Protection setting on the collection that turns it on for every public register or create form, add a honeypot field and a signed time-trap token. The register endpoint, anonymous public creates and public clones screen them: a filled honeypot, a submission under three seconds, or a missing or forged token gets a success-shaped reply with nothing saved, no email and no event. An expired but genuine token says "This form has expired. Reload the page and try again." instead, so a cached page or an old tab never loses a submission silently. Signed-in callers (session, API key, OAuth) skip the check. The token is an HMAC over time, collection and form type, keyed from the site key, so it cannot be forged or reused on another form. Forms without the option are unchanged. See Form Options - Bot protection covers form mail. A form's mailer action is a second request to the public
/api/action/mailerendpoint, so the save's bot check never covered it. The endpoint now screens the form's bot fields, and a Mailer template with Require Bot Protection refuses anonymous sends without them. Create and register forms whose mailer action uses such a template render the fields without needing thebotProtectionoption. See Mailer - Twig no longer hands templates stored password hashes. Object reads, collection-URL object pages, page hydration, the admin page preview and
cms.auth.userData()return records without password properties. Login, password reset and passkeys still read the full record internally - Redirects after login, verification and password reset stay on the site. One same-origin check now guards all of them and rejects backslashes and control characters before anything else, which closes open redirects such as
/\evil.comandhttps://evil.com\@example.comthat the old login rule let through. The password-reset redirect previously had no check at all - Sync import needs an admin.
POST /api/sync/importnow requires an admin session, as its export sibling does (API keys are unaffected); any signed-in user could previously push collections, schemas and templates - MCP
create_schemaseeds only the collection it creates. If a collection with that id already exists the schema is saved and seeding is refused, soseedObjectscan no longer write aroundcreate_object's checks - Downloading log files needs the Log Analyzer permission. The log download route checked only that a user could open some utility, so an access group granted, say, the Cache Manager could download every log file. It now checks the Log Analyzer grant, as the page does; admins are unaffected
- Extension manifests are escaped in the admin. An extension's name, description, version and author are escaped on the list, review, settings and health pages, and the version is limited to a safe character set
- Activity Log: who changed, downloaded or called what, and when. Utilities → Activity Log is a filterable table of site activity, newest first: object, collection, schema and template changes (updates list the changed field names, never values), logins, file downloads by signed-in users, refused requests, imports, bulk deletes, and MCP tool calls with the tool, its target and its duration. Each row names the user, API key or OAuth client behind it, so everything an AI client a member connected did is one filter away. Filter by event, actor, user/key/client, collection, level and date. Export CSV downloads every row the filters match. Rows live in SQLite at
tcms-data/.system/activityand are kept 90 days by default; Settings → Activity has the switch, the retention and whether to record anonymous MCP calls (off, since a public MCP server listed in an AI directory takes thousands). Every edition. See Activity Log - A
file.downloadedevent. Fired when a file is served from a/download/...link after its protection passed, with the collection, object, property, file name and whether the file is protected;actorsays who downloaded it. Extensions and event-triggered automations can react to it, for example to email someone when a member downloads a contract. Inline/stream/...playback does not fire it. See Extension Events tcms collection:updatechanges an existing collection's settings.--set ownerProperty=authorsets one setting, a dot reaches into a map (--set mcp.access=authenticated,--set properties.price.label=Cost), and JSON from a file or stdin handles bigger changes. Maps merge key by key as the MCPupdate_collectiontool does, through the same service, and the save is validated as the collection form validates it. Before, changingownerProperty, a URL or the sort aftercollection:createmeant the admin, the API, or editing.meta.jsonby hand.tcms access-group:list,access-group:get,access-group:setandaccess-group:patch. Access groups had no CLI at all. Definitions are checked against the stored shape, so an unknown section, key or operation is refused instead of saving a group that quietly grants nothing. Writes need the Access Groups edition, as the admin page does.update_collectionanddelete_collectionover MCP. An agent can change a collection's settings after creating it, field overrides included: settings maps and overrides merge, so an agent sends only what changes, and an override's field type is filled in from the schema. Deleting a collection removes its settings only, as in the admin: objects stay, and the result carries the settings to recreate it. Login and system collections are refused, and a storage format change is refused with a pointer totcms collection:convert. Both are for admins and for OAuth members whose access groups grant collection settings; a limited API key never gets them.create_collectionnow says it takes field overrides. See MCP Serverdelete_objectover MCP. An agent can delete one object, gated like the other write tools: an admin, an OAuth member whose groups grant delete on the collection (their own objects on an owned collection), or an API key whose endpoints name the collection and whose methods include DELETE. It is marked destructive, so clients ask first. The record stays in the backup history; uploaded files are deleted, and the result says which fields held them. The OAuth Grants page and the access group visualizer now count delete as MCP reach. See MCP Server- An "API keys" MCP access level for collections and data views. Between Admin only and Authenticated: admins, plus API keys whose endpoints name the collection or view. Give an integration a key for one collection without opening that collection to signed-in members over OAuth or the site's own pages.
/dataviewsand/dataviews/{id}/datain a key's endpoints now grant views over MCP too. See MCP Server $ctx->publicHttpfor automations. A handler that fetches a URL from content — a member's website, a feed — can GET it without the URL reaching this server's own network: the host must resolve only to public addresses, the connection is pinned to the checked address, and every redirect is checked again. A 10 second timeout and a 2 MB read cap by default. See Automation Handlerscms.badge: a "Built with Total CMS" badge in one line of Twig. The badges at totalcms.co/badges were paste-only HTML, frozen the day it was pasted: a new theme or a changed link never reached the sites already showing one.cms.badge.pill(),.stacked()and.text()write the generic badges, and.profile(handle)and.earned(handle)a designer's, linked to their profile on totalcms.co. The images are served by totalcms.co, so a designer's photo and approved count stay current. Themes arelight,darkandauto, which follows the visitor's color scheme through a<picture>; the text badge takescolorormono. An invalid handle gives the pill, never a broken image. On a designer's own site,me: trueaddsrel="me"to a profile badge's link, so the site and the profile verify each other. See Badgescms.render.picture()can show a different image in dark mode. Passdark: 'logo-dark'(another object or ID, resolved with the same collection and property) ordarkProperty: 'imageDark'(a second image property on the same object) and the<picture>leads with one<source media="(prefers-color-scheme: dark)">per format for that image, before the light sources and<img>. The browser picks the first source whose media query and type match and re-evaluates it live, so the swap follows the visitor's theme with no CSS or JavaScript. The dark image gets its own candidates capped at its own width, with its stored format appended as a last resort since it has no<img>to fall back to; alt text, dimensions and the link stay the light image's. A missing, empty or GIF dark image is ignored. See Render → picture()cms.media.imageDataUri(): an image embedded instead of linked. An SVG served as an<img>— a badge other sites paste in — cannot load anything from outside itself, so it could not show an uploaded photo at all:imagePath()gives a URL, and there was no way to get the image's bytes into a template.imageDataUri()takes the same arguments, renders through ImageWorks and returns adata:URI, or an empty string when there is no image, the render fails or the result is over 64 KB, so a template can fall back. See Mediacms.redirect(): a template answers its URL with a redirect. A page record's Redirect To covers a fixed move, but a forwarding URL whose target depends on data — a badge link pasted on another site that should reach a member's profile while it is public and the home page once it is not — had nowhere to go: the template could only render, or callcms.collection.redirectIfNotFound(), which is tied to a missing object.cms.redirect(url, status)stops rendering and the page router answers with the status (302 by default; 301, 303, 307 and 308 also accepted) and aLocationheader. A URL holding a line break is refused, so a target built from the request cannot inject a header. See Total CMS helperscms.notFound(): a template answers its URL with the 404 page, in place. A collection URL matches every object, drafts included, so a detail template that should not serve a record had onlycms.collection.redirectIfNotFound(), which sends the visitor to the 404 page's own address — and answers 302, since PHP drops the 404 status it set once aLocationheader is sent. Crawlers and link checkers recorded a redirect rather than a missing page, and a guessed draft slug answered differently from a slug that never existed.cms.notFound()stops rendering and the page with status 404 renders at the visitor's URL with a real 404, exactly as an unmatched URL is answered.redirectIfNotFound()is unchanged for sites that want the redirect. See Total CMS helpers- The Article node's
authorcan be a node, not just a name.authoron the record was read as a string and became{"@type": "Person", "name": …}— so a site that declared a full Person (withurlandsameAs) as an extra JSON-LD node still handed search engines an anonymous author, since the two nodes were never linked and the author is the one they read. An arrayauthoris now emitted as the author node itself,@type: Personadded when it has none, so a template can pass{'@id': base ~ '/about#founder'}and the article points at the Person it already declares. A string still works as before. See SEO - A template's JSON-LD node can extend a core node. An extra node whose
@idmatched one core emitted was dropped, so there was no way to say anything about the Organization beyond what the Site SEO record holds. It now adds its properties to core's node — core's own values stand — so a page can pass{'@id': base ~ '/#organization', 'founder': {'@id': base ~ '/about#founder'}}and the Organization names its founder, matching the Person'sworksForin the other direction. See SEO - Owned collections: members see and edit only their own objects. A collection may name an
ownerProperty, picked in the collection form from the schema's properties that can hold a user id. Anyone but a super admin is then scoped to the objects they own: another member's object is denied, list and query routes return only theirs, and routes that cannot be scoped (bulk delete, reindex, clone, import, export, duplicate) are refused. A list-typed owner property holds several owners; a new object is stamped with its creator.ownerCollectionnames the auth collection whose members may own objects, so a user from another auth collection is treated as anonymous. Over MCP, the same rules hold: a member granted the collection reads their own objects, drafts included, plus everyone's published ones on a public collection, creates objects stamped as theirs and edits only their own, anddescribe_collectionsays so up front. One access guard now serves REST, MCP, forms and Twig, and every refusal fires anaccess.deniedevent. See Collection Settings - Protected fields. A schema property may be
protected: true(a non-admin's change is reverted and logged) orprotected: {"value": X}(forced to X on every non-admin save), across every write path, uploads and deck items included, so a moderated object cannot be left approved. Theapprovedtoggle of the reservedtotalcmsschema is protected, andschema:lintrejects an invalid value - Public registration from the collection form. A collection built on the
authschema (or one inheriting it) now has a Public Registration section in its settings: Allow Public Registration, Require Email Verification and Registration Groups. Registrants receive the globalpublicRegistrationGroupplus the collection's groups, neveradmin. Only super admins and API keys may change these settings. Collections listed inauth.publicRegistrationkeep working. See Collection Settings verifyRedirecton register forms. A register form can passverifyRedirect(e.g./login) so the email verification link lands there instead of the admin login; an expired link carries it through the resend form, which is now always the collection's own- Install an extension from a zip. The Extensions page has an Install Extension drop zone, and the CLI gains
extension:install <zip> [--force]andextension:package [dir]. A fresh install opens the review page only when there is something to review; an update keeps state, permissions and settings. User-installed extensions get a Remove button, andextension:removenames the owner when it refuses. Install and remove need an admin browser session. See Extensions extendFieldType(): an extension field type built on a core one. An extension field that adds to a core field (an image field with a capture button, say) can subclass the core class instead of starting fromTotalField. Exposed onwindow.TotalCMSinadmin.jsandforms.js. See Extension Points- A
countriesoption source and acountry_namefilter."propertyOptions": "countries"fills a select with ISO 3166-1 alpha-2 codes labeled in the site's locale, andcountry_nameshows a stored code as its name. No PHP intl extension needed. See Property Options cms.render:altandprotectoptions.altsets an image's alt text, and an empty string marks a decorative image, instead of falling back to the image's alt field, EXIF or the filename.protect: falsedrops thedraggable="false"andoncontextmenuattributes every rendered<img>carried, withimageworks.protectas the site-wide default. Both work onimage()andpicture();protectcovers galleries toofieldIconson the login form.cms.form.loginForm({fieldIcons: false})drops the icon slots beside email and password, as the form builder already could- The form builder's
formgridoption lays out schema forms.cms.form.builder('users', {formgrid: ...})was ignored for schema-driven forms; it now wins when given, so a front-end form can arrange a subset of a schema's fields without changing the admin layout - Every event payload carries
actor. Listeners and automations see who caused an event (kindissystem,apikey,admin,memberoranonymous, with the user, OAuth client or API key behind it), captured when it fired, so a queued listener still records the person. Work the system does on someone's behalf (an automation an admin ran, a registration, a login stamp) names that person in the actor'sinitiator. The CLI, automation runs and queued jobs act as the system. See Events - Agent skill: troubleshooting steps and a support report template for a suspected Total CMS bug; a recipe for content members submit and edit (ownership, protected review flags, account pages, member AI clients); ordering a list without a
sortfield; the new CLI commands; and a review pass against the current code - OAuth login collection. Settings → OAuth Server → Login Collection names the auth collection whose members connect an AI client or authorize an integration, for a site whose members live in their own collection (such as
users). A signed-out caller is sent to that collection's login page, where staff from the default user collection can also sign in during an OAuth flow. A session from any other auth collection cannot authorize and is logged aslogin_collection.rejected. Unset, OAuth behaves as before. See OAuth Server
- The MCP tool list follows a connection's OAuth scopes. A member's token approved for
cms:readonly no longer listscreate_object,patch_object,delete_objector the other write and structure tools; before, they were listed and refused when called. Content read tools stay listed withoutcms:read, since public collections need no scope, and admins still see every tool. list_schemasandget_schemafollow the schemas read grant over MCP. They were admin only. Now, as with the REST schema reads, an OAuth member whose groups grant schema read, or an API key whose endpoints name/schemasor/schemas/{id}, reads the schemas it may, andlist_schemaslists only those. Changing schemas stays admin only for keys.- The Article headline and breadcrumb leaf are the record's title, not the browser title. Both used the resolved page title, so a record with an authored SEO title (
Making a PHP app legible to AI agents — Total CMS) declared that whole string as its headline — the site suffix included — while its H1 said otherwise. They now use the record's owntitle, falling back to the page title only when there is none;<title>andog:titleare unchanged - The WebPage node's
datePublishedis the record'sdate, elsecreated— the same choice the Article node has always made, instead ofcreatedalone. A post dated 1 October and created on the 5th reported both dates on one page, one per node - A card in the collection table shows its first text field. An indexed card's cell joined every scalar sub-field, so an SEO card read as "About Us, Who we are, 1, 1". It now leads with the first text field in the card schema's order (skipping
id, hidden fields, styled text, code, SVG, JSON and passwords); a card with none, or with it empty, shows its first two values. Decks still show their item count - ImageMagick's memory is capped per render. ImageMagick allocates outside PHP's memory manager, so
memory_limitnever bounded it, and its own default on a 4 GB host is 1 GB per PHP worker. Resizing a 24 MP photo peaked at 268 MB per worker; a few at once can push a small host into swap. Total CMS now caps ImageMagick's in-memory pixel cache at 64 MB and its memory-mapped cache at 128 MB before any render, HEIC conversion or palette extraction, and ImageMagick stages anything larger on disk instead. The same resize now peaks at 86 MB. Output is byte-identical; a first (uncached) render of a large source takes roughly 10–20% longer, and typical 12 MP photos are unaffected. A host whose ImageMagick policy is already stricter keeps its own limits pdo_sqliteis required software. The job queue (imports, automations, bulk mail) and the bulk mailer's send log are SQLite files, but the Server Checker,tcms checkand the setup wizard did not list the extension, so a host whose cron PHP lacks it only showed up asjobs:processfailing every minute with "could not find driver". All three now check for it, and the requirements page says to check the cron PHP as well as the web server's- Stacks 6 admin uses the "Stacks" session name. Upgrade note. Stacks 6 opens every page's session as
Stacks, but admin requests fell through toPHPSESSID, so a login was stored in one session and Page Lock checked the other. Stacks 6 admins are logged out once on upgrade as the login moves cookie; Stacks 5 is unchanged - The update check gives up faster. An unreachable license server cost every dashboard load the full timeout, twice. The timeout is now 5 seconds and a failed check is cached for an hour
- Faster requests. One shared Slugify and one shared Serializer instead of a new one per record (a listing page spent about 18% of its time building Slugify rules), and extension entry files are tokenized only up to their class line. From a customer's profile of 3.6.2
- A new capability on an enabled extension logs a warning. A capability first detected at boot is stored off by design; the log now names the extension, the capabilities and how to grant them
- CLI commands that confirm before acting fail when nobody can answer.
object:delete,backup:restore,update:apply,update:rollbackandextension:removerun from a script without--forcenow exit 1 and name--force, instead of exiting 0 having done nothing or waiting on an open pipe automations:processreports the queued jobs it ran, not only the scheduled automations it fired, so a run that drained the queue no longer says "Fired 0"- Verification and reset emails state their expiry in hours when the lifetime is whole hours
- The JumpStart utility sorts collections by name, not id
- Front-end libraries updated, among them Tiptap 3.32, CodeMirror, marked 18, Mermaid 12 and Choices 11.2.4. The list field's drag-to-reorder now works on the official Choices release, using Sortable's pointer-based dragging in every browser. The data visualizer keeps its dagre layout and classic look under Mermaid 12
- MCP server on mcp/sdk 0.9. Parallel calls in one session keep their own answers and progress, a server-sent notification no longer rides inside a response as a JSON array (which current clients reject), and an unreachable session store answers 503 instead of "session not found".
/.well-known/mcp.jsonnow advertises2026-07-28. A tool'sstructuredContentis now checked against itsoutputSchema, and a mismatch reaches the client as a tool error instead of the bad value — an extension tool whose output drifted from its schema now fails visibly. Returnnew \stdClass(), not[], for an empty object
- A page URL no longer answers with JSON for
?_format=jsonorAccept: application/json. The page router returned the matched builder page or collection record as JSON before the template ran: every field of the record, whether or not the template showed it, and past anycms.notFound()guard, on anAcceptheader some fetch clients send by default. Nobody asked for it and the REST API is the headless surface, with API keys and access groups in front of it. Those requests now render the page like any other, and theauthpage middleware answers them with the login redirect rather than a JSON 401
- An extension the system turns off is no longer turned off silently. Disabling after an update added risky code, or quarantining after repeated crashes, happens on a request nobody is watching, and the only sign was a banner on the Extensions page. Every admin page now shows admins a warning naming the extension and why, linking to its settings, until someone re-enables it or disables it themselves
- Updating an extension no longer disables it for code you already approved. The update check disabled any enabled extension whose new version had a risky pattern at all, so one that makes HTTP calls or writes files was turned off on every version bump, and again when the update reached production. Enabling now records the findings you reviewed, and an update is disabled only for findings beyond them; the banner lists just those. Extensions enabled before this release take their current findings as approved on their next update
- Saving a schema no longer replaces a field type the editor does not know. A property using a disabled extension's field type had no option in the editor's field-type list, so the list fell back to
codeand the next schema save wrotecodeover it. The type now keeps its own option under "Unavailable", andschema:lintwarns about a field type that neither core nor an enabled extension provides, and about a field that stores a different shape than its$ref - The OAuth Grants page shows what a member really reaches over MCP. It listed a collection left at Admin only as readable whenever the member's groups granted read, though MCP refuses it, and it showed owned collections as closed to the member's group grant, though members read and write their own objects there. Reach now comes from the same exposure check and AccessGuard the MCP tools use, and owned collections are marked "own". The access group visualizer shows group grants on owned collections too.
- MCP can edit image, gallery, file and depot metadata. An agent could not fix alt text, tags, a focal point or a download label: any value in a media field was refused.
update_objectandpatch_objectnow merge a metadata edit over the uploaded file — an object for an image or file, a list matched bynamefor a gallery and bypathfor a depot — while uploads, renames and file protection stay in the admin.describe_collectionlists the editable keys. The REST meta route the admin's gallery and depot dialogs use now keeps a file's name, size, hash and other identifying keys whatever a request sends. See MCP Server - An MCP call that changes the tool surface keeps its own session. Creating a schema or collection over MCP drops every connected client's session so they reload the tool list; the calling client's session went too, so a
create_schemareporting progress failed with an internal error - A content save no longer drops MCP sessions on a collection saved before a setting existed. A record missing a later-added default (an
mcpblock withouttools, say) read as a settings change the first time an object save rewrote it - Restricted MCP origins accept the site's own page over https behind a proxy. With
mcp.allowedOriginsset, a configured origin is matched in full (scheme, host, port), and the site's own origin is accepted whether the proxy hands T3 the request as http or https - The automation form's code editor stops growing at 600px. A long handler made the PHP editor as tall as its code; it now scrolls inside a 600px box, as the template, data view and mailer editors do
- A Site Builder page that serves an image no longer opens a session. A page whose URL ends in an image extension (a badge at
/badges/pill.svg, an OG card) started a PHP session on every request, so an image embedded on other sites opened a session file, held its lock and sent aPHPSESSIDcookie on every view of every page it sat on — and theSet-Cookiekept any CDN from caching it. Front-end URLs ending in.svg,.png,.jpg,.gif,.webp,.avifor.iconow run without a session, like ImageWorks and asset URLs; admin and API URLs keep theirs - A 404 no longer resets OPcache. The default error handler reset the pool-wide bytecode cache on every exception it answered, so each bot probing
/wp-login.phpand the like recompiled the whole site on the next request — and on a shared pool, every co-located site with it. The reset exists so a fixed file takes effect right after a fault; it now runs only for a real fault, never for an HTTP exception, and theX-OPcache-Clearedheader is sent only when a reset actually happened - The table format works for API-key callers.
GET /api/collections/{collection}/query?format=tableanswered 500 "No User ID provided" when authenticated with an API key: the renderer asked the access control whether the session user may inline-edit, and an API-key request has no session user. An empty user is now a plain "no" — no pencil, same table - A property with
"type": "integer"saves. The docs and the agent skill recommend it for whole numbers,schema:lintaccepts it and the reserved schemas use it, but every save of an object with one failed with "Unknown property type for object": the property factory builds its class from the type and there was noIntegerData. There is now; a whole number is stored as3, not3.0, a default applies, and2.5is still refused by schema validation. The error for a type that really is unknown now names the type and the property (Unknown property type "foo" for property "position"). Reported by Marcelo - An autogen
idand acalcfollow the field they name, whatever its type."autogen": "${name}"listened for a nativechangeon the first input in the form calledname. Two things went wrong with that. An image or file field renders a readonly Filename sub-input with that same name, so when the schema's form order put the image before the Name field, as theauthschema does, typing a name did nothing and only editing the Filename regenerated the id. And most field types (Styled Text, date, color, list, card, …) set their value in code and never fire a native event, so${title}from a Styled Text title never ran at all. Both now listen for the field's own change event, scoped to the form's or deck item's top-level fields, so any field type works as a reference and a composite's sub-inputs are never matched. The id now regenerates as you type, a moment after you pause, instead of when you leave the field - A field visibility rule follows a Styled Text, date, color, list or card field. A
watchrule re-evaluated only on a nativechangefrom the watched field, and those field types set their value in code without firing one, so a field shown or hidden by one of them did not react until something else on the form changed. The rule now also listens for the field's own change event; a plain text or select field still answers at once emptyandnot_emptyvisibility operators work on any field. They only ever worked when the watched field held an array (checkbox, multiselect, list); on a text, image or card field they fell through to a plain comparison, and the form's server-side render hid the field whatever the value becausevalueis null for these operators. A field is now empty when it is blank text, an array with no items, or an image, file or card with nothing in it, in the browser and on first render alike.0and an unticked toggle are values, not empty. See All Field Settings- Total CMS no longer logs visitors out of other PHP apps on the same site. When a Stacks page or another app had already opened the session, Total CMS destroyed that session file before reopening its own. An app with
session.use_strict_modeon (VibraCart Pro, for one) then rejected the session, so visitors were logged out and carts emptied themselves. The host's session is now closed, not destroyed, and a host session in a different folder is logged at info. See Sharing Sessions. Reported by StacksWeaver - A cropped image is sized from its crop. With a
croprect,image()andpicture()used the whole upload's dimensions, so the<img>was sized wrong andsrcsetran to descriptors far wider than the image delivered, giving large screens a small image. Reported by StacksWeaver - Clicking Save always sends, even after a failed save. A failed request left the form refusing every later attempt with "Error in form, cannot save" until a field changed; the Save button is now the retry
- A save right after correcting a field no longer fails with "Error in form, cannot save". Validation now waits for the field's pending change before deciding
- A form's "id taken?" check works on a root install. Register forms and admin-routed forms asked the wrong URL, a 404 every time, so every handle looked free; the mailer action had the same flaw
- A new form no longer steals focus on a public page. It leaves focus alone when another field has it or carries
autofocus, and never scrolls to a form below the first screen - Front-end forms with an image field no longer request an admin tool. The embed loader treated the upload field's link dialog as a
cms.embed()iframe, a 403 for members - Fields rendered without an icon keep their full corners: the styled text, markdown, JSON, code, SVG and select fields squared their right corners anyway
- The WordPress import no longer times out on images, and keeps categories. Featured images download inside the import job instead of the upload request (a 273-image export took close to a minute), and a failed download imports the post without its image. Categories and tags go to
categoriesandtags, the author is the display name, images keep their filename, and block editor markers are stripped - The
trimfilter takes Twig's three arguments. Total CMS shadowed Twig'strim(characterMask, side)with PHP's two-argument function, sotrim('x', 'left')threw - A JSON Schema list type such as
["string", "null"]no longer crashesjumpstart:importandschema:lint - Extension Twig globals reach templates. An extension that registered only globals got none, while the admin showed Twig Globals granted
- An air-gapped site with a rejected offline license file says so, with the reason and the
domainfix, instead of a misleading "DNS verification required" - Gallery reordering no longer throws when part of the field failed to build, and admin API errors show the server's message instead of "Unknown error"
- No more "mkdir(): File exists" when a web request and
jobs:processcreate the same directory at once - A form with a field named
styleloads. Conditional visibility wrote to the form's inline style, and a browser resolvesform.styleto a field of that name, so the form threw "removeProperty is not a function" on load - A styled markdown field's toolbar leaves out buttons it cannot run. A
toolbarConfignaming a styled text button such as Indent, Underline or an alignment showed it, and clicking it threw, since the markdown editor has no such command
- Sharing Sessions. A new Operations page explains how to share
$_SESSIONwith other PHP apps on the site since 3.6 moved sessions to<datadir>/.system/sessions, the split-session symptoms, and how to confirm them. See Sharing Sessions - Reference groups sort themselves. Operations, Field Types and Bundled Extensions list their pages alphabetically
- The licensing page explains the domain-match requirement for offline license files, and points a client handoff at the freelancer kit
- Total CMS in the ChatGPT and Claude directories. The AI integration page and the welcome page link the ChatGPT listing and say to search Claude's directory for Total CMS. See Using Total CMS with AI
- The MCP CORS docs match the code. An empty
mcp.allowedOriginsallows any origin, as the settings help already said; the MCP page claimed it blocked browsers. A listed origin is matched in full, and the extension docs sayoutputSchemais now enforced. See MCP Server
- RSS feeds are off unless a collection turns one on. Upgrade note.
/feed/rss/{collection}served every collection that had a URL, and the feed URL's parameters chose which fields appeared, so any indexed field of those collections could be read by anyone. A collection now publishes a feed only when Publish RSS Feed is on in its settings. On upgrade, blog and feed collections are turned on automatically; every other collection's feed returns 404 until you turn it on. Field mapping (title,content,date,author,media) andlink,imageandlanguageare now collection settings and are ignored in a feed URL, so a feed that mapped them in its URL needs the same values set on the collection's RSS Feed card. See RSS Feeds - Drafts no longer leak into RSS feeds. Only the
blogschemas left drafts out, and adding anyincludeorexcludeto the feed URL switched that off, for blogs too. Drafts are now left out for every schema, whatever the URL says, unless the collection's own Hidden Field setting is changed - Signing in and password reset match the email address exactly. The lookup matched part of an address, so
smith@gmail.comresolved tojohn.smith@gmail.com. A password reset mailed the address that was typed and looked the account up the same way when the link was used, so the owner of the shorter address could set the password on the longer one - A failed sign-in no longer says whether the account exists. The message named the account and told a wrong password apart from an unknown address. Both now show "Invalid login credentials", and an inactive, expired or login-limited account is only reported once the password is correct
- The Bulk Mailer endpoints need a signed-in user with the Mailer permission.
/api/action/mailer/bulk,/previewand/objectsonly checked the edition, so on a Pro site anyone could queue a bulk send to a whole collection or list any collection's object ids and titles without signing in. They now require a session or API key whose access group grantsmailer, the same check as the Mailer admin page.POST /api/action/mailer, which front-end form actions call, stays public - A new access group no longer starts with Mailer, Data Views, Builder and every utility ticked. The admin form pre-selected them, and "All utilities" as well, while a group created any other way starts without them, so a group made in the admin could reach more than the built-in Editor group. The form now starts from the same defaults, so these stay off until an admin grants them. Existing groups are unchanged
- Password hashes are never sent to clients. Upgrade note for API and MCP writers. With public Read on an auth collection, anyone could GET a user record and receive its bcrypt hash. Read permissions decide who sees a record; no setting exposes the hash. API responses and HTML fragments now leave every password property out, index rebuilds never store one even when a schema lists it in
index(saving the schema drops it, andschema:lintwarns), and a RESTPUTthat omits the password key keeps the stored hash, so a GET-then-PUT round trip no longer locks the user out. MCPupdate_objectnow merges likepatch_objectinstead of replacing the record: agents never see passwords or binary values, so a full replace could only wipe them - Public Read is removed from sensitive collections. Upgrade note. 3.0.41 and 3.0.42 defaulted new collections to public Read and nothing cleared it since. Several affected collections are hidden from the admin, so the box could not be unticked; on an auth collection it let anyone fetch user records. A one-time migration removes
readfrom the public operations of auth collections (and schemas inheriting from auth), playground, mailer, automations, Site Builder pages and data views, matched by schema so renamed collections are covered. Other public operations and content collections are left alone. A front end that read one of these collections anonymously needs an API key or an access group - Deck item labels and list option labels are sanitized when shown. Both printed stored values into the admin as HTML, trusting that the value had been sanitized on save. A
codefield is not, and neither is any field withhtmlcleanoff, so a deck label pattern or a relational option pointing at one could run stored markup. Deck labels now go through the sanitizer in the browser and on the server, so SVG icons and simple formatting still render; list fields default toallowHTML: false, so a list that relies on HTML in its option labels needsallowHTML: truein its settings
tcms check: the Server Checker from the command line. Bundle integrity, license, server information, directory permissions, required and optional PHP extensions, in the admin utility's order, with--jsonand--config(the merged configuration, secrets redacted). It exits 1 when the install cannot run (corrupted bundle, unwritable directory, missing required extension), so a deploy script can gate on it. The checks run in the CLI's PHP, so extension and cache rows can differ from what a page request sees; the output says so- The
markdownfield: a Markdown source editor. You write Markdown and it is stored as written. A toolbar inserts the syntax, Preview shows it rendered, and Fullscreen puts the source and a live preview side by side. Images and files upload through the same dialogs as Styled Text and are inserted as Markdown. The editor is the admin's CodeMirror, or a plain textarea on a page that does not load it. Render with|markdown. See Markdown - The
styledmarkdownfield: the Styled Text editor, saving Markdown. The Tiptap visual editor limited to what Markdown can express, with a source mode and preview, storing a Markdown string instead of HTML. A field nobody edits is never rewritten, and content the visual editor cannot keep (raw HTML, comments, footnotes, abbreviations, linked images) opens in source mode; switching to visual asks first.markdownandstyledmarkdownstore the same string, so a schema can switch between them, andhtmlclean: trueopts in to the sanitizer as on thecodefield. See Styled Markdown - The login form can leave out "Keep me signed in".
cms.form.loginForm({showRememberMe: false}), orshowRememberMein the whitelabel login options, renders the form without the persistent login checkbox - RSS Feed settings on each collection. A new card on the collection form holds the feed's name, description, language, link, image, filters, item limit and field mapping, plus a Hidden Field: objects with that property on are left out of the feed. It defaults to
draft. The feed URL is a clean/feed/rss/{collection};include,exclude,limit,nameanddescriptioncan still be passed to build a filtered variant, which is what the Feeds utility now generates. A Markdown content field is rendered to HTML in the feed - Send History for bulk emails. A template's edit page lists its recent bulk sends with sent, failed, skipped and pending counts, marks test sends to an override address, and refreshes after each queue. Until now nothing in the admin showed what a bulk send had actually done
cms.adminAssetsHead()loads browser error reporting. The Sentry snippet was included only by the two built-in admin layouts, so a custom admin page (the Stacks Admin Core, or any page calling the helper) ran the admin scripts with nothing listening, and the upload bug fixed below was never reported. The helper now emits the snippet when thesentrysetting is on, limited withallowUrlsto errors thrown by Total CMS's own assets or its dashboard pages, so a site's own scripts are not reported
tcms inforeports the site's real domain, edition and license. The domain comes from the request's Host header, which the CLI never has, so every command ran as the domainunknown: the license API had no record for it andtcms infoshowed a licensed Pro site asDomain unknown,Edition Trial,License Invalid. Web requests now record the site's origin incache/.siteurl(the same way.docrootalready is) and the CLI reads it back;domaininconfig/tcms.phpstill overrides it. Until the site has been loaded once in a browser, or right after a cache clear, the CLI still reportsunknown- The Server Checker shows a directory that is not writable. The permissions table dropped every failing row along with the disabled-cache row, so an unwritable
tcms-data,cache,logsortmpsimply disappeared from the list instead of showing as failed. The Domain row now shows the domain Total CMS resolved (the one it licenses against), including behind a reverse proxy, rather than the rawSERVER_NAME tcms infolists every installed cache backend. It read the cache settings with a key shape that does not exist, so it saidfilesystemon every install, with APCu, Redis and OPcache all running. It now reports the same per-backend status as the admin's Cache Manager (APCu (not active in CLI), Redis, Filesystem, OPcache), and the JSON gainscache.backendsalongsidecache.backend, which names the backend a web request stores data in- A blank-named depot folder no longer wipes the whole depot. The Add Folder dialog is prefilled with
parent/; submitting it without a name created a folder named"", which the next save wrote into the record. Deleting that folder passed the name-confirm prompt (an empty answer matches an empty name) and built a request that the trailing-slash rewrite turned into the whole-property delete, which emptied the depot and removed its directory. Blank names are now dropped in the browser and refused by the server for create, delete and rename, and an existing record with a blank-named folder is healed on load by moving its contents into the parent, where they already lived on disk - A fresh upload keeps its data on a page that loads
forms.jswithadmin.js. A custom admin page loading both scripts lost every new upload:forms.jsreplaced the field classesadmin.jshad registered with its lazy loaders, the preview's tags sub-field had no field object yet, and the next save stored empty sub-fields over the file that had just been uploaded, leaving it on disk but broken in the object.forms.jsnow keeps any class already registered, and file and image fields wait for the preview's sub-fields before writing the upload's data - Deleting a file while it is still uploading no longer throws. The upload's completion rebuilt a preview that was already gone and failed with "Cannot read properties of null (reading 'preview')"; file and image fields now return when there is no preview
- Site Builder pages answer HEAD requests like GET. Every builder page returned 404 to HEAD while GET returned 200, so uptime monitors, link checkers and
curl -Ireported working pages as missing. HEAD now gets the same rendering, redirects, fallback 404 page and page gates as GET; the server drops the body - The new object form keeps the cursor in the first field, in Safari too. An image field's link dialog holds a hidden iframe of the ImageWorks builder page, whose own form counted as a new object form and focused its first input; Safari moves focus into a hidden frame on a scripted
focus(), so the field lost focus about half a second after load. The auto-focus now runs in the top window only, and picks the top-most, then left-most, field on screen rather than the first in the markup, so a formgrid that places the Draft toggle above the ID field no longer gives the toggle the cursor - IndexNow submissions are accepted. Every submission was sent without its JSON content type, so the IndexNow endpoint answered HTTP 415 and no changed URL ever reached Bing or the other engines. The job failed in the queue with "IndexNow returned HTTP 415". Submissions now go out as JSON; re-save a page to submit it
- MCP content no longer returns
&for an ampersand. Styled text read as Markdown through the MCP tools came back with every&HTML-escaped, so "Tom & Jerry" read asTom & Jerryand agents copied that into titles and summaries. A plain ampersand is now returned as written - Form dividers, section headers, fieldsets and accordions hide with their fields. A
---divider, a section header, a fieldset or an accordion panel in a formgrid stayed on screen when every field in it was hidden by a visibility rule. Each now hides with its fields, and the blank space left by hidden rows at the end of a form or card is closed up - A Sync push no longer removes the receiving site's files. A file or depot field was sent empty and written over whatever the receiving site had, so a push dropped a file uploaded there from its record. File and depot fields are now left out of the payload and kept on the receiving site, the same as images and galleries, at the top level and inside cards and decks. Both sites need this version
- A Sync push no longer breaks images inside cards and decks. A page's SEO image lives in its
seocard, and the push sent its file name and alt text to a site that did not have the file. Re-uploading the image there lasted until the next push, which replaced the card. Image and gallery fields inside cards and deck items are now left out of the payload and kept on the receiving site, as top-level ones already were. Both sites need this version - A test send with Override To no longer stops the real send. Proofing a bulk email to your own address recorded every object as sent, so clearing the override and queuing the real send skipped everyone, while the admin reported the emails as queued. Test sends are no longer deduped and never count as a delivery, and an install that already has test rows can simply queue the real send again
- A bulk send says how many objects it left out. A template goes to each object once; objects that already received it are now left out when the send is queued, and the result says how many ("Queued 12 emails for sending (40 left out: already received this email)"). When every object has it already, nothing is queued and the error says why, instead of reporting emails that would all be skipped
- A scheduled bulk send goes out at the time you picked. The schedule was stored in the browser's
2026-10-01T09:00form and compared as text against the queue's UTC clock, so a send scheduled for later today waited for the next UTC day and the site's timezone was ignored. It is now converted from the site's timezone to UTC when queued, and an unreadable date is refused - The hourly and daily email limits count the right hour. The window was worked out in the site's timezone but compared against send times recorded in UTC, so the limits were off by the site's UTC offset: too strict west of UTC, too loose east of it
- Waiting out an email limit no longer uses up a job's retries. Each deferral counted as an attempt, so a bulk email that waited for the limit a few times had no retries left if the send then failed once
- The Mailer whitelist matches domains exactly and ignores case.
Jane@Example.COMwas refused by an@example.comentry, and an entry written without the@, such asexample.com, also let@badexample.comthrough. The@is now added when missing - A bulk email to an object deleted after queuing is logged as failed. It left no record, so its batch would have shown as in progress indefinitely
- SMTP encryption applies on an install that never saved its SMTP settings. The shipped default was
TLSwhile the check only matchedtlsandssl, so no encryption mode was set until the settings were saved from the admin
- The MCP docs give the right default for
mcp.publicAccess. It has been on since 3.5.0; the docs still said off. Nothing is readable anonymously until a collection's MCP Access is set to Public, and settingmcp.publicAccesstofalserefuses anonymous callers outright - The Mailer has full documentation. The placeholder page is replaced with SMTP and Mailer settings, template fields and variables, Inky layouts, sending from forms, auth flows, automations, PHP and the API, and Bulk Send, including the once-per-object rule, proofing with Override To and Send History
- The OpenAPI spec describes
/action/mailercorrectly (mailerIdanddata, notto/subject/body) and adds the bulk endpoints - Pushover is documented as the bundled extension it is. Form Settings and the Pushover page pointed at a Settings → Push Notifications page that no longer exists; setup now goes through Admin → Extensions
- The license page is rewritten. Lite is no longer sold, so its section is gone and the schemas every edition gets move under "Included in every edition", with an editions summary linking to pricing. New sections cover what a license covers (www, ports, testing subdomains, rehoming, client transfer), free development domains, the trial, updates and how validation works. The installation guide's License step now says that
localhost,127.0.0.1and any.localhostor.testdomain run with every Pro feature and no license, that.localdoes not, and that DDEV does not serve on.testby default - Versioning content in git. The deployment guide gains an optional section for sites that commit
tcms-data: what must never be committed (.system/holds plain-text API keys, the sync key,site.key, OAuth signing keys, sessions, queues and migration state;auth/holds password hashes;.index.jsonchurns on every save), an ignore list that keeps content and drops runtime state,repair:indexafter a pull, and what to expect from.meta.json, uploads and Markdown storage json_decodeis documented as the PHP function it is, not a filter. The filters page showed|json_decode, which fails with "Unknown filter", anddocs_lookupserved the same entry. It now points toparseJson(), whose entry says what it returns: an array, or null for empty, invalid or non-array JSON- Internal doc links resolve on docs.totalcms.co and in the admin. 58 links were bare relative paths that the docs site resolved against the current page and the admin viewer sent to
/admin/...; Search Console was reporting the doubled paths as 404s. They are written asdocs/paths, which both places resolve
- Confirmation dialogs wait 2 seconds instead of 3. The countdown that keeps a destructive dialog's confirm button disabled now defaults to 2 seconds. The settings form's placeholder and the script's own fallback said 5 and now agree too. A site that set its own Confirmation Countdown under Settings → Dashboard keeps it
- Textareas size to their content in CSS. A textarea now grows and shrinks with its text through
field-sizing: content, and never gets shorter than itsrows, so an empty field still shows its full height. The script that grew textareas is gone, which also means a public form's textarea sizes itself without any JavaScript, and the JSON field's textarea grows too. The fields that hand their textarea to an editor (code, styled text, SVG) are unchanged,autoGrow: falsestill keeps a fixed height, and the 60vh ceiling and the resize handle work as before. One difference: the old script only ever grew, so deleting text now shrinks the field back, down to its rows
- Gallery images drag to reorder again. A 3.6 change that stopped action-bar clicks from focusing the field also blocked the move handle's mousedown, so a drag never started. The move handle is now exempt
- A freshly uploaded file in a deck no longer fails the next save. A new upload's download count of
0blanked its input, which then saved asnulland failed schema validation, taking every other edit in that save with it. Reloading the page hid the problem. Zero and false values now survive in both the file and image previews - A date field with both onCreate and onUpdate re-stamps on every save. With both flags set, onCreate always won, so once the first save had filled the date a "last modified" field froze at creation. onUpdate is now checked first. Either flag on its own behaves as before, and the date field docs now spell out what each setting and the combination do
- A duplicated object starts with its onCreate and onUpdate dates empty. The duplicate form copied the original's timestamps even though the save re-stamps them, so it showed dates the new object would never keep. Those fields are now left blank, like the id and file fields already were
- The ImageWorks builder shows "Error" when it cannot read an image's size. A network failure while fetching the preview (Safari's "Load failed") surfaced as an unhandled error instead of the readout the builder already showed for a failed response
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.
- 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
- 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
-
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
-
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
- 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.
-
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
- Zip installs: move your own files out of the Total CMS folder before you update. An update replaces
config/,public/,resources/,src/andvendor/whole — it installs the new release's copy over the old one rather than merging file by file — so a folder you added inside any of them is removed along with its parent. Earlier installation instructions told zip users to point their document root at the extractedpublic/directory, which leaves nowhere for a site's own stylesheets, scripts, fonts and media except inside the application. If that describes your install, move those files out to your site, alongside the Total CMS folder rather than within it, before updating. The installation guide now describes the layout this updater is safe in, and Updates lists exactly what is replaced and what is left alone. - The new "keep a copy of the previous version" safety net does not cover the update that installs it. An update is carried out by the version being replaced, so the run that brings you to this release still uses the old code — which deleted its backup as soon as it finished. Retention starts protecting you on the update after this one. Take your own backup before updating to this release, particularly if anything of yours currently lives inside the Total CMS folder.
- A Site Builder page routed at
/rssnow servesapplication/rss+xml. An extensionless route used to fall through totext/html; a route whose last segment is exactlyrssis now treated as a feed. Only that exact segment counts —/rss-helpis still a page, and no other type is matched this way. If you have an HTML page at/rss, rename its route - Enclosure types are no longer
application/octet-streamfor images and PDFs./feed/rss/{collection}guessed from a table covering only mp3, ogg, wav, mp4 and webm, so every other attachment was labelled as a generic download. Nothing needs changing; subscribers just start seeing the real type - An SVG that references an XML entity is now rejected rather than partially cleaned. The sanitiser was upgraded, and it refuses a document whose body uses a
&name;declared in aDOCTYPEinstead of stripping the expansion and keeping the rest — the XXE and billion-laughs shapes. A plainDOCTYPEis still fine, and so is declaring an entity without using one, so ordinary Illustrator and Inkscape exports are unaffected. If a file is refused, re-export it from your design tool /mcpnow answers the 2026-07-28 MCP specification as well as the older ones. Nothing needs changing and no client has to be reconfigured — each request is classified on arrival and routed to the lifecycle it belongs to. If you run PHP-FPM with a small worker pool, note that the new spec'ssubscriptions/listenholds one worker for the length of its window:mcp.subscriptionStreamSecondsdefaults to 1 second and the existingmcp.listeningStreamMaxConcurrentcap bounds how many can be open at once- Regenerate your Designer Tokens. Tokens issued before this release carry half the randomness they appear to — see below. Existing ones keep working and nothing breaks if you leave them, but a token generated from now on is genuinely 14 characters. To reissue one, turn the Template Designer toggle off and back on for that template, then update the token in your development tool
- Template Designer remote sync starts working again. If your production templates have been stuck at an old version — or sitting empty — since 3.5.0, the pushes were 404ing (see below) and the fix needs no configuration. Render the dev page once and the current content is pushed. Where a production
.twigwas left empty, the body is still on your development server: the sync will restore it, or the badge's "Copy Template" button hands it to you - Template ids resolve inside
builder/templates/on every surface. 3.5 movedtcms-data/templates/*intotcms-data/builder/templates/*, but the Twig loader's root becamebuilder/— one level above — socms.render.loadMore('blog', {template: 'blog/card'})started looking forbuilder/blog/card.twigand failing with "Unable to find template". Ids are now resolved relative tobuilder/templates/wherever one is accepted, so pages that worked before 3.5 work again with no edit. If you worked around this by writingtemplates/blog/card, that keeps naming the same file — the redundant prefix is ignored
- Designer Tokens are no longer half as random as they look. The token pattern interpolated the same 7-character random value twice, so a 14-character token like
mt1euuqmt1euuqcarried only 7 characters of entropy — and this token is the sole credential on a public endpoint that can overwrite template content. New tokens are drawn as a single 14-character value. Existing tokens are unchanged and still valid; see the upgrade note above to reissue one - A user can no longer rewind their own login quota or audit trail.
maxLoginCountwas a protected field butloginCount— the counter it is measured against — was not, and bothLoginServiceandPasskeyLoginActiongate onloginCount >= maxLoginCount. Because the profile form submits every schema field, a user who had reached their limit could save their profile withloginCount: 0and grant themselves unlimited logins, repeatedly.loginCount,lastloginandcreatednow join the privileged set: a non-super-admin's write to them is reverted to the stored value and logged. Nothing legitimate is affected — the login counter is written byLastLoginUpdateServicethrough a domain service, andcreatedis assigned server-side on every create — and a super-admin can still set all three by hand
- Feeds in Twig:
cms.feed.rss()andcms.feed.atom()build a feed from two arguments — the feed's own details, and a list of items the template has already shaped. Choosing and shaping stays where|filter,|sortByand|mapalready live, which is what the existing/feed/rss/{collection}endpoint cannot reach: it maps fields by name, so it cannot compose a title out of two fields or run a Markdown field through|markdown. Escaping, CDATA, RFC-2822 dates and theatom:linkself reference are handled for you. Items take an optionalmediafor enclosures — a bare URL, or{url, type, length}for a podcast, where image and file fields already carrymimeandsize. See Feeds - An update refuses an incomplete archive instead of installing it. Extraction reports failure when the disk fills, a permission cannot be satisfied, or an entry is damaged, and that result was not being read — so a partly-extracted release could be swapped over a working install one directory at a time, leaving a mix of versions or a truncated file. A single zero-length file in
config/is enough to end every request with "Value of type int is not callable" before any of the checks that would explain it can run. The extraction result is now checked, and the staged files must contain the directories every release ships before anything is replaced. A refused update leaves the site running on the version it already had - A zip install now keeps the version it replaced. A successful update used to delete its own backup the moment it finished, so
tcms update:rollbackonly ever worked after an update that failed part-way — exactly the wrong way round for anyone who discovered a problem afterwards. One copy of the previous version is now kept attcms-data/.system/backups/(about 60 MB), shown in the Update Manager with a button to remove it, and restorable withtcms update:rollback. Untick Keep a copy of the previous version, or pass--no-backuptotcms update:apply, to skip it. The copy goes in the data directory rather than beside the application on purpose: a directory next to the application sits inside the document root on most installs, which would leave the previous release's PHP reachable over the web — including whatever the update just patched. Where it cannot be placed there safely (a data directory on another filesystem) it is discarded rather than left behind, and the update log says so - Named validation patterns in schemas: a property's Extra Schema Definitions can say
{"pattern": "patterns.version"}instead of carrying a literal regex. It is the same name a form field uses, so both share one definition, and there are no backslashes to escape. Nested patterns use their dotted path (patterns.postCode.usa). Expansion happens once, on save, so the stored schema is still real JSON Schema for the validator, MCP and exports — and it adds the^/$anchors, which a literal pattern needs and often does not get patterns.versionandpatterns.versionExtended: a plain three-part release number (3.5.0), and full semver with an optionalvprefix, prerelease and build metadata (v3.5.1-rc.1,3.5.0+build.7)- Support for the 2026-07-28 MCP specification, served from the same
/mcpURL as the older revisions. That revision drops theinitializehandshake and the session that came with it: a client states who it is on every request instead, which means an agent can reconnect without re-establishing anything. Tools, resources and prompts are identical either way — the same registry, the same persona filtering, the same authorization — so a site gains the new clients without changing its surface. See MCP server subscriptions/listen, the new specification's replacement forresources/subscribe: one stream on which a client names the resource URIs it cares about and receives updates until the window closes. The stream is filtered by the caller's persona, so a client only ever receives updates for resources it could read directly — naming one it cannot reach is accepted but never delivers.mcp.subscriptionStreamSecondssets how long a stream stays open
- The JSON field forgives unescaped backslashes. The Extra Schema Definitions box holds JSON Schema fragments, and the common one is a regex — nearly all backslashes. JSON only permits a backslash before
" \ / b f n r t u, so{"pattern": "^\d+$"}was a hard parse error even though the intent is plain. A backslash that starts no legal escape is now doubled, alongside the trailing commas the field already forgave. The repair is idempotent, and runs only when the value fails to parse — valid JSON is never rewritten - An access-group refusal is now written to the access log. The 403 a user sees says only "Access denied", and until now nothing was recorded server-side either —
oauth-activitycovered API tokens, so a refusal for someone signed into the admin left no trace at all. Working out why a member could not save something meant reconstructing their session by hand. Every refusal now logs the resource, operation, user, route and path to the access channel, which names the check that refused rather than just the fact that something did - The MCP protocol revision is now negotiated rather than asserted. A client that asks for a revision the server supports is answered on that revision instead of being told which one the server prefers, so editors and agents pinned to an older revision stop having to decide whether to continue against a mismatch. Where the server genuinely cannot speak what was asked for it still counter-offers, which is what the specification calls for
- SVG sanitising is stricter about XML entities.
enshrined/svg-sanitize1.0 refuses a document that references an entity rather than cleaning around it, which also fixes 0.x leaving an unresolved&name;in its own sanitised output. The release additionally carries two upstream fixes tohrefhandling — one where cleaning was skipped whenhrefandxlink:hrefappeared together — and a fix for quadratic sanitising time on element-dense files mcp.subscriptionsEnabled: falsenow refusessubscriptions/listenoutright rather than accepting a stream that would deliver nothing for the length of its window. Handshake-eraresources/subscribecalls are still accepted as before, so clients that error on a rejected subscription keep working
- Deleting the file from an image or file field no longer fails. The delete returned a 500 —
Cannot access offset of type string on string— and the file vanished from disk while the field went on claiming it was there. One remover serves every field type without a dedicated one, and it was written for the gallery shape: a gallery holds a list of files to filter, while an image or file field holds a single file's details directly, so the filter was reading a field name as if it were a file. A single-value field is now emptied, which is what deleting its only file means - A schema file that is not valid JSON no longer breaks the tools that would tell you so. One unparseable file in
.schemas/threw out of the internal "does this schema exist?" check, and because nine places ask that question — the admin schema page andschema:lintamong them — a single bad file could take out parts of the admin that had nothing to do with it.tcms schema:lintwas the worst of them: the command for finding broken schemas died on the first one it met, showing a stack trace rather than naming the file. A corrupt schema is now reported as<file> is not valid JSON: <reason>, everything else keeps working, and lint tells you which file to fix - A form no longer hangs when a field cannot read its own value. A JSON field holding invalid JSON threw out of value collection, and because the payload was gathered as an argument to the save request, the throw escaped before the error handler was attached. The form stayed in the processing state that blocks every later submit — a dead form, no request sent, and no message shown. The payload is now collected before the form is locked, and the error names the offending field
- The Template Designer pushes to production again. Every remote sync had been answered with a 404 since 3.5.0: the Designer endpoint moved under the site's
/apiprefix when the routes were reorganised, but the code composing the push was never told, so it kept addressing the old location. The failure was easy to miss — the local save still succeeded, the production file simply never changed, and a cached copy could keep serving the previous version until the cache was cleared. The push is now addressed to/api/designer/templates/{path}, which is where the endpoint has actually lived all along, and the documentation has been corrected to match. Subfolder installs are fixed by the same change, and a site at the domain root no longer emits a doubled slash after its hostname - A Template Designer id written with a leading slash is accepted.
{% templatedesigner for '/templates/card' %}produced an empty segment in the push URL and, locally, a folder named literally/templates. The id is now normalised where it is read, so both forms behave identically — the server already tolerated the stray slash - A template id now names the same file everywhere.
{% templatedesigner for '...' %}, thetemplateoption oncms.render.loadMore()and friends, and the load-more endpoint that serves the later pages each resolved an id their own way. The Designer's local save wrotebuilder/myblog.twigand reported a green "Local: ✓", while the remote half looked for the real template atbuilder/templates/myblog.twigand answered "Template not found" — a stray file on one side and a sync that could never succeed on the other. All four now resolve through one function: an id is relative tobuilder/templates/, a leadingtemplates/is optional, and so is the.twigextension. The load-more endpoint was corrected alongside the Twig helper, so a server-rendered first page and the pages fetched after it cannot disagree about which template they are rendering - A user can update their own profile again. Saving at
/admin/profilereturned "Access denied: Your access groups do not have permission to perform this action on this collection" for anyone whose groups did not separately grant write access to the auth collection — which is the point of having restricted groups at all. The carve-out that is supposed to let a user edit their own record compared the request's collection against the rawAUTH_COLLECTIONsession value, and that value is empty for everyone who signs in at the ordinary/admin/loginURL, since it carries no{collection}segment.'auth' === ''never matched, so the write fell through to the group check and was refused. Everywhere else in the codebase an empty auth collection already means "the configured default", and the comparison now resolves it the same way. Sessions store the resolved value from now on, but no one has to sign in again for the fix to take effect. Only super-admins were unaffected, which is why this survived since 3.1.0
- Regenerate any UPC-E or Codabar barcodes. Both were being encoded incorrectly, and the fix below means existing ones are wrong wherever they have been printed or saved. UPC-E now takes the 6-digit payload; the 7- and 8-digit forms are still accepted and normalised, so no template needs changing. Codabar data must no longer contain A–D — the encoder supplies the start/stop characters itself
tcms push --collectionshas changed meaning. It used to move objects from five allowlisted collections; it now moves collection settings, which is what the name should always have meant. Scripts callingpush --collections=builder-pagesexpecting objects will silently push settings instead — use--pagesfor those objectstcms push --collection-metais removed. Its job is now--collections. This one fails loudly as an unknown optioncms.barcode.upce()output: the rendereddata-valueattribute now carries the 6-digit payload actually encoded rather than the input string, when a longer form was passed- Everyone is signed out once when you upgrade. Session files move from the server's shared session directory into
tcms-data/.system/sessions(see below), and sessions in the old location are not carried over. Anyone who used "keep me signed in" is restored automatically rather than bounced to the login form - Behind a CDN that is not Cloudflare, set
trustProxyHeaders. Total CMS now believesCF-Connecting-IPandX-Forwarded-Foronly when the request came from a private address or a Cloudflare edge. Cloudflare and a reverse proxy on the same server both keep working untouched; any other CDN in front of the site needs$settings['trustProxyHeaders'] = 'always'— and its origin firewalled first, or that setting is an open door. Settings → Server Info reports what your install is doing - CSV exports may gain columns. A collection whose objects still hold properties its schema no longer declares now exports those properties instead of dropping them. Existing columns keep their positions — the new ones are appended at the end
- Client IP is no longer taken on trust.
CF-Connecting-IPandX-Forwarded-Forare set by whatever sits in front of the server — but a visitor can send them too, and seven places read them unconditionally. On a site reachable directly from the internet that let a caller hand itself a new identity on every request, walking straight past the per-IP rate limit onPOST /api/action/mailer, the OAuth token endpoint, the MCP endpoint and the automation webhook throttle. One resolver now makes that decision for all of them, andtrustProxyHeadersdecides when the headers are believed. Cloudflare's published ranges ship with each release and are refreshed at build time, so Cloudflare sites need no configuration - Session files are no longer stored in the server's shared session directory. PHP's default location is one directory for every site on the machine, so a neighbouring site's session cleanup could read or delete Total CMS session files. They now live in
tcms-data/.system/sessions, which the shipped Apache and nginx rules already deny, with0700permissions
- Seed content to production:
tcms push --objects=blogsends a collection's objects to your production server, skipping anything already there. Any collection can be seeded, not just the five that sync could already move — so a new feature's schema, collection and starter content can all travel together.--objects=blog:welcome,aboutnarrows to specific objects;--overwrite(with--force) lets the local copy win instead - Seed Objects in the Sync Manager: pick the collections whose objects should be seeded on push. All-or-nothing per collection, and the Sync Manager can only add — overwriting stays on the CLI where it has to be asked for explicitly
- Feature-named sync flags:
--pages,--dataviews,--mailer,--mcp-promptsand--automations, each accepting an optional id list (--pages=home,about). They replace the old--collections, which named a gated set of collection ids and required knowing that Site Builder pages live in a collection calledbuilder-pages - MCP: read-only Site Builder template tools —
list_templatesandget_template, so an agent editingbuilder-pagescan see whichpage.data.*keys a template actually consumes instead of guessing. Admin-scoped, and deliberately read-only: writing Twig reaches the wholecms.*surface, which is a larger authority grant than writing content
- A mixed push now sends two requests. Seeded objects go to the skip-existing import endpoint while everything else keeps its upsert semantics, so
push --schemas=faq --objects=faqdeploys the schema and seeds its rows without either behaving like the other. Both routes already existed in 3.5.0, so a seed push works against a production server that has not upgraded yet tecnickcom/tc-lib-barcodemoves to^2.14, which brings real input validation to the barcode encoders. Values that used to render an incorrect barcode now raise an error instead. In a Twig template that error renders as an HTML comment rather than breaking the page — so a bad barcode value degrades to a missing barcode, with the reason visible in View Source- Binaries still never travel. Image, file, gallery and depot fields are omitted from every sync payload and the receiving side keeps what it already had, so a seeded post cannot blank a production image. The
image,gallery,fileanddepotcollections cannot be seeded at all — the binary is the object there, and seeding one would land a record pointing at a file the target does not have
-
UPC-E barcodes encoded the wrong product.
upce()accepted 7–8 digits, but UPC-E's payload is 6, and the encoder never decompressed the longer forms — it zero-padded the input and appended a check digit.04252614encoded UPC-A0000042526148instead of the correct0042100005264, scanning cleanly as a product that does not exist. All input forms now reduce to the same payload and the same barcode -
Codabar barcodes did not scan. A, B, C and D are reserved as Codabar's start/stop characters and the encoder adds them itself, so passing
A1234Bencoded literally asAA1234BA. Such values are now rejected with an explanation rather than silently producing an unreadable barcode -
"Keep me signed in" survives an update. Persistent-login tokens were stored inside the application directory, which a zip update replaces wholesale — so every update signed out everyone who had ticked the box. They now live in
tcms-data/.system/persistent_tokensalongside logs, and existing tokens are carried across on the first run after upgrading -
Sessions no longer expire early on shared hosting. PHP's shared session directory is cleaned using whichever site triggers the cleanup, commonly on a 24-minute lifetime, so a neighbouring site's cleanup deleted Total CMS sessions regardless of the 24 hours Total CMS configures. With sessions in their own directory that can no longer happen. If the directory cannot be written, Total CMS falls back to PHP's default rather than leaving nobody able to log in
-
A single unreadable object no longer aborts a whole export. One object whose stored data did not match its schema — usually after the schema was edited — threw out of
exportAllObjects(), so the export produced nothing instead of the remaining objects. It now skips and logs, matching the JSON and CSV exports, and the export job records which object ids it skipped -
CSV export no longer silently drops data. Columns are built from the schema, so properties still stored on an object but removed from the schema had no column and vanished — while a JSON export of the same collection kept them. A CSV taken as a backup after a schema edit was quietly incomplete
-
Booleans inside cards survive a CSV round trip. A card's
falseexported as an empty cell andtrueas1, while a top-level boolean column in the same file saidtrueorfalse. Card booleans now use the same spelling and are restored as booleans on import, using the card's own sub-schema so a text field containing the word "true" stays text -
A partial
authconfiguration no longer breaks every request. Twenty places readauth['enable']directly. On installs that turn PHP warnings into exceptions, a settings array without that key made the authentication middleware throw on every request — a 500 rather than a login page. Missing now means enabled, as the shipped default always intended -
A throwing extension route registrar can no longer take down the site. An extension whose route callback threw took the exception straight out of the boot sequence, producing an empty HTTP 500 on every route — including
/admin, so the operator could not reach the page to disable it. Route registrars now run through the existing extension guard: a thrower loses only its own routes, is logged with attribution, and surfaces as an error in the admin -
Large collections could render an empty listing. Collections over 500 objects build their index by streaming it straight to disk, and the build returned nothing because the content is deliberately never held in memory. Anything that triggered that build and used its result — a fresh install, a cleared index, a repair, the rebuild endpoint — got zero objects back, so the page rendered empty and the rebuild reported an empty index. The next request was already correct, because the file itself was written, but nothing reported the empty one
-
"This month" excluded the first of the month. The window opened at the 1st at the current clock time rather than midnight, so a record dated the 1st — stored as midnight, therefore earlier — fell outside it. A post dated the 1st was missing from every "this month" filter for the whole month, not just on the 1st. "This week" was unaffected
Total CMS 3.5 is a platform release. The jump from 3.2 reflects the scope: new top-level subsystems — Site Builder, Extensions, CLI, MCP server, OAuth, Automations, Internationalization, Composer distribution, Setup Wizard, Event system — sit alongside the existing collections and templates engine.
- Site Builder — build pages, routes, and templates in the admin, served dynamically at request time with no build step. Four starter kits (minimal, blog, business, portfolio), drag-and-drop page ordering, an optional Vite frontend pipeline, and git-first templates for teams that keep their site in a repo
- Extension system — extend Total CMS at every layer: Twig functions and filters, CLI commands, routes, admin pages, dashboard widgets, field types, event listeners, MCP tools, form actions. Capability detection turns what an extension actually does into per-capability permission toggles, backed by crash containment, auto-quarantine on production, a pre-enable review screen, and update re-consent
- MCP server + OAuth 2.1 — AI agents read and write your content over a standards-based interface. Three personas (anonymous, API key, OAuth), content tools with full schema validation, saved-query tools, templated prompts, pluggable search providers, and a full OAuth 2.1 authorization server with PKCE, consent, revocation and an audit log. Every install also serves its own documentation to agents
- Automations — run your own PHP handlers on a schedule, a webhook, or a content event, with contained failures, auto-disable after repeated errors, run history, and replay. (Pro)
- Internationalization — localized field types with synced locale tabs, a BCP 47 locale registry with region fall-down, per-user admin language, and an admin shipping in seven languages
- Publishing API — publish from MarsEdit, Byword, Ulysses or Open Live Writer over a WordPress-compatible XML-RPC endpoint. Off by default behind an explicit toggle
- Composer distribution & CLI —
composer create-project totalcms/totalcms, plus a fulltcmscommand-line tool and a first-run Setup Wizard that takes a new install from welcome to dashboard - Event system — a centralized, priority-ordered dispatcher with 20 core events, typed payloads, fault-isolated extension listeners, and import-time suppression so importers fire
import.*rather than a save firehose - Security — a per-site encryption key, security headers across admin, auth and setup, reduced error-report payloads, and extensions that can no longer override core services
- Sync — the Sync Manager now moves collection objects alongside schemas and templates, shows the diff before you commit to it, and backs up whatever it overwrites
The complete feature breakdown is in the 3.5.0 release notes at docs.totalcms.co. Detail follows.
- API prefix: API routes now live under
/api/. External callers must add the prefix; server-side templating is unaffected - Template include paths: the templates root is namespaced — prefix paths with
templates/, e.g.{% include 'templates/header.twig' %}. The same applies anywhere a template path appears - Whitelabel location: whitelabel templates must live under
whitelabel/. Re-save each in the admin to migrate it - CSRF on API writes: session-authenticated API writes require a CSRF token. A verified same-origin request is accepted in its place, so browser forms are unaffected — but anything scripted against the API with a session cookie needs updating. API-key callers are unaffected
- Super-admin scope: super admins are recognised only in the default auth collection. Sites running several auth collections may find an operator has lost privileges, and should be granted them in the default collection
- API key path grants now match on segment boundaries and across the
/apiprefix. A key that was unintentionally reaching neighbouring paths will stop - Install model: new installs are Composer-based; existing zip installs continue to work (docroot →
public/, writable dirs → project root) - Locale setting: the site locale moved to Internationalization settings (
i18n.default); affected sites self-heal - Templates permission retired in favour of the Site Builder permission. Stored
templatesvalues in existing group files survive as the read-side fallback, so groups keep working, but the checkbox is gone from the access-group form - Settings → Installation removed. Its one field (
datadir) orphaned every collection, key, session and user at the old path when saved through the UI.datadiris now set exclusively inconfig/tcms.php, which the Setup Wizard still writes on first run multicheckboxrenamed tochecklist. The old type name still works, so this breaks nothing today
- Site Builder: dynamic page router matching
builder-pagesat request time; templated URLs like/blog/{id}; builder admin UI with hierarchical sidebar, live reload and a page-inspector overlay; starter kits viatcms builder:initeach shipping a custom 404; optional Vite pipeline viatcms builder:frontend; git-first templates from a project-rootbuilder/folder; Template Designer for inline token-gated template definition; per-page middleware with built-inauthgating;cms.builder.url()andcms.builder.canonicalUrl() - Extension system: two-phase lifecycle behind a curated
ExtensionContextAPI; extension points across Twig, CLI, routes, admin nav, dashboard widgets, field types, event listeners, container definitions, schemas, settings, form actions, MCP tools/resources/prompts and search providers; per-extension access control in Access Groups; a sanctioned per-extension storage API; extensions loadable from a project-levelextensions/directory; bundled extensions for Protect, Scheduled, Maintenance, Pushover, Algolia search and Docs - MCP server: built-in server at
/mcp; query, search, fetch, create, update and patch tools; saved-query tools and templated prompts defined as data, targetable at a collection or a Data View; output schemas on every tool; ChatGPT and deep-research compatiblesearch/fetch; an SSE listening stream onGET; a connection checker for the failures that are invisible from outside; access-group governed reach across read tools, write tools, resource reads and enumeration - OAuth 2.1: authorization-code flow with PKCE, refresh tokens, consent screen, revocation, dynamic registration (off by default), RFC 9728 protected-resource metadata, replay detection, and garbage collection of stale self-registered clients
- Automations: schedule, webhook and event triggers; a pre-wired handler context covering object and deck CRUD, querying, counters, mailer, file and image savers, importers, sync and logging;
apiKey,sameOriginandnonewebhook auth modes;ExtensionContext::addAutomation()so extensions can ship their own - CLI (
tcms): collections, objects, schemas, JumpStart import/export, sync, updates, builder scaffolding, extension management; a full object write surface withobject:create,object:patchandobject:delete;schema:lint,search:reindex,repair:files,repair:index,jobs:process,automations:process,skill:install,deploy;--jsonon every command - Composer distribution:
totalcms/cmsandtotalcms/totalcmson Packagist;totalcms/cmsis itself a Composer plugin running project-side maintenance on install and update; subpath-aware routing, assets, OAuth and MCP discovery - Setup Wizard: welcome → environment → data path → account → license → server config → complete, with auto-login. New trials register a name and a verified email at the license step so an evaluator can be told before their trial expires; skipping stays available throughout and existing trials are grandfathered
- Internationalization:
LocalizedText,LocalizedTextareaandLocalizedStyledTextfield types with locale tabs that sync across fields; BCP 47 locale registry with site default and region fall-down; per-user admin locale; localized import/export;cms.locale.*Twig helpers includinghtmlLang(); a locale-aware price field; admin translations for en_US, en_GB, de_DE, es_ES, it_IT, nl_NL and pl_PL - Fields and forms: singleton collections; card field for inline nested objects; secret field for masked storage; file and image fields inside cards and deck items; color field with matching
colorsTwig helpers; checklist field (formerlymulticheckbox); form fieldsets via[[ ]]informgridorcms.form.fieldset(); conditional visibility gains a disable mode and works inside deck tables; tag suggestions on media fields including nested ones;${uid-N}autogen; public registration with an opt-in allow-list and optional email verification - Admin: Permission Matrix auditing what each access group can reach, including an AI / MCP reach dimension; user impersonation; multi-select bulk actions in the collection table with bulk delete and bulk download; a needs-attention dashboard panel and an automations status widget; Collection and Object Visualizers under Utilities → Reporting, both filtered to what the viewing operator can read
- Operations: cron URLs at
GET /cron/automationsandGET /cron/jobsfor hosts without shell cron;cache.domainScopedso installs sharing onetcms-datashare a cache namespace; a rebuilt job queue with an execution deadline, a daily vacuum, real indexes and on-demand clearing of failed jobs; OPcache file-cache-only mode detected and reported - Caching, search and performance:
{% cache %}fragment cache tag with tag-based auto-invalidation and auth-safe defaults; thelistifyfilter; relevance-scored search viacms.collection.searchScored(), shared with MCP search; Twig yield (streaming) mode; CodeMirror 6 and HTMX 4 across the admin - Sync: collection objects sync alongside schemas and templates; push and pull operate as create-or-update; a diff preview with per-item badges, a consequence preview and Select Changed; overwrites backed up first; collection settings sync carrying a timestamp content cannot move
- Developer experience: a built-in agent skill teaching AI coding agents how to build a Total CMS site, installed automatically with an
AGENTS.mdpointer; a Cursor rules file giving Cursor the same grounding;cms.assetsHead()andcms.assetsBody()for injecting core and extension assets into any front end; atotalcmsreference schema exercising every field type; docs reorganized into feature-first groups with the search index shipped in the package - DataViews: views can depend on other views, with the rebuild scheduler resolving the full transitive set and rebuilding in dependency order
- The MCP server moves from Pro to Standard — a Standard site can expose collections for an AI agent to read. Writing from an agent stays Pro, because both credentials granting a non-anonymous persona (API keys and the OAuth server) are Pro features. Lite does not include MCP. Text watermarks and barcodes also moved down to Standard
- Logging consolidated from 22 ad-hoc files into a nine-file taxonomy, with all warnings and errors mirrored into one place. Zip installs now log to
tcms-data/.system/logs/so logs survive updates mcp.publicAccessdefaults to on; each collection's ownmcp.accessremains the gate- Site Builder pages are identified by their route rather than their title in the builder sidebar and the Sync Manager — a route is short, unique, and is what identifies a page in a router-driven builder
- Per-site encryption key — the
encrypt/decryptTwig filters are keyed to a secret generated attcms-data/.system/site.keyrather than a constant shipped in public source. Existing values keep working via a legacy fallback. Includesite.keyin your data-directory backups - Security headers on admin, auth and setup routes:
X-Frame-Options: SAMEORIGIN,frame-ancestors 'self',nosniff, and a strict referrer policy - Error reports carry less data — the error-monitoring SDK is explicitly configured never to attach personal data or request bodies; previously small POST bodies could ride along. The Setup Wizard now asks for consent rather than assuming it
- Extensions can no longer override core services — container definitions under the
TotalCMS\namespace, or already defined by the core container, are denied with a logged warning. An extension could previously registerLoginService::classand silently replace core authentication - Code-executing system collections are super-admin only on import — collections carrying handler code such as
automationscould previously be imported by any admin - Super admins are recognised only in the default auth collection; JumpStart is gated behind super-admin and no longer exports credentials; extension secrets are written with private permissions; dynamic OAuth client registration ships off by default
Pre-release history for the 3.5 line (32 beta and RC entries) is archived in CHANGELOG-3.5-prereleases.md.
- Styled Text Dialogs: Link, Anchor, File, Image, Video, and Block Attributes dialogs in the styled text editor now use the native
<dialog>element with proper focus management, ESC-to-close, and backdrop click handling - Choice Field Refactor: Radio and Multicheckbox fields now share a common
ChoiceFieldbase class for consistent behavior and reduced duplication - Form Grid-Area Application: Simplified how grid-area names are applied to form fields, removing special-case handling across Checkbox, Toggle, Radio, Multicheckbox, and DeckTable layouts
- Centralized Help Text: Field help text rendering consolidated into a single shared helper
- FormField Attribute Builder: New
buildFieldAttributes()method onFormFieldreduces duplication across field types - Sentry Filtering: Added ignore rules for reserved-name schema validation errors and the SortableJS touch-drag race condition when elements are detached during HTMX swaps
- HTMX Confirm Handler: Fixed
hx-confirmdialog crashing on every confirmation request after the HTMX 4 upgrade. The handler now reads the element and confirm message from HTMX 4's updated event detail (ctx+ event target) instead of the removeddetail.eltproperty - Portrait Image Preview on Mobile: Fixed scroll lock on the image preview info panel that prevented editing image metadata on portrait-oriented mobile screens
- Indent / Outdent Buttons: New
indentandoutdenttoolbar buttons for the styled text editor. Uses a stackabledata-indentattribute on paragraphs and headings rather than nesting blockquotes, preserving semantic HTML. Inside lists, the buttons delegate to the native list sink/lift behavior - Standardized Confirm Dialog: New
tcmsConfirmdialog replaces the browser's nativeconfirm()across the admin. Supports an optional auto-dismiss timer and consistent styling - Field Columns: New
fieldColumnssetting for the Radio and Multicheckbox fields arranges options in multiple columns - Report API Access: Access groups with read permission on a collection now automatically gain access to the matching
/reportAPI endpoint
- Image Cache Strategy: Image cache keys now include a content hash so cached derivatives invalidate correctly when a source file is replaced without renaming
- Code Field Performance: Reduced initialization overhead for the code field editor
- Playground Max Height: Twig Playground code field now has a
maxHeightcap to prevent runaway growth on long templates - Styled Text Content Styles: Figure, image float/size, and indent styles moved to shared
styledtext-content.scssso they render consistently on both the admin preview and the public site - Deck Form Formgrid: Formgrid layouts now work inside deck item forms, and deck item setting presets are applied correctly
- Collection Report Sorting: Report property sorting now produces a predictable ordering
- API Key Form Sorting: Collections and data views in the API key form are now sorted alphabetically
- Sentry Filtering: Expanded ignore rules to drop reserved-name validation errors and the CodeMirror closetag addon null-check crash
- DeckTableField Inheritance: Property settings now inherit correctly for DeckTableField
- Subfield Name Collisions: Depot, file, and image subfield properties no longer collide with parent object property names
- Duplicate JSON Fields: Duplicating a property in the schema editor no longer produces duplicate JSON fields in the output
- Duplicate Multicheckbox Options: Fixed duplicate checkboxes rendering in the multicheckbox field
- BaseAccessMiddleware Dependency: Fixed missing dependency wiring for
BaseAccessMiddleware
- Indent / Outdent: Documented the new
indent/outdenttoolbar buttons in the styled text toolbar reference - Field Columns: Documented the new
fieldColumnssetting for radio and multicheckbox fields - README: Trimmed and refreshed the project README
- License: Project license updated and migrated to
LICENSE.md
- Element Attributes Dialog: New
blockAttributestoolbar button for the styled text editor that lets users set class, id, and custom data-* attributes on block-level elements (headings, paragraphs, list items, etc.) without using code view - Block Classes Setting: New
blockClassessetting for styled text provides autocomplete suggestions in the Element Attributes dialog via native datalist - Global Attributes Extension: Class, id, and data-* attributes now survive code view round-trips on all block-level elements in the styled text editor
- Heading Levels Setting: New
headingLevelssetting for styled text controls which heading levels (1-6) appear in the Paragraph Format dropdown. Defaults to[2, 3, 4] - HTML Snippet Unwrap: HTML snippet blocks in the styled text editor now have a remove button to unwrap the element and keep the inner content
- Styled Text Heading Support: All heading levels (H1-H6) are now supported in the editor. Previously only H2-H4 were available
- ImageWorks File Size Display: Preview image file size now falls back to reading the response blob when the Content-Length header is missing, eliminating "Unknown" display on servers with chunked transfer or compression enabled
- Collection Shuffle: Fixed
sortCollection([{shuffle: true}])Twig filter not properly randomizing collection items - Collection Sort Priority: Fixed multi-criteria sort rules so the first rule is primary and subsequent rules are tiebreakers, matching expected behavior
- SimpleForm Null Button: Fixed TypeError crash on pages with
.simple-formelements that lack a submit button (e.g., export pages) - HTML Snippet Type Guard: Fixed crash when an
htmlSnippetssetting contains a non-string template value - List Item Attributes: Element Attributes dialog now correctly targets the
<li>element instead of the inner<p>when editing list items
- Styled Text Settings: Complete example now includes every toolbar button and all configuration options
- Heading Levels: Documented new
headingLevelssetting - Block Classes: Documented new
blockClassessetting - Element Attributes: Added
blockAttributesto the available toolbar buttons list - REST API: Updated REST API documentation
- AI Integration: Added MCP server documentation for AI agent integration
- URL Filters Utility: New
cms.utils.urlFilters()Twig function converts URL query parameters into include/exclude/sort/search options for visitor-facing filtering with clean, shareable URLs - Deck CSV/JSON Import: Import CSV or JSON data into deck properties via the collection import page with object and property selection, update mode, and auto-generated IDs
- Deck CSV/JSON Export: Export deck properties as CSV or JSON files from the collection export page with object and property selection and format choice
- Index Filter Sort: New
sortoption forIndexFilterandDataViewFiltersupporting shorthand (-date) and colon (date:desc) formats - Relational Options Sort: Sort support added to
relationalOptionsusing the new IndexFilter sort capability - Multicheckbox Relational Options:
relationalOptionsnow works with the multicheckbox field type - Paste as Plain Text: Styled text editor now defaults to pasting as plain text, stripping HTML formatting from clipboard content. Configurable via
pasteAsPlainTextsetting - Collection Table Filter: Admin collection table filter now uses substring contains matching instead of word-boundary search for more intuitive filtering
- CalcService Decimal Precision: Added support for decimal precision in
CalcService round() - toSeconds Twig Filter: New filter to convert time strings to seconds
- Import Collection Options:
importCollectionform now supportsupdateandqueueoptions with configurable defaults - Bulk Mailer Enhancements: Bulk mailer moved to its own standalone form with configurable max per day settings
- Commit Count in Version: Version info now includes the commit count
- Autogen on Page Load: Autogen fields now trigger on page load to pick up default values from other fields
- Webhook Content-Type: Form webhook POST requests now send
Content-Type: application/jsonheader - Styled Text Cleanup: Improved cleanup of paragraphs inside styled text lists
- Schema Inherited Properties: Inherited properties now show in schema required and index lists
- Sentry Error Filtering: Expanded Sentry ignore rules for browser extensions, filesystem permission errors, stale deployments, and user schema errors
- PHPStan Compliance: Cleaned up all null coalesce warnings for stricter type safety
- DateFieldResetter Utility: Extracted date field reset logic from ObjectCloner into shared
DateFieldResetterused by both ObjectCloner and ObjectSaver - Bundle Checker: Removed swagger.php from bundle verification
- Duplicate Object onCreate Date: Duplicating an object now correctly sets
onCreatedate fields to the current time instead of copying the original object's creation date - Gallery Launcher on Mobile: Fixed lightGallery not working on touch devices by using
template.content.textContentinstead ofinnerHTMLfor JSON parsing - DataView Timezone: Fixed
lastBuilttimestamp using UTC instead of configured timezone when processed via job queue - Depot Stream/Download Macros: Fixed missing
pathparameter in stream macro output for depot files in subfolders - Numeric Filter Values: Fixed include/exclude filters not matching numeric values (e.g.,
mail_group:2) due to strict type comparison - Autogen ID Override: Fixed autogen overwriting existing IDs on page load for deck items and existing objects
- Factory CSRF Token: Fixed factory import attempting to use CSRF token as a Faker method
- Factory Image Compatibility: Fixed factory-generated images using palette-based PNGs incompatible with Intervention Image 3.x by switching to truecolor
- Deck Item IDs: Deck item IDs now correctly replace hyphens with underscores for Twig dot notation compatibility
- License Caching: Fixed caching license data by storing as array
- License Validation Logging: Corrected license validation logging
- 404 Logging: Stopped logging 404 object-not-found errors
- Code View Crash: Fixed crash in code view within styled text editor
- Collection Table Caching: Fixed caching issue with new admin collection table
- Bulk Mailer Tests: Updated bulk mailer tests to match new
queueBulkSendsignature
- URL Filters Utility: New documentation for
cms.utils.urlFilters()with usage examples, filter links, and search forms - Paste as Plain Text: Documented
pasteAsPlainTextsetting for styled text editor - Index Filter Sort: Documented sort option with shorthand and colon format examples
- Deck OID: Clarified that
${oid}is not supported for deck item IDs - Relational Options Sort: Documented sort option for relational dropdown options
- Nginx Configuration: Added nginx deployment documentation
- Calc Settings: Updated calc documentation
- Validation: Added validation documentation
- Site Builder Plans: Added planning document for Site Builder feature
- Lock on Edit: New
lockOnEditfield setting to prevent editing after creation - Calc Field Settings: New form field calc settings
- Access Group Utils: New utilities for accessing groups
- Collection-Level Watermarks: Watermark settings can now be set at the collection level for images
- Email Test Data Autosave: Email test data now autosaves in the mailer form
- Styled Text Cleanup: Empty paragraphs are automatically cleaned from the start and end of Styled Text content
- 1Password Compatibility: Reduced 1Password prompting on forms
- DeckItemFactory: New DeckItemFactory with related refactoring
- Guzzle Error Handling: Added connection exception catches for Guzzle HTTP calls
- Passkey Login Control: Passkey settings now control functionality in the login form
- Mailer API Calls: Fixed API calls on the Mailer form
- ImageWorks Defaults: Fixed imageworks default settings
- Test Email: Fixed Test Email functionality
- Gallery Tags: Fixed gallery tags
- Image Captions: Fixed loading images with captions on edit
- Depot Drops: Fixed depot drop validation errors
- Property Naming: Enforce properties to start with a letter
- Styled Text: All-new rich text editor replacing our old one, built with image uploads, video/file embeds, table editing, custom inline styles/classes, custom elements, anchor links, audio support, auto-markdown, and code view
- Passkey Authentication: WebAuthn passkey support for passwordless admin login (Standard Edition), with setting to disable
- Data Views: New collection data views system with API access, edition-gated visibility, and auto-creation of dataviews collection
- Load More System: Frontend pagination with
loadMorefor progressive content loading, including empty state handling, dataview support, and blog templates - Template Designer:
{% templatedesigner %}Twig tag for inline template definition with API endpoints, token-gated access, and companion.designer.jsonmetadata files - Collection Reports: New reporting API and admin utility for collection data with form integration, include/exclude/search support, select-all, and translations
- WordPress Import: Full WordPress import system with security validation (Standard Edition)
- RSS Import Utility: Import content from RSS feeds (Standard Edition)
- JSON Feed Support: Parse and import JSON feed format
- SVG Field: New dedicated SVG form field with editing, dark mode support, ID sanitization, and drag and drop
- Deck Table Field: New table-style display for deck items with horizontal scrolling, hidden field support, and visibility controls
- Setting Presets: Configurable default settings for any field with preset overrides, migrated all setting forms to TotalForm
- Pushover Notifications: Push notification support for form actions with image attachments and group messaging
- Localized Dashboard: Multi-language admin interface with Dutch, German, Spanish, and UK English translations
- L1 + L2 Cache Architecture: Two-tier caching system with APCu (L1) and filesystem/Redis/Memcached (L2), cache sizing advisor, and signal service for cron-based cache clearing
- Bulk Mailer: Mailer form builder, bulk send to specific objects, and user data access in mailer templates
- T1 Import Utility: Import data from Total CMS 1 installations
- Orphan Scanner Utility: New admin utility to find orphaned files and data
- CMS Grid Utility Classes: New
cms-gridCSS utility classes for content grid layouts - New Twig Functions:
next,prev,setSessionDatafunctions andcms.datamacros for image, gallery, file, and depot - Twig Adapter Namespacing: Improved Twig adapter organization with proper namespacing
- Gallery Sort Options: Configurable sort order for gallery collections
- Deck Min/Max Items: New
minandmaxitem count settings for deck fields - Field Visibility in Deck: Control field visibility within deck items
- Custom Login Form:
restrictPageAccessnow supports custom login form templates - Forgot Password Dropdown: Password reset template setting converted to a dropdown selector
- Autogen for Deck Items: Auto-generate values when creating deck items via API
- Gallery Launcher Filtering: Include/exclude/search support for the gallery launcher
- Export API Filtering: Include/exclude options added to the
/exportAPI - Deck Export/Import: Deck field support in the export and import APIs
- Cache Signal Service: Clear cache from cron jobs via signal files
- Auto Cache Clear on Update: Cache automatically clears when CMS is updated
- Clear Watermark Cache: New admin utility to clear watermark image cache
- Clear All Image Cache: Button to clear all processed image caches
- HTMX 4.0 Refactors: Job queue, passkey UI, test data view, quickaction buttons, and collection sidebar refactored to use HTMX for reduced JavaScript complexity
- Gallery Performance: Significant performance improvements for large galleries including optimized image processing and EXIF/color extraction toggle
- Index Builder Performance: Improved performance for building ID-only indexes
- Cache TTLs: Longer cache durations with cleanup of legacy caching logic
- Documentation: Major reorganization with keyboard navigation, search improvements, SEO enhancements, and collapsible nav groups
- Admin Navigation: Darker active nav styling, better details open/close logic, scroll position maintained in docs
- Admin Utilities: Organized into logical groups with improved icons
- ImageWorks EXIF Control: New setting to disable EXIF and color extraction for images and galleries (GDPR compliance)
- Property Meta Inheritance: Better property meta resolution with new
PropertyMetaResolverservice - Relational Options: View support and factory compatibility for relational option fields
- Styled Text: Default to no word count, fixed return of empty tags on no content
- Form Grid: Auto-add missing properties to form grid layout
- Logout: Now defaults to HTTP referer redirect,
cms.logoutsupports redirect URL - Gallery Sizing: Gallery image sizing now controllable via CSS variable
- Collection Search: Autofocus on collection search field on index pages
- Wildcard Filtering:
ObjectFilternow supports wildcard-based filtering - HTMLUtils: Centralized option and datalist generation with optimizations
- Vendor Files: Slimmed down distribution vendor files
- Dev Environment: Better watch script and dev environment prefix for CLI commands
- Centralized HTTP Client: Moved to central Guzzle client, replacing direct curl usage
- License Simulator: Pro Edition can now simulate any edition
- Collection totalObjects: Rebuild Index now updates totalObjects for the collection
- Security Fixes: CORS limited to specific routes, CSRF header fix, max download size protection, WordPress import security validation, caching security fix
- Deck Items: Fixed items not clickable, bad deck items ignored on save, validation fixes, fixed revert that broke deck
- Setup Wizard: Fixed first-time setup flow, hide passkey option during initial account setup
- Collection Table: Fixed delete object, layout fixes with autolink
- Form Fixes: Fixed code field scroll-to on new forms, password confirmPlaceholder, SMTP setting form labels, hidden fields in DeckTable
- Gallery Fixes: Fixed features gallery buttons, image height issues, proper error handling when gallery doesn't exist
- Cache Fixes: Cache advisor fix, improved filesystem cache clearing, L2 cache connection fixes, disabled cache in processJobs
- Sentry Fixes: JavaScript error fixes, proper domain default for processJobs
- Login/Auth: Fixed custom login URL, don't redirect to admin on logout
- Template Fixes: Fixed Twig syntax errors, macro documentation
- Depot: Fixed browser PDF embed, restored depot browser functionality
- Build Fixes: Fixed Inky email build, added translation JSONs to build, icon reference updates
- Log Permissions: Adjusted logfile permissions for better compatibility
- Relational Options: Graceful handling when data is not as expected
- Settings: Return blank string if setting doesn't exist
- Feed/Blog Forms: Fixed form configuration issues
- Depot Browser
reverseSort: New option to reverse the sort order of files and folders in the depot browser - Depot Browser
filterTags: New option to filter depot browser files by tags (OR logic, case-insensitive)
- Gallery Image Attributes: Gallery images now support
classandloadingattributes - Image Serving: Content-Length header now always reflects the actual file size on disk
- EXIF Reading: Fixed errors when reading EXIF data from non-JPEG/TIFF files (e.g., WebP, PNG)
- Date Filter INTL Fallback: Fixed
diffForHumansTwig filter crashing when the intl extension is missing - Object Setting Overrides: Fixed custom per-object property settings not being applied in forms
- Deck Duplicate IDs: Fixed duplicate element IDs when adding or duplicating deck items
- Form Field Processing: Fixed sub-fields being incorrectly skipped during form initialization
- Depot Long Filenames: Fixed long file names and comments overflowing in depot browser
- Depot Keyboard Navigation: Fixed keyboard navigation interfering when a depot dialog is open
- Image
loadingAttribute: Fixed missingloadingattribute on single image output
- Depot Browser: Full-featured file management UI with file preview, filtering, drag-and-drop uploads, keyboard navigation, folder renaming, and auto-saving file info
- Depot Drop Field: New form field for selecting files from depot with support for custom collection and property targeting
- Manual Sort: Define custom sort orders for collections via the
manualSortcollection setting with Twig filter support - Form Error Summaries: Form validation errors now display a summary for easier identification of issues
- Custom Form Status Banners: Configurable status banner messages for form success and error states
- Form Actions Completed Event: New
actions-completedcustom event dispatched on form element after all form actions finish - Log Download: Download log files directly from the log analyzer in admin utilities
- Mailer Duplicate: Duplicate existing mailer configurations from the admin interface
- Form Actions: Success banner now displays and waits before executing navigation actions
- Gallery Images:
data-galleryattributes are now always included on gallery images - Documentation Search: Improved search functionality in admin documentation
- Help Tooltips: Fixed positioning and display issues with help tooltips
- Sentry Error Filtering: Improved filtering of non-actionable errors including corrupted installations, unhandled promise rejections, and license timeout errors
- Keep Me Logged In: Fixed persistent login (Remember Me) not working correctly
- Login Redirect: Fixed redirect behavior after login
- Logout Redirect: Fixed redirect on logout
- Log Downloads: Fixed log file download functionality
- Formgrid Headers: Fixed layout breaking when header text contained more words than grid columns
- Manual Sort Save: Fixed saving empty manual sort configurations
- Page Access Groups: Fixed
restrictPageAccesswhen using only access groups - Password Reset Redirect: Fixed redirect query parameter for password reset flow
- Inherited Schema Unique: Fixed unique field feature for inherited schemas
- Sentry beforeSend: Fixed crash when
error.nameis undefined in the Sentry JS error filter - Import Error Messages: Fixed collection-not-found error messages not being filtered by Sentry
- Gallery Caption Templates: Gallery captions now support Twig templating for fully customizable caption rendering
- Lightbox Captions: New option to display captions within the lightbox viewer
cms.log()Twig Function: Custom logging directly from within Twig templateskeyByandsumCollection Filters: New Twig filters for grouping collections by key and summing numeric values- Field
hideSetting: New option to hide fields in the admin form while preserving their data - PHP API Documentation: Comprehensive reference for the
TotalCMSclass covering CLI automation scripts, all public methods, and a complete example script
- Collection Self-Healing: Better automatic recovery for corrupted or incomplete collection data
- JSON Array Properties: Improved handling of array property types during object creation
- Sentry JS Filtering: Better filtering of Froala editor errors and suppression of bad Twig function call errors
- Filesize Display: Now uses base-1000 bytes for more intuitive file size reporting
- Boolean Import: Now accepts "YES" as a truthy value during data import
- Blog Legacy Support: Media field moved to index for blog legacy compatibility
- Twig Logging: Enhanced logging throughout the Twig adapter for better debugging
- Original Image Serving: Fixed instances where the original image was not served correctly
- ImageWorks Format: Fixed image format option not working in ImageWorks presets
- ImageWorks Upscaling: Presets no longer scale images up beyond their original dimensions
- ImageWorks Default Width: Removed incorrect 600px default width that could affect image output
- Image Macro Builder: Fixed empty image options in the macro builder
- RSS Builder: Fixed bad date being passed to the RSS feed builder
- Job Queue Stats: Fixed job queue statistics display
- Empty Image Options: Fixed empty image options causing errors in macro builder
- Export Object to ZIP: New functionality to export individual objects to ZIP archives
- Twig
cms.objectCount()Function: New function to get the count of objects in a collection without loading all data - Offline License Support: License validation now works offline with cached license data
- Nginx No-Cache Header: Special
X-No-Cacheheader support for nginx reverse proxy configurations
- Admin Browser Titles: Standardized browser title format across admin pages
- Performance Improvements: Significant caching and performance optimizations for license validation and index building
- Job Queue Maintenance: Improved job queue handling and maintenance routines
- Mailer Collection: Automatically creates mailer collection if it does not exist
- INTL Extension Checks: Better handling and validation of PHP INTL extension availability
- Deck Requirements: Made deck require statements more generic for broader compatibility
- Defensive Error Handling: Added additional error checks throughout the codebase
- Styled Text Editor: Fixed JavaScript error when deleting images with data URLs (e.g., dragged-in SVG images)
- Code Field Mobile: Fixed code field hiding incorrectly on mobile devices
- Trial Expiration Workflow: Fixed issues with trial expiration handling
- Index Builder: Index builder now consistently reads from disk to ensure data accuracy
- Localization Support: Comprehensive internationalization for dates, numbers, currencies, and relative time strings
- New
cms.locale()andcms.getLocale()Twig functions - Support for 30+ languages including Arabic, Chinese, Japanese, Korean, and European languages
- Khmer (Cambodian) locale support
- New
- Deployment Documentation: New deployment guide with Git configuration, cache clearing instructions, and CI/CD examples
- Featured Image Indicator: Visual indicator for featured images in image fields
- Color Field Datalist: Color fields now support datalist for predefined color options
- Collection Sorting: Improved sort with better shuffle support and text-aware key sorting
- Schema Save: Automatically cleans up required and index properties on save
- CLI Cache Support: CacheManager can now be used in TotalCMS CLI scripts
- TotalCMS::clearCache(): Now returns detailed results array for programmatic use
- Diagnose Tool: Added pdo_sqlite extension check
- Sentry Error Filtering: Now ignores Collection not found errors and license rate limit errors
- Property Factory: Fixed handling of array types in property factory
- Canonical Redirects: Removed id URL parameter from redirect canonical URLs
- SVG Styles: Fixed SVG rendering styles
- INTL Extension: Graceful fallback when PHP INTL extension is not installed
- List Selection: Fixed click-to-select behavior in lists
- Deck Item Autogen: Deck item creation now supports autogen ID patterns from deck schemas
- Deck Item Validation: Deck items are now validated against their schema on create/update (same as objects)
- Twig currentUrl Property: New
cms.currentUrlproperty for getting the current request URI in templates
- RSS Feed Library: Migrated from mibe/feedwriter to laminas/laminas-feed for PHP 8.4 compatibility (fixes deprecation warnings)
- API Error Status Codes: Fixed multiple API actions returning 200 status on errors instead of proper error codes (400/404/500)
- Form Error Display: Fixed error messages not displaying in status banner when API returns string errors
- Deck Item Forms: Fixed addOnly deck item forms to properly skip ID field when autogen is configured
- Deck Item Defaults: Fixed schema default values not being applied to new deck items
- Date Field Defaults: Fixed date fields with default value "now" not being applied when value is empty
- Sentry Error Filtering: Added file upload errors and missing PHP extension errors to ignore list
- Diagnose Tool: New support diagnostic tool to help troubleshoot installations on servers
- Dashboard Dev Mode Toggle: Quick toggle for development mode directly from dashboard
- Property Field Documentation Links: Direct links to documentation from property field dialogs
- Object Form Navigation: Cmd+click (Ctrl+click on Windows) to open object forms in new tab
- Edit Object Action: New edit action for object management in the collection table
- Recurring Date Filters: New
recurringMonthDateTwig filters for recurring event handling - Automation Services: Exposed additional services in TotalCMS for automations:
- Mailer service for sending emails
- Logger service for custom logging
- Deck item saver for deck operations
- Property incrementer for numeric property operations
- Property Options Categories: Extended
propertyOptionsto support Collection and Schema categories
- HEIC Image Conversion: Now uses PHP ImageMagick extension instead of shell commands for improved reliability and compatibility
- Mobile Form Layouts: Better responsive layouts for form grids on mobile devices
- Form Header Responsiveness: Improved form header behavior on smaller screens
- Job Queue Statistics: Enhanced JavaScript for better job stats display
- Sentry Error Filtering: Updated ignore rules to reduce noise from user-caused errors
- Deck Item Forms: Resolved issues with deck item form handling
- Deck Property Conflicts: Fixed conflict when deck schema has same property name as parent schema
- Form Layout Issues: Various fixes for form layout rendering
- Collection Form Styling: Fixed collection form styles and labels
- Temporary Files: Moved away from
tmpfile()for better server compatibility
- Dashboard: Temporarily removed recent activity section
- Test Suite: Improved test coverage with new unit tests
- Logout Class Handler: Elements with
.cms-logoutclass now trigger logout redirect via API
- Twig Download Functions:
cms.download()andcms.stream()now accept full object arrays in addition to IDs - Schema Property Inheritance: Inherited schema properties can now be overridden in child schema forms
- Sentry Error Filtering: Improved filtering of user-caused errors to reduce noise in error tracking
- API Request Handling: Better error handling for undefined fetch responses in JavaScript
- Firefox Drag and Drop: Fixed gallery image reordering not working in Firefox
- Added SortableJS fallback mode for Firefox compatibility
- Fixed MutationObserver interference during drag operations
- Fixed order not saving after multiple drag operations
- Firefox Save Animation: Fixed success/error checkmark icon spinning instead of scaling in Firefox
- Clipboard on HTTP: Added fallback for clipboard copy functionality on non-HTTPS sites
- Properties Field: Fixed TypeError when getting values from uninitialized property fields
- User Profile Permissions: Users can now always update their own profile regardless of access group
- Profile Form: Fixed profile form submission issues
- Access Group Defaults: Fixed default access group assignment for users without explicit groups
- Project Setup: Fixed project setup utility issues
- Mailer Settings: Fixed type casting for SMTP port and timeout settings
- PHP Namespace: Fixed namespace declaration for global helper functions
- Encryption Algorithm: Updated cipher algorithm for improved security
- Twig Debugger Utility: New admin utility at
/admin/utils/twig-debuggerfor checking Twig syntax errors- Shows error line number with surrounding context
- Supports direct linking via
?filepath=/path/to/filequery parameter - Twig error pages now include a link to debug the file directly
- Auth Collection Auto-Creation: First admin login automatically creates the auth collection if it doesn't exist
- ObjectUrlBuilder: New URL template system for collections supporting Twig-like syntax
- Template URLs like
/campsites/{{ region }}/{{ county | lower }}/{{ id }} - Supports filters:
slug,lower,upper,trim,raw - Auto-appends
{{ id }}if not present in template - Admin UI shows URL template fields used and warnings for empty segments
- Template URLs like
- Canonical URL Twig Functions: New functions for generating absolute URLs
cms.canonicalObjectUrl(collection, object)- absolute URL for an objectcms.objectUrl(collection, object)- now supports full object array for templated URLscms.objectUrlHasEmptySegments(collection, object)- check for missing template datacms.collectionUrlFields(collection)- get fields used in URL template
- unique Twig Filter: New filter to remove duplicate values from arrays
- Documentation: New guides for Form Grid Layout and Object Linking
- Sitemap & RSS Feeds: Now use ObjectUrlBuilder for templated URL support
- Twig Date Handling: Date filters now default to the timezone configured in settings
- Collection Table Performance: Improved loading performance for collection tables
- Index Builder Memory: More memory-efficient index building for large collections
- Job Queue Processing: Improved verbose output, memory management, and in-progress job handling
- Import ID Normalization: IDs are now normalized during import for consistency
- Warning Styles: Standardized warning message styling across admin interface
- PHP 8.5 Compatibility: Fixed
imagedestroy()andcurldeprecation warnings - Collection Object Count: Fixed
totalObjectscount display in collections - Collection Performance Warning: Fixed performance warning appearing incorrectly
- Pretty URLs Redirect: Skip
redirectToCanonicalUrlwhen pretty URLs are disabled - DNS Warning in Preview: Fixed DNS verification warning appearing in preview mode
- New Install Caching: Fixed caching and cleanup issues during new installation setup
- Reserved Schema Collections: Disabled automatic creation of reserved schema collections on startup
- Import Performance Optimization: Job queue automatically enables
queueRebuildOnSaveduring import/update/factory jobs- Index rebuilds only once per collection after all jobs complete instead of after each object
- Significantly improves bulk import performance
- Deck Schema Select: Schema options in deck field dropdown are now sorted alphabetically
- Deck Autogen ID: Fixed identifier autogeneration not working in deck items
- Autogen like
${title}-${now}now correctly updates when title field changes - Lock condition now checks for existing saved data instead of any value
- Autogen like
- Deck Required Field Validation: Required fields in deck schemas now properly validate
- JavaScript validation calls
validate()on each field inside deck items - PHP properly passes
requiredattribute to deck fields from schema definition
- JavaScript validation calls
- Froala in Deck: Fixed duplicate Froala editors appearing in styled text and SVG fields inside decks
- Preview License Validation: Disabled license API calls in preview environment to prevent rate limiting
- Cached License Compatibility: Fixed "property must not be accessed before initialization" errors when license cache contains old data format
- Documentation Syntax Highlighting: Code blocks in documentation now have syntax highlighting using highlight.js
- Supports Twig, JSON, JavaScript, Bash, HTML, PHP, CSS, and Apache configs
- Copy-to-clipboard button appears on hover for all code blocks
- Light/dark theme support via
prefers-color-scheme
- DNS Verification Status: License status icon shows warning when domain DNS is not verified
- Standard Edition Whitelabel Templates: Select whitelabel templates now available in Standard edition
login-above,forgot-password-above,reset-password-above,download-auth-above,admin-welcome- Form options templates for customizing form labels (login, forgot-password, reset-password, download-auth)
- markdownInline Filter: New Twig filter for inline markdown processing without wrapper tags
- Persistent Login: Complete overhaul of "Keep Me Logged In" functionality
- Safe token rotation prevents login loss on cookie failures
- Direct cookie checking independent of PHP session garbage collection
- Comprehensive logging for debugging persistent login issues
- Whitelabel Documentation: Updated with JSON template approach for form label customization
- REST API Documentation: Cleaned up to reflect actual available routes
- Twig Filters:
sortCollectionandfilterCollectionnow accept null values gracefully
- Edition-Based Feature Limiting: Comprehensive edition-level access control system
- Middleware enforcement for templates, mailers, API keys, and access groups
- Service-level edition checks throughout the application
- Twig-level edition checks for template-based restrictions
- Custom collection visibility based on edition
- Whitelabel support for Standard edition
- Edition simulation for testing different access levels
- prefixSlug Twig Filter: New filter to add prefixes to URL slugs
- File Extension Property:
file.extproperty now available for depot files - Watermark Cleanup Service: New service to manage watermark file cleanup
- Depot Field: Disable add-folder button on new object forms until object is saved
- Admin Sidebar: Hide empty sidebar groups when filtering
- Form Actions: Edition-based limits on form actions
- Export Logging: Improved logging for export operations
- redirectIfNotFound: More flexible redirect support
- Toggle Field: No longer required in schema definitions
- Auth Active Field: Changed to toggle field type
- Default Collections: Allow blank saves in default collections
- License Status Icon: Only shown to admin users
- Dashboard: Moved help content to documentation; fixed whitelabel display
- Depot on New Objects: Fixed depot field not working correctly when creating new objects
- Gallery Macros: Fixed
firstandlastgallery macros - Number Fields with Autogen: Fixed autogeneration for number fields and fields with question marks
- depotDownload Macros: Fixed issues with depot download macros
- CSV Deprecations: Fixed PHP deprecation warnings in CSV handling
- Profile Picture: Fixed alignment when licensed
- Edition Simulation: Fixed simulation mode in settings
- Schema/Collection Access: Fixed access denied handling for schemas and collections by edition
- Emergency License Cache Clear: New
/emergency/cache/clear-licenseendpoint for clearing license cache during debugging - Frontend Cache Control:
noCacheIfAuthenticated()method in TotalCMS PHP API to disable browser caching for logged-in users on custom pages - Admin Keyboard Shortcuts: Cmd+P (or Ctrl+P) shortcut to preview objects in admin interface
- Featured-Only Gallery Display: New
featuredOnlyoption forcms.gallery()- Grid displays only featured images
- Lightbox shows all images from gallery
- Clicking featured image opens lightbox at correct position
- Gallery Index:
data-gallery-indexattribute now uses 1-based indexing for better user experience - Admin Caching: No-cache headers automatically added to all admin routes to prevent stale content
- Featured Toggle: Featured button icon now updates immediately when clicked without requiring unhover/rehover
- Gallery Numeric Index: Access gallery images by numeric index (1-based)
cms.galleryImage(gallery, 1)returns the first imagecms.galleryImage(gallery, 3)returns the third image- Works with
galleryPath(),galleryAlt(), andgalleryImageData()
- Unique Property Support: Schema properties can now enforce uniqueness across objects
- SMTP Tester: New utility to test SMTP email configuration
- Deck Item Labels: Custom labels for deck field items with
deckItemLabelsetting - Preview Action: Object preview action in admin interface
- Performance Improvements:
- Major image processing performance optimizations
- Request-level memoization for collection and object fetching
- Reduced response times from ~2000ms to ~340ms in some cases
- License Caching: Improved resilience during license server outages
- Separated cache refresh interval (24h) from storage TTL (7d)
- License data preserved when clearing all caches
- Asset Caching: Better
/assetsendpoint caching - Image Caching: Improved image cache headers with robots indexing support
- Form System:
- Schema field settings now merge with Twig macro settings
- Better property defaults when not set in request
- Less strict field change event handling
- Schema descriptions no longer required
- Default to
equaloperator forfilterCollection()
- Password Reset: User information included in password reset emails
- Image Alt Text: Improved automatic alt text generation
- Focal Point Cropping: Better crop focal point for blog post related images
- Required Validation: Enhanced validation for image, file, and gallery fields
- Relational Options: Can set to
falseto disable; validates array type - Data Organization: Moved
.bundleand job queue totcms-datadirectory
- Setup Flow: Fixed login redirect to setup on first load
- Preview Environment: Skip setup check when in preview environment
- License Validation: Better handling when license server is unavailable
- Deck Fields:
- Fixed deck ID setting form conflicts
- Fixed form ID conflicts with deck items
- Auth Settings: Fixed settings being saved as strings instead of proper types
- Empty Settings: Fixed saving empty settings values
- Single Field Forms: Fixed ID field showing when no object exists
- Access Controls: Fixed access controls for non-default auth collections
- Required Fields: Fixed empty indexes when new required field is added
- Checkbox/Toggle: Fixed not saving when value is false
- Custom Emails: Fixed user name display in custom emails
- Log Content: Fixed log content ordering
- Custom Path Setup: Fixed custom path configuration in setup
- Preview Admin Embed: Fixed admin embed in preview mode
- imageFromData: Removed deprecated
imageFromDataTwig function
- Blog Post Layout Template: Complete ready-to-use blog post template (
layouts/blog-post.twig)- Flexible macro-based template with extensive customization options
- Related posts feature with smart tag/category matching and scoring algorithm
- Support for compact mode (image + title) or detailed mode (full content)
- Dynamic filtering using
filterCollection()for optimal performance - Localization options for customizable text strings
- Hero image with featured badge support
- Summary, content, gallery, extra content sections
- Categories and tags with optional links
- Media embed support
- Last updated footer with customizable text
- Feed Layout Template: Clean template for news feeds and updates (
layouts/feed.twig) - Grid Templates: New compact blog grid template (
grid/blog-compact.twig) - Gallery Features: New
galleryDynamic()andgalleryLauncher()Twig functions - ImageWorks Enhancements:
- Multiline text watermark support
- Smart text mark scaling for better text rendering
- Barcode generation improvements
- QR code and embed improvements
- Collection Management:
- Default code collection for storing code snippets
- New setting to keep ID when duplicating objects
- Duplicate/clone object action
- Sort collections by name option
- Admin Interface:
- Admin welcome template for new user onboarding
- Sentry dashboard integration
- Gallery view all styles
- Cache Performance: Optimized cache TTL values for better Redis performance
- Reserved schemas: 1h → 24h (2300% increase)
- Object data: 1h → 4h
- Collections list: 15m → 1h
- Custom schemas: 2h → 4h
- Improved cache hit rates from ~32% to 60-75%
- Object Duplication: Improved duplicate/clone logic across schemas and collections
- Enhanced
ObjectClonerwith automaticonCreate/onUpdatedate handling - Duplicate action renamed to "clone" for clarity
- Enhanced
- Collection Operations:
- Collection save efficiency improvements
- Collections now sorted alphabetically by name
- Enhanced word boundary checks for better searching
- User Experience:
- Improved new user setup workflow
- Better droplet error handling and reporting
- No save warning in playground mode
- Hide ID field when using
addOnlywith autogen
- Dark Mode: Fixed dark mode styling issues
- Schema icons now properly styled in dark mode
- Styled text field dark mode support
- Form System:
- Gallery sizing improvements
- Better error logging for field validation
- Login form button styling matches other forms
- Security:
- Default to no public access for new collections
- Better license validation error handling
- Deck Fields: Multiple fixes for deck field handling
- Fixed default values not appearing in deck fields
- Fixed property settings (min, max, pattern) not making it into deck field settings
- Fixed empty deck handling and validation
- Schema now supports empty array or object with proper validation
- Form Fields:
- Fixed default values overruling falsey actual values (0, false, etc.)
- Fixed boolean default value handling
- Fixed autogen ID save functionality
- Fixed depot folder name input validation (now required)
- Clear value for image and file fields when deleted
- Admin Interface:
- Fixed recent collections display
- Fixed simple form buttons styling
- Fixed settings form saving
- Fixed simple form validation error display
- Fixed gallery launcher functionality
- API & Data:
- Fixed backwards compatibility with
totalObjectsin Collections - Fixed gallery sizing issue
- Fixed backwards compatibility with
- Testing: Multiple test fixes and improvements for CI/CD pipeline
- Collection Filtering: Comprehensive new filter system with 14 filter types
- Numeric Range:
between- Check if number is between min and max (inclusive) - Calendar Periods:
thisWeek,thisMonth,thisYear- Filter by current time periods - Text Length:
longerThan,shorterThan- Filter by text character count - Array Counting:
hasMin,hasMax,hasCount- Filter by array item counts - Day of Week:
isWeekday,isWeekend,dayOfWeek- Filter by day of week - Relative Dates:
todayPlusDays,todayMinusDays- Filter by dates relative to today
- Numeric Range:
- Collection Metadata: Enhanced collection statistics and tracking
totalObjectsproperty automatically calculated on collection savelastUpdatedtimestamp for tracking collection modifications- Dashboard now displays recent collections based on activity
- Versioning: New
cms.versionTwig variable for version information- Can be used as asset cache buster for automatic cache invalidation
- Collection Form Settings: Enhanced form configuration options for collections
- Configure help styles of forms
- Add new/edit/delete actions to forms
- Dashboard Improvements: Better user experience and data visualization
- Fixed dashboard statistics display with accurate counts
- Added recent collections section showing recently modified collections
- Fixed add button functionality
- Improved cache information display
- Fixed grid colors for better visual consistency
- Data Directory Configuration: Improved default tcms-data directory logic
- Better automatic detection and configuration
- Enhanced path resolution for various deployment scenarios
- Authentication: More flexible page acess control
- If no collection is defined for restricting access, then it will only verify the user is valid.
- Authentication: Keep me signed in functionality improvements
- Multiple iterations and fixes for persistent login reliability
- Better session management and cookie handling
- Fixed login for custom auth collections
- UI Components: Various interface and display fixes
- Fixed details content overflow issues
- Fixed details component inside ImageWorks builder
- Improved buffer controller handling
- Form & Field Issues: Better form handling and validation
- Fixed ID field comma removal for cleaner identifiers
- Fixed schema import 404 errors
- 404 error when trying to load an object that does not exist
- Cache System: Settings and cache management fixes
- Fixed cache settings save bug that could cause configuration issues
- Improved cache information reporting
- Template Management System: Complete admin interface for managing Twig templates
- Full CRUD operations (create, read, update, delete) for templates
- Support for nested template folders with recursive display
- Template editing with syntax highlighting
- Moved template API to JSON formatting for consistency
- ID field now supports
allowCharactersetting for custom character restrictions
- Access Control System: Comprehensive permission management
- Access groups with granular permissions for collections, schemas, and templates
- Public/private collection access controls
- Collection metadata access controls
- Access control middleware refactoring for better security
accessGroupOptionsfield setting for restricting options by access groupprotectedByCollectionsetting for file and depot fields- Admin-only access to access groups and API keys management
- Settings Architecture: Settings now save to tcms-data for better portability
- Settings refactored to store in tcms-data instead of config files
- Settings form completely redesigned with improved UX
- Locale setting added for internationalization support
- Accent color customization in admin interface
- Fixed Sentry integration enable/disable
- Schema Inheritance: Schemas can now inherit properties from parent schemas
- Inheritance system for schema definitions
- Improved inherited property handling
- Collection schemas no longer allow clearValue to prevent accidental deletion
- API Key Management: Generate and manage API keys with permissions
- API key generation and storage in
.system/apikeys.json - API key admin interface with list and creation forms
- x-api-key header support for API authentication
- Multicheckbox field for permission selection
- Copy to clipboard functionality for API keys
- API key middleware for request validation
- API key generation and storage in
- Password Reset Workflow: Complete forgot password implementation
- Forgot password form with email verification
- Password reset email templates
- Password reset workflow with secure tokens
- Processing animations for better UX
- Mailer Configuration: SMTP settings and email testing
- Mailer/SMTP configuration UI in admin
- Email tester for validating SMTP settings
- Form mailer action for sending form submissions via email
- Mailer forms with improved error handling
- Dark Mode: Theme switcher for admin interface
- Complete dark mode theme implementation
- Dashboard theme switcher
- Dark mode styles for all admin components
- Playground dark mode support
- Image rendering improvements in dark mode
- List and form styling fixes for dark mode
- Login Form Macro: Reusable login form component
cms.form.loginForm()macro for custom login pages- Session-based redirect on login errors
- Support for custom auth collections
- Flash message integration
- Configurable submit label and forgot password link
- Deck Form: New deck field type for card-based layouts
- Initial deck form implementation
- Deck items automatically sorted after creation
- Deck form documentation
- Admin Interface Improvements: Better UX and mobile support
- Mobile-responsive admin interface with improved navigation
- Homepage dashboard with quick actions and collection overview
- Collapsible sidebar groups (default to open)
- Better form error display and handling
- Dialog and detail style improvements
- Gallery drag-and-drop improvements
- New sortable class for improved drag behavior
- Whitelabel Support: Customize Total CMS branding
- Support for custom admin pages
- Custom admin logo upload
- Whitelabel templates for login and error pages
- Custom templates in
whitelabel/directory
- JumpStart Enhancements: Improved data import/export
- Streaming export for memory efficiency with large datasets
- Templates included in JumpStart data
- Image Support: HEIC image upload and processing
- HEIC format support for modern Apple devices
- Automatic conversion and processing
- Twig Filters & Functions: Enhanced template capabilities
markdownInlinefilter for inline markdown renderingdownloadandstreammacro fixes for custom collections
- Property Increment/Decrement API: Utility endpoints for numeric properties
- POST
/collections/{collection}/{id}/{property}/increment[/{amount}] - POST
/collections/{collection}/{id}/{property}/decrement[/{amount}] - Respects min/max schema settings
- Default increment/decrement amount is 1
- POST
- Data Types: New field types for advanced data structures
- Code field type with syntax highlighting
- Array field type for structured data
- IndexFilter Service: Advanced filtering for collections
- Include/exclude options for index fetching
- Array support for filtering
- Filters for relational options
- IndexFilter limits for RSS feeds
- Forms & Validation: Improved form handling
- Simple form submit issues resolved
- Form error display improvements
- Form action array support for multiple actions
- Delete form error handling
- Save action fixes
- SVG field saves properly when in code view
- Profile image removal when not set up
- Admin Interface: UI and navigation fixes
- Dashboard links now relative to /admin
- Admin utils pages accessibility fixed
- Fixed /admin 404 routes
- CodeMirror bracket matching color in dark mode
- Code view sizing improvements
- Code autoclose fixes
- HTML syntax highlighting improvements
- Twig syntax highlighting inherits from HTML
- Security & Authentication: Enhanced security
- CSRF token fixes in preview mode
- Middleware organization improved
- isAdmin fix for auth disabled mode
- Better API route checking
- Data Handling: Object and property fixes
- Template schema fixes
- Duplicate schema handling
- Settings schema fetcher fixes
- Installation settings form fixes
- Performance & Optimization: Better resource handling
- Emergency cache clear debug output improvements
- UI icon cleanup and optimization
- Better accordion animations
- htaccess improvements to prevent redirect loops
- Auto-creation of .htaccess in tcms-data for security
- Increased download max attempts setting
- Build & Development: Developer experience improvements
- Sample nginx configuration included
- Parsedown dependency patch
- Various test suite improvements
- API Settings: API URL now dynamically set
- API setting no longer in settings form (automatically configured)
- Removed non-GET requests from collection meta API
- Sitemap Builder: Filter option renamed
- Changed from filter to include for clarity
- Download Attempts: Increased default max download attempts
- Image Settings: Turned off image max height restriction
- License System: Streamlined license validation and display
- Simplified LicenseData structure reduced from 15+ to 8 essential fields
- Consistent camelCase throughout API responses and JWT tokens
- JWT validation moved to dedicated LicenseValidator service
- License status icon in sidebar with progressive trial urgency indicators
- Domain-specific license caching for multi-site deployments
- CLI and auth routes bypass license validation for better developer experience
- Form Fields: Enhanced select and list field functionality
- Select fields now include clear button (×) that appears when value is selected
- Clear button can be disabled with
clearValue: falsesetting - Radio fields support
sortOptionsfor alphabetical sorting - Fixed list field asString + required validation
- Fixed list field data ordering with relational options
- Schema select fields properly disable clear button to prevent accidental deletion
- Session & Cache Management: Improved isolation and security
- Fixed session and cache leakage between domains
- Fixed cookie leak between domains
- Better session save path handling for cPanel servers
- Cache license data stored outside devmode restrictions
- Deep merge support for configuration arrays (with revert and refinement)
- Logging & Debugging: Replaced error_log with structured logging
- All error_log calls replaced with PSR LoggerInterface
- IndexBuilder now logs failed object loads instead of failing silently
- CacheManager, TextWatermarkFactory, ImageGenerator use LoggerFactory
- DeckCompatibilityChecker optional LoggerFactory integration
- Admin Interface: UI and UX improvements
- New Total CMS logo in dashboard
- License status icon size adjustments
- Object count moved to collection header with better positioning
- Performance warning for queue processing on save
- Dashboard button no-wrap improvements
- Server checker includes license information
- Cache manager page performance optimizations
- Sitemap Builder: Filter and exclude capabilities
- New documentation for sitemap filtering (
sitemap-filtering.md) - Enhanced sitemap generation with filter options
- New documentation for sitemap filtering (
- Factory & Testing: Job queue integration
- Factory data generation uses job queue for better performance
- Factory form improvements with better queue integration
- Autogen Enhancements: Special character handling
- Improved autogen to handle special characters properly
- Fixed autogen only replacing first dot occurrence
- Authentication: Login and session improvements
- Keep me signed in refactor for better reliability
- User download logging
- Fixed session tmp dir issues
- Better session path handling for problematic servers
- Data Integrity: Object and property handling
- Fixed getvalue for list to preserve item order
- Fixed color import issues
- Duplicate objects now properly increment counters
- Fixed list data ordering with relational options
- Configuration: Bundle and settings improvements
- Added config validation to bundle check
- Fixed setting hijack in test environment
- Improved embedded store handling
- Testing: Test suite fixes
- Multiple test fixes for improved reliability
- License validation test coverage
- Session and authentication test improvements
- Configuration System: Deep merge arrays support (experimental, reverted, then refined)
- Attempted deep merge for user configuration overrides
- Reverted due to complexity concerns
- Settings system remains with traditional override pattern
- Admin Interface Performance: Major AdminTable optimizations for large datasets
- Event delegation reduces memory usage from hundreds to just 2 event listeners per table
- Added grid initialization guards to prevent multiple executions
- Dynamic throttling based on dataset size (rowCount/4, max 2000ms, no throttle <400 rows)
- Event-driven pagination fixes using GridJS state transitions
- Schema Property Management: Improved sortable behavior in schema forms
- Fixed drag-and-drop interference with text selection in Firefox, Chrome, and Safari
- Long-press detection prevents accidental dialog opening after drag operations
- Cross-browser compatibility with
forceFallback: truefor consistent drag behavior
- Cache Management: Renamed and improved cache interface
- "Cache Cleaner" renamed to "Cache Manager" throughout admin interface
- Updated navigation, templates, and documentation references
- Better reflects comprehensive cache management capabilities
- Browser Compatibility: Fixed text selection issues in dialogs across all major browsers
- Resolved SortableJS interference with form inputs in schema property dialogs
- Implemented browser-specific workarounds for consistent drag-and-drop behavior
- Long-press detection prevents unintended dialog triggers after dragging
- AdminTable Performance: Eliminated performance bottlenecks in large data grids
- Fixed multiple grid initialization causing hundreds of redundant event listeners
- Resolved pagination breaking issue with large datasets through event-based re-rendering
- GridJS state management improvements for reliable initialization timing
- Authentication & Session Management: Enhanced login system reliability
- Improved session handling and access control
- Better redirect parameter support for login flows
- Enhanced super admin access capabilities across auth collections
- Fixed status banner animation issues
- Form Enhancement: New "addonly" form mode for restricted editing scenarios
- ImageWorks: Fixed border handling issues in image processing
- Testing: Expanded test coverage for login, session, and authentication workflows
- NEW: Radio field type with enhanced grid display support
- Comprehensive radio field implementation with JavaScript integration
- Grid-specific radio field rendering and styling
- Complete documentation for radio field configuration
- NEW: Price field type for e-commerce and pricing data
- Dedicated price field with currency support
- New currency icons and formatting options
- Enhanced documentation for price field usage
- NEW: Auto-generated ID service for objects
autogensetting for automatic ID generation on object creation- Object creation counters for collections with unique ID generation
- Better handling of ID fields in deck systems
- Testing & Code Quality: Comprehensive test suite improvements
- Extensive test coverage for authentication, properties, ImageWorks, and Twig systems
- PHPStan Level 8 compliance improvements throughout codebase
- Rector-based code modernization and cleanup
- Enhanced CI/CD pipeline with improved test reliability
- Form System: Major improvements to form handling and validation
- Fixed schema default values not populating in new object forms
- Enhanced multi-file upload reliability with improved state management
- Better form state handling for file upload processes
- Improved droplet count logic and queue processing
- Cache System: APCu integration as primary cache backend
- APCu cache service with zero-configuration setup
- Optimized cache priority for single-server deployments
- Enhanced cache management with detailed statistics
- Better error handling and cache clearing mechanisms
- Image Processing: Enhanced EXIF metadata extraction
- Native PHP EXIF implementation for PHP 8.4 compatibility
- Improved camera info and location data extraction
- Better image metadata processing with automatic alt text population
- Browser Compatibility: Safari dialog text selection issues
- Fixed SortableJS interference with text selection in dialogs
- Added proper drag handles to prevent unwanted drag behavior
- Improved dialog interaction and form field accessibility
- File Uploads: Multi-file upload reliability improvements
- Fixed gallery uploads stopping after first file
- Enhanced Dropzone event handling from "success" to "queuecomplete"
- Better parallel upload handling with data integrity protection
- CI/CD: GitHub Actions test environment fixes
- Resolved session permission errors in CI environment
- Fixed readonly class property initialization issues
- Improved test environment compatibility
- Code Quality: PHPCBF and PHPCS configuration alignment
- Separate PHPCBF configuration to prevent spacing conflicts
- Better code formatting consistency across development environments
- Enhanced development workflow with proper linting rules
- Color System: Migration to enhanced Couleur library fork
- Custom fork with improved OKLCH hue wraparound calculations
- Better color manipulation and hex conversion reliability
- Enhanced color data processing with proper mathematical operations
- Development Workflow: Improved build and publishing processes
- Reduced publishing footprint for better deployment efficiency
- Enhanced bundle creation and asset management
- Better development mode handling and cache management
- Enhanced test coverage across core systems with focus on reliability
- Rector-based code modernization improving PHP 8+ compatibility
- Comprehensive CI/CD improvements for better development workflow
- Enhanced debugging and error handling throughout the system
- Gallery System: Added
classoption tocms.gallery()function for custom CSS classes- Allows adding custom classes to the gallery wrapper while preserving the default
cms-galleryclass - Supports multiple classes via space-separated string (e.g.,
class: 'featured-gallery large-gallery') - Works seamlessly with all existing gallery options (captions, maxVisible, etc.)
- Allows adding custom classes to the gallery wrapper while preserving the default
- Deck System: Improved default value handling for deck items
- Fixed default values not being applied when creating new deck items
- Enhanced
DeckItemform rendering to properly pass schema defaults to form fields - Better integration between deck schemas and form field default value system
- Data Validation: Strengthened deck schema compatibility checking
- Added 'deck' type to incompatible property types to prevent nested deck structures
- Enhanced PropertyFactory validation with clear error messages for incompatible deck properties
- Better error handling when deck schemas contain unsupported field types
- Forms: Resolved form error display issues
- Fixed form errors not displaying properly in certain scenarios
- Improved error feedback for better user experience
- Imports: Enhanced Alloy CMS import functionality
- Improved blog content import with better content processing
- Enhanced styled text handling during import operations
- Browser Compatibility: Fixed HTML datetime input format issues
- Resolved "value does not conform to required format" console warnings for date fields
- Added proper format parameter to
DateData::cleanDate()method for HTML form compatibility - Updated
DateFieldandDatetimeFieldclasses to use browser-compatible formats
- Documentation: Fixed broken documentation links
- NEW: Deck field system - powerful structured object management
- Full CRUD operations with dedicated UI for deck items
- Advanced ID synchronization between deck items and dialog fields
- Support for numeric IDs (e.g., "1", "123") alongside traditional identifiers
- Real-time validation with comprehensive error handling
- JavaScript integration with sorting, duplication, and validation
- Schema compatibility checking with built-in warnings
- NEW: Alloy CMS import system for seamless migration
- Complete import functionality from Alloy CMS platforms
- Pre-import data analysis to identify compatible content structures
- Background job queue processing for large imports
- Streamlined admin interface for managing import operations
- NEW: Enhanced gallery system with semantic HTML5
- All galleries now use proper
<figure>and<figcaption>elements - Optional image captions below thumbnails via
captionsoption - Better accessibility with semantic HTML structure
- Enhanced LightGallery integration with proper data attributes
- All galleries now use proper
- Forms: Modern layout improvements
- New
useFormGridoption for contemporary form layouts - Multi-field label support in relational options with configurable separators
- Enhanced inline form fields with improved styling
- Better field validation with real-time feedback
- New
- Development Experience: Improved developer tools
- Enhanced development mode with intelligent cache management
- Fixed Twig playground HTML code view scrolling issues
- Better error display and debugging capabilities
- Comprehensive schema categorization system
- API: New utility methods and endpoints
- Enhanced file upload capabilities including URL-based uploads
- Complete deck management API with CRUD operations
- Improved utility methods for common development tasks
- Better error handling across all endpoints
- Gallery & Media: Resolved display and functionality issues
- Fixed LightGallery
data-srcattribute placement for proper lightbox operation - Resolved maxVisible feature compatibility with new semantic HTML structure
- Enhanced "View All" indicator placement within figure elements
- Improved gallery item structure consistency
- Fixed LightGallery
- Deck System: Comprehensive validation and UI fixes
- Fixed numeric ID validation to allow flexible naming patterns
- Resolved deck item ID synchronization issues with autogen fields
- Fixed deck validation regex to properly handle mixed patterns
- Enhanced deck item duplication and deletion workflows
- Form & Field Operations: Various field-specific improvements
- Resolved tag field drag-and-drop functionality
- Fixed form submission issues in import workflows
- Better field synchronization across complex forms
- Improved error handling in form validation
- BREAKING: Gallery HTML structure now always uses
<figure>elements- May require CSS updates for custom gallery styling
- Improved semantic structure benefits accessibility and SEO
- Deck Validation: More permissive numeric ID validation
- Now allows mixed patterns like "123feature" for greater flexibility
- Maintains backward compatibility while expanding naming options
- Performance: Enhanced cache management
- Better development mode detection and cache handling
- Improved memory management for large datasets
- Optimized collection processing and filtering
- Updated CLAUDE.md with comprehensive deck system documentation
- Enhanced import system guides with step-by-step migration instructions
- Improved API reference documentation with new endpoints
- Added practical examples for deck usage and gallery integration
- NEW: Text watermarking system with custom font support
- Support for TTF and OTF font files from depot storage
- Configurable
watermarkFontsDepotsetting (default: 'watermark-fonts') - Text size, color, background, padding, and rotation angle support
- Automatic caching for improved performance
- NEW: Enhanced object cloning functionality
- Objects with
onCreatedate fields now get current timestamp when cloned - Objects with
onUpdatedate fields now get current timestamp when cloned - Automatic property processing for date field management
- Objects with
- NEW: Multi-field relational options documentation
- Support for combining multiple fields in
relationalOptionslabels - Configurable join separators for field combinations
- Enhanced field-settings.md with comprehensive examples
- Support for combining multiple fields in
- NEW: File streaming API enhancements
- Password protection support for streamed files
- Enhanced download and stream endpoints with better error handling
- Improved file access controls and security
- ImageWorks: Complete text watermarking integration
- Centralized Roboto font management in
resources/fonts/ - Custom font loading from depot with fallback to default font
- Improved watermark cache management and clearing
- Better text positioning and angle handling
- Centralized Roboto font management in
- Color System: Fixed OKLCH color manipulation
- Proper hue wraparound (360° cycling) for color adjustments
- Fixed hex color conversion issues with ColorFactory library
- Enhanced color math operations for design system variables
- Forms: Improved select options flexibility
- Better depot file handling in select dropdowns
- Enhanced form field rendering with updated icons
- Documentation: Comprehensive ImageWorks parameter documentation
- Complete marktext options reference in twig-totalcms.md
- Organized parameters into logical sections (Basic, Effects, Watermarks)
- Practical examples for text watermark usage
- Object cloning now properly resets creation and update timestamps
- Text watermark font loading from depot with proper path structure
- Cache API now correctly clears watermark cache files
- Color hue calculations now properly wrap around 360° boundary
- PHPStan compliance improvements for color data processing
- Form field icon references updated (removed icon-url, added icon-font and icon-angle)
- SelectOptions template calls with proper parameter handling
- CMS depot functionality restored with proper adapter calls
- Moved FakerImageGD.ttf to resources/fonts/RobotoRegular.ttf for centralized font management
- Enhanced TextWatermarkFactory with comprehensive font support and error handling
- Improved cache clearing integration across all cache services
- Updated blog schema to include proper created/updated field visibility
- Code style improvements and PHPStan Level 8 compliance throughout
3.0.32 - 2025-07-12
- NEW: Complete playground system for testing Twig templates with live data
- NEW:
{% cmsgrid %}Twig tag for flexible content grids with helper methods - NEW: JumpStart system for data import/export with factory generation
- New code field type with CodeMirror integration and syntax highlighting
- Copy-to-clipboard functionality for playground snippets
mailtoTwig filter for email linkshtmlencodefilter with encoding optionsclearcacheTwig variable for cache management- Emergency cache clearing capabilities
- Grid renderer with date, tags, excerpt, and price helpers
- Factory system for generating test data with Faker
- Export/import functionality for playground snippets
- BREAKING:
configvariable in Twig templates changed tocms.env - Reorganized Factory, Twig, and Util classes for better structure
- Enhanced Total CMS 1 import functionality with better error handling
- Improved cache clearing mechanisms and OPcache integration
- Better form handling with disabled autosave on edit forms
- Enhanced dashboard with bundled CSS and improved responsiveness
- Autocapitalize disabled on ID, URL, and Email fields for better mobile UX
- Grid list layouts and template rendering
- Line numbers and code gutters in editors
- Collection factory import issues with images and galleries
- Dashboard JavaScript compatibility issues
- 404 security handling and API URL validation
- Cache issues with collection lists
- Form refresh warnings on playground page
- GitHub test compatibility and stacks preview directory handling
3.0.31 - 2025-06-27
- Form grid layout system with dividers and headers for better organization
- Custom form layout CSS class support (
custom-layout) - Natural language default date support (e.g., "today", "tomorrow", "next week")
- New Twig date filters for enhanced date formatting
- Comprehensive test suite for SettingsSaver
- Lazy loading for collection table images
- Password manager interference prevention
- Advanced form grid layouts with dividers and headers
- Enhanced form layout customization options
- CRITICAL: Settings saver now preserves manual configuration in
tcms.phpwhen saving through admin - Major cache management system refactor with new
CacheReporterclass - Enhanced configuration merging with deep merge support for nested settings
- Smart index rebuilding - only rebuilds when objects are saved/updated
- Improved cache TTL management and reporting
- Enhanced styled text editor with improved toolbar
- Updated logger naming conventions
- Improved new installation detection and setup
- Cache system optimizations
- Better cache TTL management
- Settings being completely overwritten when saving through admin interface
- Empty records being cached unnecessarily
- Styled text styles not saving properly
- Duplicate schema issues in Safari browser
- Server checker version information display
- Batch image URL validation
- Styled text styles not saving
- Settings saver improvements
3.0.30 - 2025-06-25
- Image Batcher: New bulk image upload system for galleries
- CodeMirror themes with new syntax highlighting options
- Fire Code font for better code readability
- Updated playground theme
- Complete CodeMirror refactor for better performance
- Enhanced styled text toolbar functionality
- Improved cache management error handling
- Automatic cache clear after settings changes
- Refactored IndexFetcher with bug fixes
- Styled text image upload issues
- Playground functionality
- Various code style fixes
3.0.29 - 2025-06-25
-
Security Enhancements
- Comprehensive CSRF token management with middleware
- HTMLPurify integration for XSS attack prevention
- SVG content sanitization
- File path protection and upload security validation
- Content Security Policy (CSP) middleware
- Enhanced encryption cipher class
-
Import/Export Features
- Total CMS v1 import functionality
- Gallery import with alt text support
- Export collections to ZIP files
- Improved CSV import with trimming and logging
- Import warnings for existing objects
-
UI/UX Improvements
- Complete playground redesign with autosave
- CSS Grid-based form layouts
- Improved schema editing interface
- Custom collection labels in dashboard
- Job queue with retry functionality
- Cache cleaner UI
-
Twig & Templating
- Parsedown for markdown processing
- New Twig filters: phone, svgSymbol, barcode
- Configurable markdown links (open in new tabs)
-
Performance & Caching
- Multi-backend Twig caching system (filesystem, OPcache, Redis, Memcached)
- Complete cache manager refactor
- Collection filter/sort performance improvements (30-70% faster)
- Image cache management with statistics
- OPcache clearing on errors
- New caching layer for collections/schemas/objects/indexes
-
Session management migrated to Odan\Session\PhpSession
-
Dashboard pagination size configuration
- AVIF image generation
- Form saving issues
- Job queue refresh problems
- Duplicate fields in schema forms
- Autogeneration when fields don't exist
- Bad links in pretty URL builder
- ColorThief palette generation errors
For release history before version 3.0.29, please refer to the git history or release tags.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.