Skip to content

[DRAFT] October CMS 4.4 - User Content Release #6085

Description

@daftspunk

Version 4.4 introduces user content submissions for Tailor, brings Vue components to the frontend, completes the database-driven themes story for cloud deployments, and adds inline snippets for content editors.

Table of Contents

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 }}'

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. 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.

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. This is a sibling primitive to the Halcyon model: where Halcyon handles compound template files parsed into sections, SourceFile handles non-compound files where the bytes are opaque. 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.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions