Version 6 pre-stable
This version of Silverstripe CMS has not yet been given a stable release. See the release roadmap for more information. Go to documentation for the most recent stable version.

6.0.0 (unreleased)

Overview

Features and enhancements

Changes to sake, BuildTask, and CLI interaction in general

Until now, running sake on the command line has executed a simulated HTTP request to your Silverstripe CMS project, using the routing and controllers that your web application uses to handle HTTP requests. This resulted in both a non-standard CLI experience and added confusion about when an HTTPRequest actually represented an HTTP request.

We've rebuilt Sake using symfony/console - the same package that powers Composer.

Here are some common commands you can run with Sake:

# list available commands
sake # or `sake list`

# list available tasks
sake tasks

# build the database
sake db:build

# flush the cache
sake flush # or use the `--flush` flag with any other command

# get help info about a command (including tasks)
sake <command> --help # e.g. `sake db:build --help`

To reduce upgrade pains we've retained backwards compatability with the legacy syntax for dev/* routed actions (e.g. sake dev/build flush=1 will still work). This allows you to continue using existing scripts and cron jobs. This legacy syntax is deprecated, and will stop working in a future major release.

If for some reason you specifically need to access an HTTP route in your project using Sake, you can use the new sake navigate command, e.g. sake navigate about-us/teams.

See sake for more information about using and configuring sake, including how to register your own custom commands.

