Version 4.4 introduces user content submissions for Tailor, brings Vue components to the frontend, completes the database-driven themes story for cloud deployments, adds translated page URLs and content blocks for multisite, and adds inline snippets for content editors.
Version 4.4 introduces user content submissions for Tailor, brings Vue components to the frontend, completes the database-driven themes story for cloud deployments, adds translated page URLs and content blocks for multisite, and adds inline snippets for content editors.
Table of Contents
- How to Upgrade to v4.4
- User Content Submissions
- Vue Components in CMS Themes
- Database-Driven Theme Assets
- Database Layer for Language Files & Blueprints
- Translated Page URLs & Properties
- Translated Content Blocks
- Translated Mail Templates
- RainLab.Translate Migration
- Inline Snippets
- Notable Minor Changes
How to Upgrade to v4.4
There are two ways to upgrade, by clicking the Check for Updates button in the admin panel, or via console commands. For command line interface, please use the following commands:
php artisan october:update
In the event that you find some incompatibilities with your plugins due to this release, lock your composer file to the previous version (v4.3) by modifying your composer file below and then run composer update.
"require": {
"october/all": "4.3.*",
"october/rain": "4.3.*"
}
User Content Submissions
Tailor gains a new submission blueprint type for accepting user generated content from the frontend, such as blog comments, contact form submissions and product reviews. The blueprint defines the fields, a new submission CMS component captures the input, and records arrive in the admin panel as a moderation queue.
handle: Blog\Comment
type: submission
name: Comment
submission:
titleTemplate: '{{ author_name }} on {{ record.post.title }}'
notifyGroup: moderators
fields:
author_name:
label: Name
type: text
validation: required|min:2|max:100
author_email:
label: Email Address
type: email
validation: required|email
content:
label: Comment
type: textarea
validation: required|min:5|max:2000
post:
label: Post
type: entries
source: Blog\Post
maxItems: 1
Frontend component: the submission component renders a complete form generated from the blueprint fields, or the theme can supply custom markup posting to the onFormSubmit AJAX handler. Field validation rules from the blueprint are enforced on submission and returned as AJAX field errors. After a successful submission the formSubmitted and formModel variables become available to the partial.
[submission commentForm]
handle = "Blog\Comment"
{% component 'commentForm' %}
File upload fields are supported by adding the data-request-files attribute to the form tag, with uploads validated against the maximum upload size, the field fileTypes extension allowlist and the maxFiles count, including SVG sanitisation.
Moderation workflow: submissions arrive with a Pending status and stay hidden from the frontend until approved. The submissions list provides Approve and Reject bulk actions, where rejecting is a soft delete that can be restored.
Spam sweep and retention: the Spam action also rejects other pending submissions received from the same IP address within a configurable window (spamSweepDays, default 30 days), and approved records are never affected by the sweep. Rejected submissions are deleted forever after a retention period (purgeRejectedDays, default 30 days), cleaned up automatically when viewing the submissions list.
Record titles: the titleTemplate property builds the record title from submitted values using Twig, with every field in scope and a record variable for accessing relations. Without a template, the title falls back to common fields (name, subject, author_name, full_name, email) before generating a random reference.
Email notifications: the notifyGroup property emails an admin user group whenever a submission arrives, using the group code managed under Settings → Team → Manage Groups. The built-in mail template lists the submitted values with a link to moderate the record, and the notifyTemplate property swaps in a custom template with every field available as a Twig variable. The visitor's email address becomes the reply-to address, so moderators can respond directly from their mail client, with the notifyReplyTo property selecting a different field when needed. Since the record is saved first, mail failures are logged without interrupting the visitor or losing the submission.
Spam protection: the component ships with a honeypot field and per-IP rate limiting (6 submissions per minute, override formGetThrottleRate on the component to change it). Every submission captures the visitor IP address and user agent in the submitted_ip and submitted_user_agent attributes, available as a list column, filter scope and read-only form fields.
Events: the new cms.form.beforeSubmit event fires before the record saves and throwing an exception rejects the submission, providing the integration point for spam scoring services, CAPTCHA verification and blocklists. The cms.form.submitSuccess event fires after the record saves, useful for sending notifications.
Event::listen('cms.form.beforeSubmit', function ($component, $model) {
if (SpamService::isSpam($model)) {
throw new ValidationException(['content' => 'Submission rejected.']);
}
});
See the submission component documentation for full details.
Vue Components in CMS Themes
The October-Vue component pattern that powers the admin panel is now available to CMS themes. A Vue component is a PHP class paired with a template partial and a JavaScript ES module, and a CMS component can register one during its life cycle. October delivers two things to the page, the Vue library and the registered components, and the theme stays in control of mounting the application.
Scaffolding: the new create:vuecomponent command generates the component class, template partial, and asset files, ready for use in the backend panel or a CMS theme.
php artisan create:vuecomponent Acme.Blog PostViewer
Registering: a CMS component calls registerVueComponent and the call forwards to the CMS controller, which keeps a single per-page registry with deduplication and automatic resolution of $require dependencies, exactly like a backend controller.
class MyComponent extends ComponentBase
{
public function init()
{
$this->registerVueComponent(\Acme\Blog\VueComponents\PostViewer::class);
}
}
Registering in the init method makes the component available during both page renders and AJAX requests. The onRun method also works when the component is only needed for the initial page render.
Twig tags: the feature is delivered by two decoupled tags. The vue option on the framework tag loads the Vue 3 library and exposes it as the Vue global, using the development build when debug mode is on. If the tag is omitted, the theme can include its own Vue 3 build and expose it as window.Vue.
{% framework vue %}
The new {% vuecomponents %} tag outputs the component templates and registration code, together with the oc.createVueApp and oc.mountVueApp factory functions. It is placed near the end of the page, before any script that mounts an application.
<div id="app">
<acme-blog-post-viewer :post-id="7"></acme-blog-post-viewer>
</div>
{% vuecomponents %}
<script type="module">
oc.mountVueApp('#app');
</script>
AJAX support: Vue components registered during an AJAX request, for example by a component inside an updated partial, are sent through the AJAX asset pipeline and registered on the client before DOM patching occurs, matching the existing backend behavior. Mounting an application over the updated markup remains the responsibility of the page.
Component classes: frontend component JavaScript modules should read the Vue global (const { ref } = Vue) instead of using bare imports (import { ref } from 'vue'), since bare module specifiers only resolve in the backend panel.
As part of this change, the Vue infrastructure moved to the System module: Backend\Classes\VueComponentBase is now System\Classes\VueComponentBase and Backend\Traits\VueMaker is now System\Traits\VueMaker. The Backend classes remain in place as aliases, so existing plugins continue to work unchanged, although instanceof checks against the Backend class name will not match components extending the System base directly.
See the Vue components documentation for full details.
Database-Driven Theme Assets
Theme assets (CSS, JavaScript, images, fonts) can now be published to a shared storage disk with database tracking, completing the database-driven themes story for multi-instance deployments. Where CMS templates already persist to the database, asset edits previously only landed on the local filesystem of the instance that made them. With this feature enabled, an asset edited in the CMS editor is live across every instance immediately.
How it works: asset bytes are stored on a dedicated assets filesystem disk (typically S3 fronted by a CDN) and each change is tracked by a row in the cms_source_files table. The ASSET_URL environment variable points at the same origin as the disk, so URLs generated by asset() resolve to the published location with no changes to October's asset pipeline - no resolver layer, no combiner overrides, no PHP-served asset routes.
Enabling:
Three pieces need to be in place. First, define the assets disk in config/filesystems.php:
'assets' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_ASSETS_BUCKET'),
'url' => env('ASSET_URL'),
'visibility' => 'public',
],
Second, point ASSET_URL at the same origin as the disk. Both read from the same variable so they cannot drift:
ASSET_URL=https://cdn.example.com
Third, enable the feature flag. It defaults to off, so a fresh checkout requires no cloud credentials to run:
CMS_DB_ASSETS=true
For local development, swap the disk to a local driver pointed at a public path with ASSET_URL aligned to its URL. The editor save path and console commands are unchanged, only the disk implementation swaps.
Editor behavior: when the layer is enabled, all asset operations in the CMS editor route through the disk and database instead of the local filesystem:
| Operation | Behavior |
|---|---|
| Save | Bytes are written to the disk and a tracking row is upserted |
| Upload | Same as save, with SVG sanitisation and mime detection preserved |
| Delete | The row is tombstoned and the disk object removed |
| Rename / Move | Contents are re-keyed on the disk, the old path is tombstoned |
| New directory | A placeholder row is stored so the folder appears on every instance |
Reads check the database first and fall back to the filesystem, so files that ship with the deploy continue to be served without a row. A tombstone hides the filesystem copy from listings and reads, so deletes propagate across instances even when the on-disk copy cannot be removed. Directory renames and moves re-key every file beneath the prefix, so shipped assets keep working at their new URLs on all instances without touching the local filesystem.
Publishing on deployment: the october:mirror command gains a --disk option that uploads all theme, module, plugin, and app asset directories to a filesystem disk. Run it from the deployment pipeline so the disk always reflects the current codebase:
php artisan october:mirror --disk=assets
The upload is additive only - files are created or overwritten, never deleted, which removes any risk of a mirror run taking down a live asset. Orphaned keys can be cleaned up with object storage lifecycle rules if desired. Unchanged files are skipped using a size comparison against a single remote listing. Supporting options:
| Option | Description |
|---|---|
--checksum |
Compare content hashes instead of file sizes |
--force |
Upload every file, even when unchanged |
--dry-run |
List what would be uploaded without uploading |
The command shares its path inventory and the system.console.mirror.extendPaths event with the existing symlink mode, so plugins that extend the mirror paths are published automatically. In disk mode, storage/* paths and root files (index.php, .htaccess) are excluded after the event fires, since these never belong in an asset bucket.
CDN cache invalidation: every asset change fires the cms.asset.invalidate event with the theme and the changed disk keys. The core stays CDN-agnostic; listen to the event to purge your provider:
Event::listen('cms.asset.invalidate', function ($theme, $diskPaths) {
MyCdnProvider::invalidate($diskPaths);
});
Importing back to the filesystem: the theme:copy --import-db command writes asset rows back to the theme directory, streaming bytes from the disk, and applies tombstones by deleting the corresponding on-disk files. This closes the loop for workflows where git remains the durable snapshot: import, commit the diff, deploy, and the next mirror run reflects the merged state. The --purge-db option removes the rows once imported, leaving the disk objects in place since they now match the codebase.
php artisan theme:copy demo --import-db --purge-db
See the database-driven themes documentation for full details.
Database Layer for Language Files & Blueprints
The cms.database_templates layer now extends beyond CMS templates to cover theme language files and Tailor blueprints, using the same database-first read path and tombstone semantics.
Storage: both are stored in the new cms_source_files table, consumed via the October\Rain\Halcyon\SourceFile model and its CMS-scoped subclass Cms\Models\SourceFile. A row represents one file, identified by a (source, path) pair - for example theme.demo.lang with fr.json, or app.blueprint with blog/post.yaml. Content is stored inline for text files or by reference to a Storage disk for binaries, which is the mode used by theme assets above.
Language files: when the database layer is active for a theme, language file reads, writes, and deletes in the CMS editor route through the database. At runtime, DB-backed language strings are registered directly with the translator during theme boot, so __() calls in Twig resolve database content without touching the filesystem. A tombstoned locale suppresses the on-disk JSON file entirely.
Blueprints: Tailor blueprints from all three datasources - app (app/blueprints), themes, and plugins - are layered through the database with source identifiers derived from the owning datasource (app.blueprint, theme.{dir}.blueprint, plugin.{author}.{name}.blueprint). Editor file operations (create, save, rename, move, delete, upload) route through the layer, and the BlueprintIndexer consults the database updated_at timestamps alongside filesystem mtimes for its debug-mode cache invalidation, so blueprint changes made on one instance are picked up everywhere.
Round-trip: theme:copy --import-db imports templates, language files, assets, and blueprints in a single pass, and --purge-db clears all of the corresponding rows including tombstones.
Translated Page URLs & Properties
CMS pages can now translate their URL, title, description and meta fields for each site definition, closing the last gap that required the RainLab.Translate plugin for multisite websites. The feature is delivered by a new translatable CMS component, and adding the component to a page is how the page opts in.
url = "/contact"
title = "Contact"
[translatable]
locales[fr][url] = "/contactez"
locales[fr][title] = "Contactez"
locales[ru][url] = "/контакт"
locales[ru][title] = "Контакт"
Locale matching: entries in the locales collection are keyed by locale and resolved against the active site's locale in order: the exact locale (en-AU), then the base language (en). Values resolve per field, so a shared en entry can carry the common translations while an en-AU entry overrides a single field. Unicode URLs are fully supported.
Routing: the translated URL replaces the page URL in the routing table when a matching site is active, including route parameters (/blog/:slug can become /blogue/:slug). Requests for the default URL respond with a 301 redirect to the translated URL, so existing links keep working without creating duplicate content. The redirect code is configurable with multisite.translate.cms_page_url_redirect (set false to respond 404 instead), and the whole feature can be disabled with multisite.translate.cms_pages, both found in the new translation section of config/multisite.php. Because each site's route map contains exactly one pattern per page, translated and native URLs can never conflict. Reverse routing is automatic: the |page filter, menus, sitemaps, the sitePicker component and hreflang links all produce the translated URL for each site.
Properties: the remaining fields are applied to the page when it renders on the frontend, so {{ this.page.title }} returns the translated title while the backend always displays the original values for editing. Any custom field included in a locale entry can be resolved manually with translatable.siteProperty('myField').
Backend UI: the component appears in the Editor inspector as a managed list with one entry per locale, containing the URL, title, description, meta title and meta description fields, so entries are only added for the sites that need translation.
Route caching: router cache invalidation now uses a generation key that clears every locale's route map at once. The theme:cache manifest stores a route map per site locale, so run the command again after adding site definitions or changing translated URLs.
See the translatable component documentation for full details.
Translated Content Blocks
Content blocks now translate with locale directories. Creating a folder named after the locale inside the content directory and mirroring the base file path inside it is all that is required.
content/
├── my-content.htm ← Default locale
├── fr/
│ └── my-content.htm ← French
└── ru/
└── my-content.htm ← Russian
When {% content 'my-content.htm' %} renders, the directories are checked against the active site's locale using the same matching order as translated pages: the exact locale (fr-CA), then the base language (fr), before falling back to the base file. Nested paths mirror at the content root, so content/blog/intro.htm translates as content/fr/blog/intro.htm.
Because each translation is a distinct file, every existing cache layer works without per-locale cache keys, and translations appear as regular folders in the Editor. Directories that do not match the active site's locale are unaffected and remain directly addressable, for example {% content 'fr/my-content.htm' %}.
Plugin authors can resolve localized content directly with the Content::findLocalized($theme, $fileName, $locale = null) method, which defaults to the active site's locale. The feature is governed by multisite.translate.cms_content in config/multisite.php (default true).
Translated Mail Templates
Mail templates now translate for the locale of the person receiving the message, completing the translation story across pages, content and mail.
Database templates: templates, layouts and partials customized in the admin panel use the core Translatable trait, storing translated subject and content values per locale on the same record. Each field gains the translate popup in the admin panel with no configuration, and subject lines are still parsed with Twig using the message data.
View templates: registered mail views translate with the same locale directory convention as content blocks. Placing fr/welcome.htm next to the base welcome.htm view serves French recipients, including the subject defined in the view's settings section, with regional locales such as fr-ca degrading to fr automatically.
Recipient language: the locale is specified explicitly for the whole message by passing _current_locale with the message data, and the entire message composes in that locale, including the subject, layout and partials. When no locale is passed, the active site's locale applies. Queued messages capture the locale and site context at queue time, so translated templates are selected correctly when the queue worker runs.
The feature is governed by multisite.translate.system_mail_templates in config/multisite.php (default true).
RainLab.Translate Migration
With translated page URLs and properties now in the core, the RainLab.Translate plugin (v2.3.1+) detects the core feature and automatically stops applying its own page translation behaviors and the editor Translate popup. The plugin's theme string translation (|_ filter and message management), CMS content file translation and mail template localization continue to work unchanged. Content files using the plugin's suffix convention (my-content.fr.htm) and mail templates using suffix codes (welcome-fr) keep working with the plugin installed and take precedence over the core resolution, so projects can migrate at their own pace.
Legacy keys keep working: pages translated with the plugin store values as localeUrl, localeTitle and similar keys in the [viewBag] section. The core reads these keys as a fallback, so existing themes continue to route and render translations with zero changes.
Migrating theme files: a new translate:import-theme command rewrites the legacy viewBag keys into [translatable] sections across a theme. Values already present in a [translatable] section are kept, and empty viewBag sections are removed.
php artisan translate:import-theme
# Target a specific theme without confirmation prompts
php artisan translate:import-theme --theme=demo --force
# Before
[viewBag]
localeUrl[fr] = "/contactez"
localeTitle[fr] = "Contactez"
# After
[translatable]
locales[fr][url] = "/contactez"
locales[fr][title] = "Contactez"
Migrating mail templates: the translate:import-mail command converts welcome-fr style suffix records in the database into translated attributes on the base template, keeping any translations already stored, then removes the suffix records so the core resolution takes over.
php artisan translate:import-mail
Migrating model data: the translate:import-attributes command introduced in v4.2 remains the path for moving model attribute translations to the core Translatable trait.
See the plugin migration guide for full details.
Inline Snippets
Snippets can now be inserted inline within a line of text, in addition to the existing block insertion. Where a block snippet occupies its own line, an inline snippet sits within the surrounding text, which suits small pieces of content such as a phone number, a price or a formatted value.
Enabling for a partial: the partial Snippet settings gain an Inline Snippet checkbox alongside the existing AJAX option. It is stored as snippetInline in the partial view bag.
[viewBag]
snippetCode = "inlineLabel"
snippetName = "Inline Label"
snippetInline = 1
Enabling for a component: set snippetInline to true in componentDetails(), in the same way snippetAjax is defined.
public function componentDetails()
{
return [
// ...
'snippetInline' => true
];
}
Markup: an inline snippet is inserted as an inline element rather than a block, so the snippet should render an inline element such as a <span> to sit correctly within the text.
<span class="price">{{ amount }}</span>
Editor behavior: in the rich editor an inline snippet appears as a chip within the line, and can be moved and deleted like a single character in the surrounding text. Block snippets are unchanged, and the option defaults to false, so existing snippets continue to render as blocks.
See the snippets documentation for full details.
Notable Minor Changes
Child themes inherit parent theme blueprints
Child themes now inherit Tailor blueprints from their parent theme. Blueprints in a parent theme's blueprints/ directory (or its database layer) are picked up automatically when the child theme is active - they resolve by handle, appear in the backend navigation, and work with the page finder. When both themes define a blueprint with the same UUID, the child theme version takes priority.
Seed content is also inherited: the Seed Content option now appears for a child theme when its parent contains a seeds/ directory, importing the parent's blueprints, data, and translations. A child theme with its own seeds/ directory uses that instead.
Media Finder copy and paste
The Media Finder form widget gains an optional useCopyPaste property for multiple selection mode. When enabled, the toolbar shows Select All, Copy Selected and Paste buttons.
media_gallery:
label: Gallery
type: mediafinder
mode: image
maxItems: 10
useCopyPaste: true
Copied items are held in browser storage and can be pasted into any Media Finder field that also has the property enabled. Items already present in the target field are skipped, and the maxItems limit is enforced on paste. The property defaults to false, so existing fields are unaffected.
Scaffolding command for themes
A new create:theme command scaffolds a theme directory with a starter layout, home page, and the supporting theme.yaml, version.yaml and composer.json files. The argument is the theme name, which is converted to a directory slug.
php artisan create:theme "My Theme"
Pass the --overwrite option to replace existing files when regenerating a theme.
Editor filesystem functions deprecated
The Editor\Traits\FileSystemFunctions trait is deprecated. Editor CRUD logic has moved to domain-specific operation traits: CMS asset operations live on Cms\Classes\Asset and Tailor blueprint operations live on Tailor\Classes\Blueprint. New code should call these operations through the model classes so cross-cutting concerns, such as the database layer, apply consistently.
Str facade resolves directly to its helper class
The Str global alias now resolves directly to the October\Rain\Support\Str helper class instead of routing through the container, matching how Laravel handles it. Calls such as Str::slug() are now plain static calls with no container round-trip, and the string container binding has been removed. The October\Rain\Support\Facades\Str facade is retained as deprecated for backwards compatibility, so existing code that imports it continues to work; new code should reference the helper class directly.
This is the end of the document, you may read the announcement blog post or visit the changelog for more information.