There is also a new PolyCommand class which provides a standardised API for code that needs to be accessible from both an HTTP request and CLI. This is used for BuildTask and other code accessed via /dev/* as mentioned below.

See PolyCommand for more details about the PolyCommand API.

Changes to BuildTask

Change to API

The BuildTask class is now a subclass of PolyCommand.

As a result of this, any BuildTask implementations in your project or module will need to be updated. The upgrade will likely look like this in most cases:

 namespace App\Tasks;

-use SilverStripe\Control\Director;
-use SilverStripe\Control\HTTPRequest;
 use SilverStripe\Dev\BuildTask;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;

 class MyCustomTask extends BuildTask;
 {
-    private static $segment = 'my-custom-task';
+    protected static string $commandName = 'my-custom-task';

-    protected $title = 'My custom task';
+    protected string $title = 'My custom task';

-    protected $description = 'A task that does something custom';
+    protected static string $description = 'A task that does something custom';

-    public function run(HTTPRequest $request)
+    protected function execute(InputInterface $input, PolyOutput $output): int
     {
+        if ($input->getOption('do-action')) {
-        if ($request->getVar('do-action')) {
-            if (Director::is_cli()) {
-                echo "Doing something...\n"
-            } else {
-                echo "Doing something...<br>\n";
-            }
+            $output->writeln('Doing something...');
         }
-        echo "Done\n";
+        return Command::SUCCESS;
     }
+
+    public function getOptions(): array
+    {
+        return [
+            new InputOption('do-action', null, InputOption::VALUE_NONE, 'do something specific'),
+        ];
+    }
 }

Note that you should no longer output "done" or some equivalent message at the end of the task. Any time a task finishes executing, the output will include a message about whether the task completed successfully or failed (based on the return value) and how long it took.

See PolyCommand for more details about the BuildTask API.

Change to names

Some tasks were relying on the FQCN instead of having an explicit name. This means the name used for both the URL and the CLI command were excessively long.

The way these are displayed in the new sake tasks list doesn't suit long names, so we have given explicit names to these tasks in order to make them shorter. If you have scripts or cron jobs that reference these you will need to update them to use the new name.

For example, you used to navigate to /dev/tasks/<OLD_NAME> or use sake dev/tasks/<OLD_NAME>. Now you will need to navigate to /dev/tasks/<NEW_NAME> or use sake tasks:<NEW_NAME>.

classold namenew name
ContentReviewEmailsSilverStripe-ContentReview-Tasks-ContentReviewEmailscontent-review-emails
DeleteAllJobsTaskSymbiote-QueuedJobs-Tasks-DeleteAllJobsTaskdelete-queued-jobs
MigrateContentToElementDNADesign-Elemental-Tasks-MigrateContentToElementelemental-migrate-content
UserFormsColumnCleanTaskSilverStripe-UserForms-Task-UserFormsColumnCleanTaskuserforms-column-clean
StaticCacheFullBuildTaskSilverStripe-StaticPublishQueue-Task-StaticCacheFullBuildTaskstatic-cache-full-build

Changes to /dev/* actions

With the changes to sake come changes to the way dev/* actions are handled. Most of these are now subclasses of the new DevCommand class which is itself a subclass of PolyCommand.

One small change as a result of this is the dont_populate parameter for dev/build and for the new db:build CLI command has been deprecated. Use no-populate instead. For example use https://example.com/dev/build/?no-populate=1 and sake db:build --no-populate.

Registering dev/* commands

If you have custom actions registered under DevelopmentAdmin.registered_controllers you'll need to update the YAML configuration for these. If you want them to be accessible via CLI, you'll also have to update the PHP code.

With the below example, there are two custom actions displayed in the list at /dev:

  • /dev/my-http-only-action: intended for use in the browser only, but you'd have to add custom logic in init() to disallow its use in CLI until now
  • /dev/my-http-and-cli-action: intended for use both in CLI and in the browser.

For actions that should only be accessible in the browser, you only need to change how these are registered. Move them from DevelopmentAdmin.registered_controllers to the new DevelopmentAdmin.controllers configuration property.

Controllers added to DevelopmentAdmin.controllers can only be accessed via HTTP requests, so you can remove any logic around CLI usage.

For actions that should be accessible in the browser and via CLI, you will need to change these from being a Controller to subclassing DevCommand. These get registered to the new DevelopmentAdmin.commands configuration property

 SilverStripe\Dev\DevelopmentAdmin:
-  registered_controllers:
-    my-http-only-action:
-      controller: 'App\Dev\MyHttpOnlyActionController'
-      links:
-        my-http-only-action: 'Perform my custom action in dev/my-http-only-action (do not run in CLI)'
-    my-http-and-cli-action:
-      controller: 'App\Dev\MyHttpAndCliActionController'
-      links:
-        my-http-and-cli-action: 'Perform my custom action in dev/my-http-and-cli-action'
+  controllers:
+    my-http-only-action:
+      class: 'App\Dev\MyHttpOnlyActionController'
+      description: 'Perform my custom action in dev/my-http-only-action'
+  commands:
+    my-http-and-cli-action: 'App\Dev\MyHttpAndCliActionCommand'
 namespace App\Dev;

-use SilverStripe\Control\Controller;
-use SilverStripe\Control\Director;
-use SilverStripe\Control\HTTPRequest;
+use SilverStripe\Dev\Command\DevCommand;
-use SilverStripe\Dev\DevelopmentAdmin;
+use SilverStripe\PolyExecution\PolyOutput;
-use SilverStripe\Security\Permission;
 use SilverStripe\Security\PermissionProvider;
-use SilverStripe\Security\Security;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;

-class MyHttpAndCliActionController extends Controller implements PermissionProvider
+class MyHttpAndCliActionController extends DevCommand implements PermissionProvider
 {
+    protected static string $commandName = 'app:my-http-and-cli-action';
+
+    protected static string $description = 'Perform my custom action in dev/my-http-and-cli-action or via sake app:my-http-and-cli-action';
+
+    private static array $permissions_for_browser_execution = [
+        'MY_CUSTOM_PERMISSION',
+    ];
+
+    public function getTitle(): string
+    {
+        return 'My other action';
+    }
+
-    protected function init(): void
-    {
-        parent::init();
-
-        if (!$this->canInit()) {
-            Security::permissionFailure($this);
-        }
-    }
-
-    public function index(HTTPRequest $request)
+    protected function execute(InputInterface $input, PolyOutput $output): int
     {
-        $someVar = $request->getVar('some-var');
+        $input->getOption('some-var');
-        if (Director::is_cli()) {
-            $body = "some output\n";
-        } else {
-            $body = "some output<br>\n";
-        }
+        $output->writeln('some output');
-
-        return $this->getResponse()->setBody($body);
+        return Command::SUCCESS;
     }

+    public function getOptions(): array
+    {
+        return [
+            new InputOption('some-var', null, InputOption::VALUE_NONE, 'some get variable'),
+        ];
+    }
-    public function canInit(): bool
-    {
-        return (
-            Director::isDev()
-            || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
-            || Permission::check('MY_CUSTOM_PERMISSION')
-        );
-    }

     // ...
 }

You would now access the /dev/my-http-only-action action via an HTTP request only. The /dev/my-http-and-cli-action action can be access via an HTTP request, or by using sake app:my-http-and-cli-action on the command line.

The some-var option can be used in a query string when running the action via an HTTP request, or as a flag (e.g. sake app:my-http-and-cli-action --some-var) in CLI.

See PolyCommand for more details about the DevCommand API.

sake -start and sake -stop have been removed

Sake used to have functionality to make daemon processes for your application. This functionality was managed with sake -start my-process and sake -stop my-process.

We've removed this functionality. Please use an appropriate daemon tool such as systemctl to manage these instead.

Read-only replica database support

Read-only replicas are additional databases that are used to offload read queries from the primary database, which can improve performance by reducing the load on the primary database.

Read-only replicas are configured by adding environment variables that match the primary environment variable and suffixing _REPLICA_<replica-number> to the variable name, where <replica_number> is the replica number padding by a zero if it's less than 10, for example SS_DATABASE_SERVER becomes SS_DATABASE_SERVER_REPLICA_01 for the first replica, or SS_DATABASE_SERVER_REPLICA_12 for the 12th replica. Replias must be numbered sequentially starting from 01.

Replicas cannot define different configuration values for SS_DATABASE_CLASS, SS_DATABASE_NAME, or SS_DATABASE_CHOOSE_NAME. They are restricted to prevent strange issues that could arise from having inconsistent database configurations across replicas.

If one or more read-only replicas have been configured, then for each request one of the read-only replicas will be randomly selected from the pool of available replicas to handle queries for the rest of the request cycle, unless criteria has been met to use the primary database instead, for example a write operation.

See read-only database replicas for more details.

When replicas are configured, calling the method DB::get_conn() will now give a replica by default if one is able to be used. To get the primary database connection, call DB::get_conn(DB::CONN_PRIMARY) instead.

Note that the DB::CONN_PRIMARY constant, which has a value of "primary", is used to specify the primary database used. Prior to CMS 6 when there was no DB replica support, the primary database was referred to as "default". If you have code that uses the string "default" to refer to the primary database, you should update it to use the DB::CONN_PRIMARY constant instead.

Note that some DataQuery methods such as DataQuery::execute() now work slightly differently as they will use the replica database if the queried DataObject has the DataObject.must_use_primary_db configuration set to true. However calling the equivalent SQLSelect method via a DataQuery e.g. $dataQuery->query()->execute() will not respect the DataObject.must_use_primary_db configuration.

Run CanonicalURLMiddleware in all environments by default

In Silverstripe CMS 5 CanonicalURLMiddleware only runs in production by default. This lead to issues with fetch and APIs behaving differently in production environments to development. Silverstripe 6.0 changes this default to run the rules in dev, test, and live by default.

To opt out of this change include the following in your _config.php

use SilverStripe\Control\Middleware\CanonicalURLMiddleware;
use SilverStripe\Core\CoreKernel;

CanonicalURLMiddleware::singleton()->setEnabledEnvs([
    CoreKernel::LIVE,
]);

Changes to default cache adapters

The DefaultCacheFactory used to use APCu cache by default for your webserver - but we found this cache isn't actually shared with CLI. This means flushing cache from the CLI didn't actually flush the cache your webserver was using.

What's more, the PHPFilesAdapter used as a fallback wasn't enabled for CLI, which resulted in having a different filesystem cache for CLI than is used for the webserver.

We've made the following changes to resolve this problem:

  • The PHPFilesAdapter will only be used if it's available for both the webserver and CLI. Otherwise, FilesystemAdapter will be used for both.
  • There is no default in-memory cache used by DefaultCacheFactory.
  • Cache factories have been implemented for Redis, Memcached, and APCu, with a new InMemoryCacheFactory interface available for your own implementations.

We strongly encourage you to configure an appropriate in-memory cache for your use-case. See adding an in-memory cache adapter for details.

Changes to scaffolded form fields

Some of the form fields have changed when scaffolding form fields for relations.

Previously, File, Image, and Folder were all scaffolded using UploadField for has_one relations, and GridField for has_many and many_many relations.

All other models used SearchableDropdownField for has_one relations and GridField for has_many and many_many relations.

SiteTree uses form field scaffolding

SiteTree::getCMSFields() used to create its form fields from scratch, without calling parent::getCMSFields(). This meant that all subclasses of SiteTree (i.e. all of your Page classes) had to explicitly define all form fields.

SiteTree::getCMSFields() now uses the same form field scaffolding that all other DataObject subclasses use.

Note that this means when you initially upgrade to Silverstripe CMS 6 you may have form fields being added to your CMS edit forms that you don't want to include, or tabs from relations that you don't want. You can use the scaffold_cms_fields_settings configuration property to change which fields are being scaffolded.

For example, if you have a database column for which you don't want content authors to see or edit the value, you can use the ignoreFields option to stop the form field for that column from being scaffolded:

namespace App\PageTypes;

use Page;

class MyCustomPage extends Page
{
    // ...
    private static array $db = [
        'SecretToken' => 'Varchar',
    ];

    private static array $scaffold_cms_fields_settings = [
        'ignoreFields' => [
            'SecretToken',
        ],
    ];
}

See the scaffolding section for more details about using these options.

As part of your CMS 6 upgrade, you should check all of the page types in your project and in any modules you maintain to ensure the correct form fields are available in the appropriate tabs. You should also check Extension subclasses that you know get applied to pages to ensure fields aren't being scaffolded from those that you want to keep hidden.

What if I don't have time to upgrade all of my page types?

If you have a lot of complex page types and extensions, upgrading all of them to account for the new scaffolding might be a large task. If you want to avoid upgrading your getCMSFields() and updateCMSFields() implementations initially, you can use the restrictRelations and restrictFields scaffolding options in the scaffold_cms_fields_settings configuration property for your pages. You can then declare that only the fields introduced in parent classes should be scaffolded.

The below YAML configuration can be used as a base for this workaround. It will work for all page types available in commercially supported modules. If you use page types provided in third-party modules, you may need to add configuration for those as well.

Note that this is explicitly intended as a temporary workaround, so that you can focus on other areas of the upgrade first, and come back to your page form fields later.

As more community modules are upgraded to account for form field scaffolding in their page types and extension classes, you may need to add more fields to this list. To avoid having to continuously update these lists it's recommended that you take the time to update your getCMSFields() and updateCMSFields() implementations as soon as you have time to do so.

Click to see the YAML configuration snippet
SilverStripe\CMS\Model\SiteTree:
  scaffold_cms_fields_settings:
    restrictRelations:
      # This will stop all has_many and many_many relations from being
      # scaffolded except for new relations which are added to this list
      - 'ThisRelationDoesntExist'
    restrictFields:
      # These fields are scaffolded from SiteTree, and are the bare minimum
      # fields that we need to be scaffolded for all page types
      - 'Title'
      - 'MenuTitle'
      - 'URLSegment'
      - 'Content'

SilverStripe\CMS\Model\VirtualPage:
  scaffold_cms_fields_settings:
    restrictFields:
      - 'CopyContentFrom'

SilverStripe\CMS\Model\RedirectorPage:
  scaffold_cms_fields_settings:
    restrictFields:
      - 'ExternalURL'
      - 'LinkTo'
      - 'LinkToFile'

SilverStripe\Blog\Model\BlogPost:
  scaffold_cms_fields_settings:
    restrictRelations:
      - 'Categories'
      - 'Tags'
    restrictFields:
      - 'Summary'
      - 'FeaturedImage'
      - 'PublishDate'

SilverStripe\IFrame\IFramePage:
  scaffold_cms_fields_settings:
    restrictFields:
      - 'ForceProtocol'
      - 'IFrameURL'
      - 'IFrameTitle'
      - 'AutoHeight'
      - 'AutoWidth'
      - 'FixedHeight'
      - 'FixedWidth'
      - 'BottomContent'
      - 'AlternateContent'

SilverStripe\UserForms\Model\UserDefinedForm:
  scaffold_cms_fields_settings:
    restrictRelations:
      - 'EmailRecipients'

Changes to the templating/view layer

Note that the SilverStripe\View\ViewableData class has been renamed to SilverStripe\Model\ModelData. We will refer to it as ModelData in the rest of these change logs.

See many renamed classes for more information about this change.

Improved separation between the view and model layers

Historically the ModelData class did double-duty as being the base class for most models as well as being the presumed class wrapping data for the template layer. Part of this included methods like XML_val() being called on any object in the template layer, despite being methods very specifically implemented on ModelData.

Any data that wasn't wrapped in ModelData was hit-and-miss as to whether it would work in the template layer, and whether the way you can use it is consistent. It also meant the ModelData class had some complexity it didn't necessarily need to represent a model.

To improve the separation between the view and model layers (and in some cases as quality-of-life improvements), we've made the following changes:

  • Added a new ViewLayerData class which sits between the template layer and the model layer. All data that gets used in the template layer gets wrapped in a ViewLayerData instance first. This class provides a consistent API and value lookup logic so that all data gets treated the same way once it's in the template layer.
  • Move casting logic into a new CastingService class. This class is responsible for casting data to the correct model (e.g. by default strings get cast to DBText and booleans get cast to DBBoolean). If the source of the data is known and is an instance of ModelData, the casting service calls ModelData::castingHelper() to ensure the ModelData.casting configuration and (in the case of DataObject) the db schema are taken into account.

    • Native indexed PHP arrays can now be passed into templates and iterated over with <% loop $MyArray %>. Under the hood they are wrapped in ArrayList, so you can get the count using $Count and use <% if $MyArray %> as a shortcut for <% if $MyArray.Count %>. Other functionality from ArrayList such as filtering and sorting cannot be used on these arrays since they don't have keys to filter or sort against.
  • Implemented a default ModelData::forTemplate() method which will attempt to render the model using templates named after it and its superclasses. See forTemplate and $Me for information about this method's usage.

  • The ModelData::XML_val() method has been removed as it is no longer needed to get values for usage in templates.
  • Arguments are now passed into getter methods when invoked in templates. For example, if a model has a getMyField(..$args) method and $MyField(1,2,3) is used in a template, the args 1, 2, 3 will be passed in to the getMyField() method.

    • For parity, the ModelData::obj() method now also passes arguments into getter methods. Note however that this method is no longer used to get values in the template layer.
  • Values from template variables are passed into functions when used as arguments

    • For example, $doSomething($Title) will pass the value of the Title property into the doSomething() method. See template syntax documentation for more details.
  • The ModelData::objCacheSet() and ModelData::objCacheGet() methods now deal with raw values prior to being cast. This is so that ViewLayerData can use the cache reliably.
  • Nothing in core or supported modules (except for the template engine itself) relies on absolute file paths for templates - instead, template names and relative paths (without the .ss extension) are used.

    • Email::getHTMLTemplate() now returns an array of template candidates, unless a specific template was set using setHTMLTemplate().
    • ThemeResourceLoader::findTemplate() has been removed without a replacement.
    • SSViewer::chooseTemplate() has been removed without a replacement.
  • TemplateEngine classes will throw a MissingTemplateException if there is no file mapping to any of the template candidates passed to them.
  • The Email::setHTMLTemplate() and Email::setPlainTemplate() methods used to strip the .ss extension off strings passed into them. They no longer do this. You should double check any calls to those methods and remove the .ss extension from any strings you're passing in, unless those strings represent full absolute file paths.

If you were overriding ModelData::XML_val() or ModelData::obj() to influence values used in the template layer, you will need to try an alternative way to alter those values. Best practice is to implement getter methods in most cases - but as a last resort you could implement a subclass of ViewLayerData and replace it using the injector.

If you have set the ModelData.default_cast configuration property for some model, consider unsetting this so that the relevant DBField instance is chosen based on the type of the value, and use ModelData.casting if some specific fields need to be cast to non-default classes.

Abstraction of template rendering

The SSViewer class previously had two duties:

  1. Act as the barrier between the template layer and the model layer
  2. Actually process and render templates

This made that class difficult to maintain. More importantly, it made it difficult to use other template rendering solutions with Silverstripe CMS since the barrier between the two layers was tightly coupled to the ss template rendering solution.

The template rendering functionality has now been abstracted. SSViewer still acts as the barrier between the model and template layers, but it now delegates rendering templates to an injectable TemplateEngine.

TemplateEngine is an interface with all of the methods required for SSViewer and the rest of Silverstripe CMS to interact reliably with your template rendering solution of choice. For now, all of the templates provided in core and supported modules will use the familiar ss template syntax and the default template engine will be SSTemplateEngine - but the door is now open for you to experiment with alternative template rendering solutions if you want to.

There are various ways to declare which rendering engine to use, which are explained in detail in the swap template engines documentation.

Some API which used to be on SSViewer is now on SSTemplateEngine, and some has been outright removed. The common ones are listed here, but see full list of removed and changed API below for the full list.

If you want to just keep using the ss template syntax you're familiar with, you shouldn't need to change anything (except as specified in other sections or if you were using API that has moved or been removed).

Strong typing for ModelData and DBField

Many of the properties and methods in ModelData, DBField, and their immediate subclasses have been given strong typehints. Previously, these only had typehints in the PHPDoc which meant that any arbitrary values could be assigned or returned.

Most of the strong types are either identical to the old PHPDoc types, or match what was already actually being assigned to, passed into, and returned from those APIs.

In some cases, where a string was expected but sometimes null was being used, we have explicitly strongly typed the API to string. This matches similar changes PHP made in PHP 8.1 and will help avoid passing null values in to functions that expect a string.

The one change we specifically want to call out is for ModelData::obj(). This method will now explicitly return null if there is no field, property, method, etc to represent the field you're trying to get an object for. This is a change from the old behaviour, where an empty DBField instance would be returned even though there's no way for any non-null value to be available for that field.

See the full list of removed and changed API to see all of the API with updated typing.

Changes to LeftAndMain and its subclasses

LeftAndMain has historically been the superclass for all controllers routed in /admin/* (i.e. all controllers used in the CMS). That class includes a lot of boilerplate functionality for setting up a menu, edit form, etc which a lot of controllers don't need.

A new AdminController has been created which provides the /admin/* routing functionality and permission checks that LeftAndMain used to be responsible for. If you have a controller which needs to be routed through /admin/* with the relevant CMS permission checks, but which does not need a menu item on the left or an edit form, you should update that class to be a subclass of AdminController instead.

As a result of this change, to following classes are now direct subclasses of AdminController:

Changes to password validation

PasswordValidator changes

The deprecated SilverStripe\Security\PasswordValidator class has been renamed to RulesPasswordValidator and is now optional.

The default password validator is now EntropyPasswordValidator which is powered by the PasswordStrength constraint in symfony/validator. This constraint determines if a password is strong enough based on its entropy, rather than on arbitrary rules about what characters it contains.

You can change the required strength of valid passwords by setting the EntropyPasswordValidator.password_strength configuration property to one of the valid minScore values:

SilverStripe\Security\Validation\EntropyPasswordValidator:
  password_strength: 4

EntropyPasswordValidator also has the same options for avoiding repeat uses of the same password that RulesPasswordValidator has.

This does not retroactively affect existing passwords, but will affect any new passwords (e.g. new members or changing the password of an existing member).

If you want to revert to the validator that was used in CMS 5, you can do so with this YAML configuration:

---
After: '#corepasswords'
---
SilverStripe\Core\Injector\Injector:
  SilverStripe\Security\Validation\PasswordValidator:
    class: 'SilverStripe\Security\Validation\RulesPasswordValidator'

See passwords for more information about password validation.

ConfirmedPasswordField changes

If ConfirmedPasswordField->requireStrongPassword is set to true, the old behaviour was to validate that at least one digit and one alphanumeric character was included. This meant that you could have a password like "a1" and it would be considered "strong".

This has been changed to use the PasswordStrength constraint in symfony/validator instead. Now a password is considered "strong" based on its level of entropy.

You can change the level of entropy required by passing one of the valid minScore values into ConfirmedPasswordField::setMinPasswordStrength().

Other new features

  • Modules no longer need to have a root level _config.php or _config directory to be recognised as a Silverstripe CMS module. They will now be recognised as a module if they have a composer.json file with a type of silverstripe-vendormodule or silverstripe-theme.
  • A new DataObject::getCMSEditLink() method has been added, which returns null by default. This provides more consistency for that method which has previously been inconsistently applied to various subclasses of DataObject. See managing records for more details about providing sane values for this method in your own subclasses.
  • The CMSEditLink() method on many DataObject subclasses has been renamed to getCMSEditLink().
  • The UrlField class has some new API for setting which protocols are allowed for valid URLs.
  • The EmailField class now uses symfony/validator to handle its validation logic, where previously this was validated with a custom regex.
  • ArrayData can now be serialised using json_encode().

Dependency changes

intervention/image has been upgraded from v2 to v3

We've upgraded from intervention/image v2 to v3. One of the main improvements included in this upgrade is full support for animated GIFs.

If you are directly interacting with APIs from intervention/image in your project or module you should check out their upgrade guide.

Animated vs still images

Manipulating animated images takes longer, and results in a larger filesize.

Because of this, the ThumbnailGenerator will provide still images as thumbnails for animated gifs by default. You can change that for a given instance of ThumbnailGenerator by passing true to the setAllowsAnimation() method. For example, to allow animated thumbnails for UploadField:

---
After: '#assetadminthumbnails'
---
SilverStripe\Core\Injector\Injector:
  SilverStripe\AssetAdmin\Model\ThumbnailGenerator.assetadmin:
    properties:
      AllowsAnimation: true

The Image::PreviewLink() method also doesn't allow an animated result by default. This is used in the "Files" admin section, and anywhere you can choose an existing image such as UploadField and the WYSIWYG file modals.

You can allow animated previews by setting Image.allow_animated_preview configuration property to true:

SilverStripe\Assets\Image:
  allow_animated_preview: true

You can disable the ability to create animated variants globally by setting decodeAnimation to false in the Intervention\Image\ImageManager's constructor:

SilverStripe\Core\Injector\Injector:
  Intervention\Image\ImageManager:
    constructor:
      decodeAnimation: false

You can also toggle that configuration setting on and off for a given image instance, or create a variant from your image which uses a specific frame of animation - see animated images for details.

Using GD or Imagick

One of the changes that comes as a result of this upgrade is a change in how you configure which manipulation driver (GD or Imagick) to use.

To facilitate upgrades and to ensure we are providing optimal defaults out of the box, if you have the imagick PHP extension installed, it will be used as the driver for intervention/image by default. If you don't, the assumption is that you have the GD PHP extension installed, and it will be used instead.

See changing the manipulation driver for the new configuration for swapping the driver used by intervention/image.

New API

The following new methods have been added to facilitate this upgrade:

Symfony dependencies have been upgraded from v6 to v7

We've upgraded the various Symfony dependencies from v6 to v7.

Bug fixes

This release includes a number of bug fixes to improve a broad range of areas. Check the change logs for full details of these fixes split by module. Thank you to the community members that helped contribute these fixes as part of the release!

API changes

Many renamed classes

There are a lot of classes which were in the SilverStripe\ORM namespace and a few in the SilverStripe\View namespace that simply don't belong there.

We've moved many of these into more suitable namespaces, and in a few cases have taken the opportunity to rename the class to something more appropriate. You will need to update any references to the old Fully Qualified Class Names to use the new names and namespaces instead.

Note that the change from ViewableData to ModelData specifically was made to improve the separation between the model layer and the view layer. It didn't make much sense for a class called ViewableData to be the superclass for all of our model types. The new name better reflects the purpose of this class as the base class for models.

Old FQCNNew FQCN
SilverStripe\ORM\ArrayLibSilverStripe\Core\ArrayLib
SilverStripe\ORM\ArrayListSilverStripe\Model\List\ArrayList
SilverStripe\ORM\FilterableSilverStripe\Model\List\Filterable
SilverStripe\ORM\GroupedListSilverStripe\Model\List\GroupedList
SilverStripe\ORM\LimitableSilverStripe\Model\List\Limitable
SilverStripe\ORM\ListDecoratorSilverStripe\Model\List\ListDecorator
SilverStripe\ORM\MapSilverStripe\Model\List\Map
SilverStripe\ORM\PaginatedListSilverStripe\Model\List\PaginatedList
SilverStripe\ORM\SortableSilverStripe\Model\List\Sortable
SilverStripe\ORM\SS_ListSilverStripe\Model\List\SS_List
SilverStripe\ORM\ValidationExceptionSilverStripe\Core\Validation\ValidationException
SilverStripe\ORM\ValidationResultSilverStripe\Core\Validation\ValidationResult
SilverStripe\View\ArrayDataSilverStripe\Model\ArrayData
SilverStripe\View\ViewableDataSilverStripe\Model\ModelData
SilverStripe\View\ViewableData_CustomisedSilverStripe\Model\ModelDataCustomised
SilverStripe\View\ViewableData_DebuggerSilverStripe\Model\ModelDataDebugger

GraphQL removed from the CMS

[!INFO] If you need to use GraphQL in your project for public-facing frontend schemas, you can still install and use the silverstripe/graphql module.

GraphQL has been removed from the admin section of the CMS and is no longer installed when creating a new project using silverstripe/installer, or an existing project that uses silverstripe/recipe-cms. All existing functionality in the CMS that previously relied on GraphQL has been migrated to use regular Silverstripe CMS controllers instead.

Any customisations made to the admin GraphQL schema will no longer work. There are extension hooks available on the new controller endpoints for read operations, for example AssetAdminOpen::apiRead() that allow you to customise the JSON data returned.

PHP code such as resolvers that were in silverstripe/asset-admin, silverstripe/cms and silverstripe/versioned have been move to the silverstripe/graphql module and have had their namespace updated. The GraphQL yml config for the versioned module has also been copied over as that was previously enabled by default on all schemas. The GraphQL YAML configuration for the silverstripe/asset-admin and silverstripe/cms modules has not been moved as as that was only enabled on the admin schema.

If your project does not have any custom GraphQL, after upgrading you may still have the old .graphql-generated and public/_graphql folders in your project. You can safely remove these folders.

Most extension hook methods are now protected

Core implementations of most extension hooks such as updateCMSFields() now have protected visibility. Formerly they had public visibility which meant they could be called directly which was not how they were intended to be used. Extension hook implementations are still able to be declared public in project code, though it is recommended that all extension hook methods are declared protected in project code to follow best practice.

Changes to some extension hook names

In order to better align the codebase in Silverstripe CMS with best practices, the following extension hook methods have changed name:

class that defined the hookold namenew name
MemberafterMemberLoggedInonAfterMemberLoggedIn
MemberafterMemberLoggedOutonAfterMemberLoggedOut
MemberauthenticationFailedonAuthenticationFailed
MemberauthenticationFailedUnknownUseronAuthenticationFailedUnknownUser
MemberauthenticationSucceededonAuthenticationSucceeded
MemberbeforeMemberLoggedInonBeforeMemberLoggedIn
MemberbeforeMemberLoggedOutonBeforeMemberLoggedOut
LeftAndMaininitonInit
DataObjectflushCacheonFlushCache
LostPasswordHandlerforgotPasswordonForgotPassword
ErrorPagegetDefaultRecordsupdateDefaultRecords
SiteTreeMetaComponentsupdateMetaComponents
SiteTreeMetaTagsupdateMetaTags
DataObjectpopulateDefaultsonAfterPopulateDefaults
MemberregisterFailedLoginonRegisterFailedLogin
DataObjectrequireDefaultRecordsonRequireDefaultRecords
DataObjectvalidateupdateValidate

If you have implemented any of those methods in an Extension subclass, you will need to rename it for it to continue working.

Strict typing for Factory implementations

The Factory::create() method now has strict typehinting. The first argument must be a string, and either null or an object must be returned.

One consequence of this is that you can no longer directly pass an instantiated anonymous class object into Injector::load(). Instead, you need to get the class name using get_class() and pass that in as the class.

use App\ClassToReplace;
use SilverStripe\Core\Injector\Injector;

// Use `get_class()` to get the class name for your anonymous class
$replacementClass = get_class(new class () {
    private string $property;

    public function __construct(string $value = null)
    {
        $this->property = $value;
    }
});

Injector::inst()->load([
    ClassToReplace::class => [
        'class' => $replacementClass,
    ],
]);

Elemental TopPage class names changed

The class names for the TopPage feature in dnadesign/silverstripe-elemental did not follow the correct naming convention for Silverstripe CMS. The class names have been changed as follows:

old namenew name
DNADesign\Elemental\TopPage\DataExtensionDNADesign\Elemental\Extensions\TopPageElementExtension
DNADesign\Elemental\TopPage\FluentExtensionDNADesign\Elemental\Extensions\TopPageElementFluentExtension
DNADesign\Elemental\TopPage\SiteTreeExtensionDNADesign\Elemental\Extensions\TopPageSiteTreeExtension

If you reference any of these classes in your project or module, most likely in config if you have tractorcow/silverstripe-fluent installed, then you will need to update the references to the new class names.

List interface changes

The SS_List interface now includes the methods from the Filterable, Limitable, and Sortable interfaces, which have now been removed. This means that any class that implements SS_List must now also implement the methods from those interfaces.

Many of the methods on SS_List and the classes that implement it are now strongly typed. This means that you will need to ensure that any custom classes that implement SS_List have the correct types for the methods that they implement.

As part of these changes ArrayList::find() will no longer accept an int argument for the $key param, it will now only accept a string argument.

General changes

Other changes

MySQL 5 no longer supported

MySQL 5.6 and 5.7 are no longer supported. The minimum supported version is MySQL 8.0. We support and test against the latest LTS releases of MySQL and MariaDB.

MySQL now defaults to utf8mb4

MySQL will now use utf8mb4 by default rather than plain utf8. This provides better support for emojis and other special characters.

Depending on when you created your Silverstripe CMS project, you may already be using utf8mb4 as the default encoding. The silverstripe/recipe-core recipe has included a configuration file setting your database settings to utf8mb4 for a few years.

When upgrading your Silverstripe CMS project, review the app/_config/mysite.yml file and remove the following lines if they exist:

# UTF8MB4 has limited support in older MySQL versions.
# Remove this configuration if you experience issues.
---
Name: myproject-database
---
SilverStripe\ORM\Connect\MySQLDatabase:
  connection_charset: utf8mb4
  connection_collation: utf8mb4_unicode_ci
  charset: utf8mb4
  collation: utf8mb4_unicode_ci

DBDecimal default value

Previously if an invalid default value was provided for a DBDecimal database column, it would silently set the defalt value to 0. This will now throw an exception instead, so that you're aware your configured value is invalid and can correct it.

RedirectorPage validation

RedirectorPage now uses the Url constraint from symfony/validator to validate the ExternalURL field. It will no longer add http:// to the start of URLs for you if you're missing a protocol - instead, a validation error message will be displayed.

Full list of removed and changed API (by module, alphabetically)