From 7cb613d146c204ad241d6ea52182d0132fc02e03 Mon Sep 17 00:00:00 2001
From: Guy Sartorelli <guy.sartorelli@gmail.com>
Date: Tue, 27 Aug 2024 18:13:45 +1200
Subject: [PATCH] NEW Refactor CLI interaction with Silverstripe app

- Turn sake into a symfony/console app
- Avoid using HTTPRequest for CLI interaction
- Implement abstract hybrid execution path
---
 _config/cli.yml                               |  13 +
 _config/confirmation-middleware.yml           |   6 +-
 _config/dev.yml                               |  25 +-
 _config/extensions.yml                        |   4 +-
 _config/logging.yml                           |   4 +-
 _config/requestprocessors.yml                 |   2 -
 bin/sake                                      |  15 +
 cli-script.php                                |  35 --
 client/styles/debug.css                       |  40 +-
 client/styles/task-runner.css                 |   6 +
 composer.json                                 |   8 +-
 sake                                          | 119 ----
 src/Cli/Command/NavigateCommand.php           |  63 ++
 src/Cli/Command/PolyCommandCliWrapper.php     |  48 ++
 src/Cli/Command/TasksCommand.php              |  85 +++
 src/Cli/CommandLoader/ArrayCommandLoader.php  |  55 ++
 src/Cli/CommandLoader/DevCommandLoader.php    |  16 +
 src/Cli/CommandLoader/DevTaskLoader.php       |  25 +
 .../CommandLoader/InjectorCommandLoader.php   |  73 +++
 src/Cli/CommandLoader/PolyCommandLoader.php   |  82 +++
 src/Cli/LegacyParamArgvInput.php              | 168 ++++++
 src/Cli/Sake.php                              | 282 +++++++++
 src/Control/CLIRequestBuilder.php             |  25 +-
 src/Control/CliController.php                 |  58 --
 src/Control/Director.php                      |   4 +
 .../ConfirmationMiddleware/CliBypass.php      |  39 --
 ...DevelopmentAdminConfirmationMiddleware.php |  23 +-
 .../Middleware/URLSpecialsMiddleware.php      |   2 +-
 src/Control/PolyCommandController.php         |  65 ++
 src/Core/CoreKernel.php                       |   1 -
 src/Core/DatabaselessKernel.php               |  70 ---
 src/Core/Kernel.php                           |   5 +
 src/Core/Manifest/ClassManifest.php           |   2 +-
 src/Dev/BuildTask.php                         | 143 ++---
 src/Dev/Command/ConfigAudit.php               | 117 ++++
 src/Dev/Command/ConfigDump.php                |  66 ++
 src/Dev/Command/DbBuild.php                   | 347 +++++++++++
 src/Dev/Command/DbCleanup.php                 |  90 +++
 src/Dev/Command/DbDefaults.php                |  49 ++
 src/Dev/Command/DevCommand.php                |  58 ++
 src/Dev/Command/GenerateSecureToken.php       |  55 ++
 src/Dev/Deprecation.php                       |   2 +-
 src/Dev/DevBuildController.php                |  98 ---
 src/Dev/DevConfigController.php               | 214 -------
 src/Dev/DevConfirmationController.php         |   1 -
 src/Dev/DevelopmentAdmin.php                  | 422 +++++++------
 src/Dev/MigrationTask.php                     |  84 +--
 src/Dev/State/ExtensionTestState.php          |   2 +-
 src/Dev/TaskRunner.php                        |  94 +--
 src/Dev/Tasks/CleanupTestDatabasesTask.php    |  38 +-
 src/Dev/Tasks/i18nTextCollectorTask.php       |  72 +--
 ...dminExtension.php => DbBuildExtension.php} |  13 +-
 ...tputHandler.php => ErrorOutputHandler.php} |  28 +-
 src/ORM/Connect/DBSchemaManager.php           |   8 +-
 src/ORM/Connect/TempDatabase.php              |   2 +-
 src/ORM/DataObject.php                        |   4 +-
 src/ORM/DataObjectSchema.php                  |   2 +-
 src/ORM/DatabaseAdmin.php                     | 566 ------------------
 src/ORM/FieldType/DBClassNameTrait.php        |   2 +-
 src/PolyExecution/AnsiToHtmlConverter.php     | 143 +++++
 src/PolyExecution/AnsiToHtmlTheme.php         |  30 +
 src/PolyExecution/HtmlOutputFormatter.php     |  58 ++
 src/PolyExecution/HttpRequestInput.php        | 111 ++++
 src/PolyExecution/PolyCommand.php             | 182 ++++++
 src/PolyExecution/PolyOutput.php              | 250 ++++++++
 src/PolyExecution/PolyOutputLogHandler.php    |  30 +
 src/Security/Confirmation/Handler.php         |   5 -
 src/Security/Member.php                       |   2 +-
 src/Security/RandomGenerator.php              |   3 +
 src/Security/Security.php                     |   2 +-
 src/View/SSViewer_DataPresenter.php           |   6 +
 src/i18n/TextCollection/i18nTextCollector.php |   5 +-
 .../SilverStripe/Dev/DevelopmentAdmin.ss      |  37 ++
 templates/SilverStripe/Dev/Parameters.ss      |   8 +
 templates/SilverStripe/Dev/TaskRunner.ss      |  14 +-
 tests/bootstrap/cli.php                       |   3 -
 tests/php/Cli/Command/NavigateCommandTest.php |  99 +++
 .../NavigateCommandTest/TestController.php    |  52 ++
 .../Cli/Command/PolyCommandCliWrapperTest.php |  52 ++
 .../TestPolyCommand.php                       |  43 ++
 tests/php/Cli/LegacyParamArgvInputTest.php    | 159 +++++
 tests/php/Cli/SakeTest.php                    | 307 ++++++++++
 tests/php/Cli/SakeTest/TestBuildTask.php      |  23 +
 tests/php/Cli/SakeTest/TestCommandLoader.php  |  31 +
 tests/php/Cli/SakeTest/TestConfigCommand.php  |  19 +
 .../Cli/SakeTest/TestConfigPolyCommand.php    |  26 +
 tests/php/Cli/SakeTest/TestLoaderCommand.php  |  19 +
 tests/php/Control/DirectorTest.php            |  15 +
 .../Control/DirectorTest/TestPolyCommand.php  |  26 +
 .../php/Control/PolyCommandControllerTest.php |  90 +++
 .../TestPolyCommand.php                       |  59 ++
 tests/php/Dev/BuildTaskTest.php               |  49 +-
 tests/php/Dev/BuildTaskTest/TestBuildTask.php |  27 +
 tests/php/Dev/DevAdminControllerTest.php      | 121 ++--
 .../DevAdminControllerTest/Controller1.php    |   2 +-
 .../DevAdminControllerTest/TestCommand.php    |  32 +
 .../TestHiddenController.php                  |  15 +
 .../TaskRunnerTest_AbstractTask.php           |   6 +-
 .../TaskRunnerTest_ChildOfAbstractTask.php    |   6 +-
 .../TaskRunnerTest_DisabledTask.php           |   8 +-
 .../TaskRunnerTest_EnabledTask.php            |   6 +-
 ...lerTest.php => ErrorOutputHandlerTest.php} |  16 +-
 .../PolyExecution/AnsiToHtmlConverterTest.php |  57 ++
 .../PolyExecution/HttpRequestInputTest.php    | 141 +++++
 tests/php/PolyExecution/PolyOutputTest.php    | 207 +++++++
 tests/php/View/SSViewerTest.php               |  13 +
 .../templates/SSViewerTestLoopArray.ss        |   3 +
 107 files changed, 4839 insertions(+), 1824 deletions(-)
 create mode 100644 _config/cli.yml
 create mode 100755 bin/sake
 delete mode 100755 cli-script.php
 delete mode 100755 sake
 create mode 100644 src/Cli/Command/NavigateCommand.php
 create mode 100644 src/Cli/Command/PolyCommandCliWrapper.php
 create mode 100644 src/Cli/Command/TasksCommand.php
 create mode 100644 src/Cli/CommandLoader/ArrayCommandLoader.php
 create mode 100644 src/Cli/CommandLoader/DevCommandLoader.php
 create mode 100644 src/Cli/CommandLoader/DevTaskLoader.php
 create mode 100644 src/Cli/CommandLoader/InjectorCommandLoader.php
 create mode 100644 src/Cli/CommandLoader/PolyCommandLoader.php
 create mode 100644 src/Cli/LegacyParamArgvInput.php
 create mode 100644 src/Cli/Sake.php
 delete mode 100644 src/Control/CliController.php
 delete mode 100644 src/Control/Middleware/ConfirmationMiddleware/CliBypass.php
 create mode 100644 src/Control/PolyCommandController.php
 delete mode 100644 src/Core/DatabaselessKernel.php
 create mode 100644 src/Dev/Command/ConfigAudit.php
 create mode 100644 src/Dev/Command/ConfigDump.php
 create mode 100644 src/Dev/Command/DbBuild.php
 create mode 100644 src/Dev/Command/DbCleanup.php
 create mode 100644 src/Dev/Command/DbDefaults.php
 create mode 100644 src/Dev/Command/DevCommand.php
 create mode 100644 src/Dev/Command/GenerateSecureToken.php
 delete mode 100644 src/Dev/DevBuildController.php
 delete mode 100644 src/Dev/DevConfigController.php
 rename src/Dev/Validation/{DatabaseAdminExtension.php => DbBuildExtension.php} (55%)
 rename src/Logging/{HTTPOutputHandler.php => ErrorOutputHandler.php} (86%)
 delete mode 100644 src/ORM/DatabaseAdmin.php
 create mode 100644 src/PolyExecution/AnsiToHtmlConverter.php
 create mode 100644 src/PolyExecution/AnsiToHtmlTheme.php
 create mode 100644 src/PolyExecution/HtmlOutputFormatter.php
 create mode 100644 src/PolyExecution/HttpRequestInput.php
 create mode 100644 src/PolyExecution/PolyCommand.php
 create mode 100644 src/PolyExecution/PolyOutput.php
 create mode 100644 src/PolyExecution/PolyOutputLogHandler.php
 create mode 100644 templates/SilverStripe/Dev/DevelopmentAdmin.ss
 create mode 100644 templates/SilverStripe/Dev/Parameters.ss
 create mode 100644 tests/php/Cli/Command/NavigateCommandTest.php
 create mode 100644 tests/php/Cli/Command/NavigateCommandTest/TestController.php
 create mode 100644 tests/php/Cli/Command/PolyCommandCliWrapperTest.php
 create mode 100644 tests/php/Cli/Command/PolyCommandCliWrapperTest/TestPolyCommand.php
 create mode 100644 tests/php/Cli/LegacyParamArgvInputTest.php
 create mode 100644 tests/php/Cli/SakeTest.php
 create mode 100644 tests/php/Cli/SakeTest/TestBuildTask.php
 create mode 100644 tests/php/Cli/SakeTest/TestCommandLoader.php
 create mode 100644 tests/php/Cli/SakeTest/TestConfigCommand.php
 create mode 100644 tests/php/Cli/SakeTest/TestConfigPolyCommand.php
 create mode 100644 tests/php/Cli/SakeTest/TestLoaderCommand.php
 create mode 100644 tests/php/Control/DirectorTest/TestPolyCommand.php
 create mode 100644 tests/php/Control/PolyCommandControllerTest.php
 create mode 100644 tests/php/Control/PolyCommandControllerTest/TestPolyCommand.php
 create mode 100644 tests/php/Dev/BuildTaskTest/TestBuildTask.php
 create mode 100644 tests/php/Dev/DevAdminControllerTest/TestCommand.php
 create mode 100644 tests/php/Dev/DevAdminControllerTest/TestHiddenController.php
 rename tests/php/Logging/{HTTPOutputHandlerTest.php => ErrorOutputHandlerTest.php} (94%)
 create mode 100644 tests/php/PolyExecution/AnsiToHtmlConverterTest.php
 create mode 100644 tests/php/PolyExecution/HttpRequestInputTest.php
 create mode 100644 tests/php/PolyExecution/PolyOutputTest.php
 create mode 100644 tests/php/View/SSViewerTest/templates/SSViewerTestLoopArray.ss

diff --git a/_config/cli.yml b/_config/cli.yml
new file mode 100644
index 00000000000..bc1fb049263
--- /dev/null
+++ b/_config/cli.yml
@@ -0,0 +1,13 @@
+---
+Name: cli-config
+---
+SilverStripe\Core\Injector\Injector:
+  Symfony\Contracts\EventDispatcher\EventDispatcherInterface.sake:
+    class: 'Symfony\Component\EventDispatcher\EventDispatcher'
+  Symfony\Component\Console\Formatter\OutputFormatterInterface:
+    class: 'Symfony\Component\Console\Formatter\OutputFormatter'
+    calls:
+      - ['setDecorated', [true]]
+  SilverStripe\PolyExecution\HtmlOutputFormatter:
+    constructor:
+      formatter: '%$Symfony\Component\Console\Formatter\OutputFormatterInterface'
diff --git a/_config/confirmation-middleware.yml b/_config/confirmation-middleware.yml
index 70089ed4d51..a5250a3c5a6 100644
--- a/_config/confirmation-middleware.yml
+++ b/_config/confirmation-middleware.yml
@@ -22,14 +22,10 @@ SilverStripe\Core\Injector\Injector:
     class: SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass
     type: prototype
 
-  SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass:
-    class: SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass
-    type: prototype
-
   SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass:
     class: SilverStripe\Control\Middleware\ConfirmationMiddleware\HttpMethodBypass
     type: prototype
 
   SilverStripe\Control\Middleware\ConfirmationMiddleware\Url:
     class: SilverStripe\Control\Middleware\ConfirmationMiddleware\Url
-    type: prototype
\ No newline at end of file
+    type: prototype
diff --git a/_config/dev.yml b/_config/dev.yml
index 4c1636bc4b5..6667751f3c4 100644
--- a/_config/dev.yml
+++ b/_config/dev.yml
@@ -2,21 +2,20 @@
 Name: DevelopmentAdmin
 ---
 SilverStripe\Dev\DevelopmentAdmin:
-  registered_controllers:
-    build:
-      controller: SilverStripe\Dev\DevBuildController
-      links:
-        build: 'Build/rebuild this environment. Call this whenever you have updated your project sources'
+  commands:
+    build: 'SilverStripe\Dev\Command\DbBuild'
+    'build/cleanup': 'SilverStripe\Dev\Command\DbCleanup'
+    'build/defaults': 'SilverStripe\Dev\Command\DbDefaults'
+    config: 'SilverStripe\Dev\Command\ConfigDump'
+    'config/audit': 'SilverStripe\Dev\Command\ConfigAudit'
+    generatesecuretoken: 'SilverStripe\Dev\Command\GenerateSecureToken'
+  controllers:
     tasks:
-      controller: SilverStripe\Dev\TaskRunner
-      links:
-        tasks: 'See a list of build tasks to run'
+      class: 'SilverStripe\Dev\TaskRunner'
+      description: 'See a list of build tasks to run'
     confirm:
-      controller: SilverStripe\Dev\DevConfirmationController
-    config:
-      controller: Silverstripe\Dev\DevConfigController
-      links:
-        config: 'View the current config, useful for debugging'
+      class: 'SilverStripe\Dev\DevConfirmationController'
+      skipLink: true
 
 SilverStripe\Dev\CSSContentParser:
   disable_xml_external_entities: true
diff --git a/_config/extensions.yml b/_config/extensions.yml
index 1d77a36dc12..9d928d52879 100644
--- a/_config/extensions.yml
+++ b/_config/extensions.yml
@@ -7,6 +7,6 @@ SilverStripe\Security\Member:
 SilverStripe\Security\Group:
   extensions:
     - SilverStripe\Security\InheritedPermissionFlusher
-SilverStripe\ORM\DatabaseAdmin:
+SilverStripe\Dev\Command\DbBuild:
   extensions:
-    - SilverStripe\Dev\Validation\DatabaseAdminExtension
+    - SilverStripe\Dev\Validation\DbBuildExtension
diff --git a/_config/logging.yml b/_config/logging.yml
index b729fd33710..d49e30b14fc 100644
--- a/_config/logging.yml
+++ b/_config/logging.yml
@@ -52,7 +52,7 @@ Only:
 # Dev handler outputs detailed information including notices
 SilverStripe\Core\Injector\Injector:
   Monolog\Handler\HandlerInterface:
-    class: SilverStripe\Logging\HTTPOutputHandler
+    class: SilverStripe\Logging\ErrorOutputHandler
     constructor:
       - "notice"
     properties:
@@ -66,7 +66,7 @@ Except:
 # CLI errors still show full details
 SilverStripe\Core\Injector\Injector:
   Monolog\Handler\HandlerInterface:
-    class: SilverStripe\Logging\HTTPOutputHandler
+    class: SilverStripe\Logging\ErrorOutputHandler
     constructor:
       - "error"
     properties:
diff --git a/_config/requestprocessors.yml b/_config/requestprocessors.yml
index ad454e9fa13..abf0b6d5efa 100644
--- a/_config/requestprocessors.yml
+++ b/_config/requestprocessors.yml
@@ -60,7 +60,6 @@ SilverStripe\Core\Injector\Injector:
       ConfirmationStorageId: 'url-specials'
       ConfirmationFormUrl: '/dev/confirm'
       Bypasses:
-        - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass'
         - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")'
         - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/confirm")'
       EnforceAuthentication: true
@@ -94,7 +93,6 @@ SilverStripe\Core\Injector\Injector:
       ConfirmationStorageId: 'dev-urls'
       ConfirmationFormUrl: '/dev/confirm'
       Bypasses:
-        - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass'
         - '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")'
       EnforceAuthentication: false
 
diff --git a/bin/sake b/bin/sake
new file mode 100755
index 00000000000..713ab522689
--- /dev/null
+++ b/bin/sake
@@ -0,0 +1,15 @@
+#!/usr/bin/env php
+<?php
+
+use SilverStripe\Cli\Sake;
+
+// Ensure that people can't access this from a web-server
+if (!in_array(PHP_SAPI, ['cli', 'cgi', 'cgi-fcgi'])) {
+    echo 'sake cannot be run from a web request, you have to run it on the command-line.';
+    die();
+}
+
+require_once __DIR__ . '/../src/includes/autoload.php';
+
+$sake = new Sake();
+$sake->run();
diff --git a/cli-script.php b/cli-script.php
deleted file mode 100755
index c21778710f0..00000000000
--- a/cli-script.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-// CLI specific bootstrapping
-use SilverStripe\Control\CLIRequestBuilder;
-use SilverStripe\Control\HTTPApplication;
-use SilverStripe\Core\CoreKernel;
-use SilverStripe\ORM\DB;
-use SilverStripe\ORM\Connect\NullDatabase;
-
-require __DIR__ . '/src/includes/autoload.php';
-
-// Ensure that people can't access this from a web-server
-if (!in_array(PHP_SAPI, ["cli", "cgi", "cgi-fcgi"])) {
-    echo "cli-script.php can't be run from a web request, you have to run it on the command-line.";
-    die();
-}
-
-// Build request and detect flush
-$request = CLIRequestBuilder::createFromEnvironment();
-
-
-$skipDatabase = in_array('--no-database', $argv);
-if ($skipDatabase) {
-    DB::set_conn(new NullDatabase());
-}
-// Default application
-$kernel = new CoreKernel(BASE_PATH);
-if ($skipDatabase) {
-    $kernel->setBootDatabase(false);
-}
-
-$app = new HTTPApplication($kernel);
-$response = $app->handle($request);
-
-$response->output();
diff --git a/client/styles/debug.css b/client/styles/debug.css
index bb3ac83912f..4c41f05e4b7 100644
--- a/client/styles/debug.css
+++ b/client/styles/debug.css
@@ -113,7 +113,6 @@ a:active {
 }
 
 /* Content types */
-.build,
 .options,
 .trace {
     position: relative;
@@ -128,22 +127,28 @@ a:active {
     line-height: 1.3;
 }
 
-.build .success {
+.options .success {
     color: #2b6c2d;
 }
 
-.build .error {
+.options .error {
     color: #d30000;
 }
 
-.build .warning {
+.options .warning {
     color: #8a6d3b;
 }
 
-.build .info {
+.options .info {
     color: #0073c1;
 }
 
+.options .more-details {
+  border: 1px dotted;
+  width: fit-content;
+  padding: 5px;
+}
+
 /* Backtrace styles */
 pre {
     overflow: auto;
@@ -162,3 +167,28 @@ pre span {
 pre .error {
     color: #d30000;
 }
+
+.params {
+  margin-top: 0;
+  margin-left: 10px;
+}
+
+.param {
+  display: flex;
+  align-items: baseline;
+}
+
+.param__name {
+  display: inline-block;
+  font-weight: 200;
+}
+
+.param__name::after {
+  content: ": ";
+}
+
+.param__description {
+  display: inline-block;
+  margin-left: 0.5em;
+  font-style: italic;
+}
diff --git a/client/styles/task-runner.css b/client/styles/task-runner.css
index 33d1e0fa2c0..e44c39ea552 100644
--- a/client/styles/task-runner.css
+++ b/client/styles/task-runner.css
@@ -36,6 +36,12 @@
   margin-bottom: 12px;
 }
 
+.task__help {
+  border: 1px dotted;
+  width: fit-content;
+  padding: 5px;
+}
+
 .task__button {
   border: 1px solid #ced5e1;
   border-radius: 5px;
diff --git a/composer.json b/composer.json
index 4e8fed3e73c..0fdec4af13a 100644
--- a/composer.json
+++ b/composer.json
@@ -19,7 +19,7 @@
         }
     ],
     "bin": [
-        "sake"
+        "bin/sake"
     ],
     "require": {
         "php": "^8.3",
@@ -36,12 +36,14 @@
         "psr/container": "^1.1 || ^2.0",
         "psr/http-message": "^1",
         "sebastian/diff": "^6.0",
+        "sensiolabs/ansi-to-html": "^1.2",
         "silverstripe/config": "^3",
         "silverstripe/assets": "^3",
         "silverstripe/vendor-plugin": "^2",
         "sminnee/callbacklist": "^0.1.1",
         "symfony/cache": "^7.0",
         "symfony/config": "^7.0",
+        "symfony/console": "^7.0",
         "symfony/dom-crawler": "^7.0",
         "symfony/filesystem": "^7.0",
         "symfony/http-foundation": "^7.0",
@@ -84,6 +86,8 @@
     },
     "autoload": {
         "psr-4": {
+            "SilverStripe\\Cli\\": "src/Cli/",
+            "SilverStripe\\Cli\\Tests\\": "tests/php/Cli/",
             "SilverStripe\\Control\\": "src/Control/",
             "SilverStripe\\Control\\Tests\\": "tests/php/Control/",
             "SilverStripe\\Core\\": "src/Core/",
@@ -98,6 +102,8 @@
             "SilverStripe\\Logging\\Tests\\": "tests/php/Logging/",
             "SilverStripe\\ORM\\": "src/ORM/",
             "SilverStripe\\ORM\\Tests\\": "tests/php/ORM/",
+            "SilverStripe\\PolyExecution\\": "src/PolyExecution/",
+            "SilverStripe\\PolyExecution\\Tests\\": "tests/php/PolyExecution/",
             "SilverStripe\\Security\\": "src/Security/",
             "SilverStripe\\Security\\Tests\\": "tests/php/Security/",
             "SilverStripe\\View\\": "src/View/",
diff --git a/sake b/sake
deleted file mode 100755
index 59103445b54..00000000000
--- a/sake
+++ /dev/null
@@ -1,119 +0,0 @@
-#!/usr/bin/env bash
-
-# Check for an argument
-if [ ${1:-""} = "" ]; then
-	echo "SilverStripe Sake
-
-Usage: $0 (command-url) (params)
-Executes a SilverStripe command"
-	exit 1
-fi
-
-command -v which >/dev/null 2>&1
-if [ $? -ne 0 ]; then
-  echo "Error: sake requires the 'which' command to operate." >&2
-  exit 1
-fi
-
-# find the silverstripe installation, looking first at sake
-# bin location, but falling back to current directory
-sakedir=`dirname $0`
-directory="$PWD"
-if [ -f "$sakedir/cli-script.php" ]; then
-    # Calling sake from vendor/silverstripe/framework/sake
-	framework="$sakedir"
-	base="$sakedir/../../.."
-elif [ -f "$sakedir/../silverstripe/framework/cli-script.php" ]; then
-    # Calling sake from vendor/bin/sake
-	framework="$sakedir/../silverstripe/framework"
-	base="$sakedir/../.."
-elif [ -f "$directory/vendor/silverstripe/framework/cli-script.php" ]; then
-    # Vendor framework (from base) if sake installed globally
-    framework="$directory/vendor/silverstripe/framework"
-    base=.
-elif [ -f "$directory/framework/cli-script.php" ]; then
-    # Legacy directory (from base) if sake installed globally
-    framework="$directory/framework"
-    base=.
-else
-    echo "Can't find cli-script.php in $sakedir"
-    exit 1
-fi
-
-# Find the PHP binary
-for candidatephp in php php5; do
-	if [ "`which $candidatephp 2>/dev/null`" -a -f "`which $candidatephp 2>/dev/null`" ]; then
-		php=`which $candidatephp 2>/dev/null`
-		break
-	fi
-done
-if [ "$php" = "" ]; then
-	echo "Can't find any php binary"
-	exit 2
-fi
-
-################################################################################################
-## Installation to /usr/bin
-
-if [ "$1" = "installsake" ]; then
-	echo "Installing sake to /usr/local/bin..."
-	rm -rf /usr/local/bin/sake
-	cp $0 /usr/local/bin
-	exit 0
-fi
-
-################################################################################################
-## Process control
-
-if [ "$1" = "-start" ]; then
-	if [ "`which daemon`" = "" ]; then
-		echo "You need to install the 'daemon' tool.  In debian, go 'sudo apt-get install daemon'"
-		exit 1
-	fi
-
-	if [ ! -f $base/$2.pid ]; then
-		echo "Starting service $2 $3"
-		touch $base/$2.pid
-		pidfile=`realpath $base/$2.pid`
-
-		outlog=$base/$2.log
-		errlog=$base/$2.err
-
-		echo "Logging to $outlog"
-
-		sake=`realpath $0`
-		base=`realpath $base`
-
-		# if third argument is not explicitly given, copy from second argument
-		if [ "$3" = "" ]; then
-			url=$2
-		else
-			url=$3
-		fi
-
-		processname=$2
-
-		daemon -n $processname -r -D $base --pidfile=$pidfile --stdout=$outlog --stderr=$errlog $sake $url
-	else
-		echo "Service $2 seems to already be running"
-	fi
-		exit 0
-fi
-
-if [ "$1" = "-stop" ]; then
-	pidfile=$base/$2.pid
-	if [ -f $pidfile ]; then
-		echo "Stopping service $2"
-
-		kill -KILL `cat $pidfile`
-		unlink $pidfile
-	else
-		echo "Service $2 doesn't seem to be running."
-	fi
-	exit 0
-fi
-
-################################################################################################
-## Basic execution
-
-"$php" "$framework/cli-script.php" "${@}"
diff --git a/src/Cli/Command/NavigateCommand.php b/src/Cli/Command/NavigateCommand.php
new file mode 100644
index 00000000000..738ec495bda
--- /dev/null
+++ b/src/Cli/Command/NavigateCommand.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace SilverStripe\Cli\Command;
+
+use SilverStripe\Control\CLIRequestBuilder;
+use SilverStripe\Control\HTTPApplication;
+use SilverStripe\Core\Injector\Injector;
+use SilverStripe\Core\Kernel;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Command that simulates an HTTP request to the Silverstripe App based on CLI input.
+ */
+#[AsCommand(name: 'navigate', description: 'Navigate to a URL on your site via a simulated HTTP request')]
+class NavigateCommand extends Command
+{
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        // Convert input into HTTP request.
+        // Use the kernel we already booted for consistency and performance reasons
+        $app = new HTTPApplication(Injector::inst()->get(Kernel::class));
+        $request = CLIRequestBuilder::createFromInput($input);
+
+        // Handle request and output resonse body
+        $response = $app->handle($request);
+        $output->writeln($response->getBody(), OutputInterface::OUTPUT_RAW);
+
+        // Transform HTTP status code into sensible exit code
+        $responseCode = $response->getStatusCode();
+        $output->writeln("<options=bold>RESPONSE STATUS CODE WAS {$responseCode}</>", OutputInterface::VERBOSITY_VERBOSE);
+        // We can't use the response code for unsuccessful requests directly as the exit code
+        // because symfony gives us an exit code ceiling of 255. So just use the regular constants.
+        return match (true) {
+            ($responseCode >= 200 && $responseCode < 400) => Command::SUCCESS,
+            ($responseCode >= 400 && $responseCode < 500) => Command::INVALID,
+            default => Command::FAILURE,
+        };
+    }
+
+    protected function configure(): void
+    {
+        $this->setHelp(<<<HELP
+        Use verbose mode to see the HTTP response status code.
+        The <info>get-var</> arg can either be separated GET variables, or a full query string
+          e.g: <comment>sake navigate about-us/team q=test arrayval[]=value1 arrayval[]=value2</>
+          e.g: <comment>sake navigate about-us/team q=test<info>&</info>arrayval[]=value1<info>&</info>arrayval[]=value2</>
+        HELP);
+        $this->addArgument(
+            'path',
+            InputArgument::REQUIRED,
+            'Relative path to navigate to (e.g: <info>about-us/team</>). Can optionally start with a "/"'
+        );
+        $this->addArgument(
+            'get-var',
+            InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
+            'Optional GET variables or a query string'
+        );
+    }
+}
diff --git a/src/Cli/Command/PolyCommandCliWrapper.php b/src/Cli/Command/PolyCommandCliWrapper.php
new file mode 100644
index 00000000000..c5fd8587795
--- /dev/null
+++ b/src/Cli/Command/PolyCommandCliWrapper.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace SilverStripe\Cli\Command;
+
+use SilverStripe\Core\Injector\Injectable;
+use SilverStripe\PolyExecution\PolyCommand;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputDefinition;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Wraps a PolyCommand for use in CLI.
+ */
+class PolyCommandCliWrapper extends Command
+{
+    use Injectable;
+
+    private PolyCommand $command;
+
+    public function __construct(PolyCommand $command, string $alias = '')
+    {
+        $this->command = $command;
+        parent::__construct($command->getName());
+        if ($alias) {
+            $this->setAliases([$alias]);
+        }
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $polyOutput = PolyOutput::create(
+            PolyOutput::FORMAT_ANSI,
+            $output->getVerbosity(),
+            $output->isDecorated(),
+            $output
+        );
+        return $this->command->run($input, $polyOutput);
+    }
+
+    protected function configure(): void
+    {
+        $this->setDescription($this->command::getDescription());
+        $this->setDefinition(new InputDefinition($this->command->getOptions()));
+        $this->setHelp($this->command->getHelp());
+    }
+}
diff --git a/src/Cli/Command/TasksCommand.php b/src/Cli/Command/TasksCommand.php
new file mode 100644
index 00000000000..8e5742490f7
--- /dev/null
+++ b/src/Cli/Command/TasksCommand.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace SilverStripe\Cli\Command;
+
+use SilverStripe\Cli\CommandLoader\DevTaskLoader;
+use SilverStripe\Cli\Sake;
+use Symfony\Component\Console\Application;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Command\ListCommand;
+use Symfony\Component\Console\Completion\CompletionInput;
+use Symfony\Component\Console\Completion\CompletionSuggestions;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Command that runs `sake list tasks` under the hood to list all of the available tasks.
+ * Useful when you have too many tasks to show in the main commands list.
+ *
+ * Note the description is blue so it stands out, to avoid developers missing it if they add a new
+ * task and suddenly they don't see the tasks in their main commands list anymore.
+ */
+#[AsCommand(name: 'tasks', description: '<fg=blue>See a list of build tasks to run</>')]
+class TasksCommand extends Command
+{
+    private Command $listCommand;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->listCommand = new ListCommand();
+        $this->setDefinition($this->listCommand->getDefinition());
+    }
+
+    public function setApplication(?Application $application): void
+    {
+        $this->listCommand->setApplication($application);
+        parent::setApplication($application);
+    }
+
+    public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
+    {
+        if ($input->getCompletionType() === CompletionInput::TYPE_ARGUMENT_VALUE) {
+            // Make this command transparent to completion, so we can `sake tasks<tab>` and see all tasks
+            if ($input->getCompletionValue() === $this->getName()) {
+                $taskLoader = DevTaskLoader::create();
+                $suggestions->suggestValues($taskLoader->getNames());
+            }
+            // Don't allow completion for the namespace argument, because we will override their value anyway
+            return;
+        }
+        // Still allow completion for options e.g. --format
+        parent::complete($input, $suggestions);
+    }
+
+    public function isHidden(): bool
+    {
+        return !$this->getApplication()->shouldHideTasks();
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        // Explicitly don't allow any namespace other than tasks
+        $input->setArgument('namespace', 'tasks');
+        // We have to call execute() here instead of run(), because run() would re-bind
+        // the input which would throw away the namespace argument.
+        $this->getApplication()?->setIgnoreTaskLimit(true);
+        $exitCode = $this->listCommand->execute($input, $output);
+        $this->getApplication()?->setIgnoreTaskLimit(false);
+        return $exitCode;
+    }
+
+    protected function configure()
+    {
+        $sakeClass = Sake::class;
+        $this->setHelp(<<<HELP
+        If you want to display the tasks in the main commands list, update the <info>$sakeClass.max_tasks_to_display</info> configuration.
+        <comment>
+        $sakeClass:
+          max_tasks_to_display: 50
+        </>
+        Set the value to 0 to always display tasks in the main command list regardless of how many there are.
+        HELP);
+    }
+}
diff --git a/src/Cli/CommandLoader/ArrayCommandLoader.php b/src/Cli/CommandLoader/ArrayCommandLoader.php
new file mode 100644
index 00000000000..9eff04bc5e9
--- /dev/null
+++ b/src/Cli/CommandLoader/ArrayCommandLoader.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace SilverStripe\Cli\CommandLoader;
+
+use SilverStripe\Core\Injector\Injectable;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
+use Symfony\Component\Console\Exception\CommandNotFoundException;
+
+/**
+ * Command loader that holds more command loaders
+ */
+class ArrayCommandLoader implements CommandLoaderInterface
+{
+    use Injectable;
+
+    /**
+     * @var array<CommandLoaderInterface>
+     */
+    private array $loaders = [];
+
+    public function __construct(array $loaders)
+    {
+        $this->loaders = $loaders;
+    }
+
+    public function get(string $name): Command
+    {
+        foreach ($this->loaders as $loader) {
+            if ($loader->has($name)) {
+                return $loader->get($name);
+            }
+        }
+        throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
+    }
+
+    public function has(string $name): bool
+    {
+        foreach ($this->loaders as $loader) {
+            if ($loader->has($name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public function getNames(): array
+    {
+        $names = [];
+        foreach ($this->loaders as $loader) {
+            $names = array_merge($names, $loader->getNames());
+        }
+        return array_unique($names);
+    }
+}
diff --git a/src/Cli/CommandLoader/DevCommandLoader.php b/src/Cli/CommandLoader/DevCommandLoader.php
new file mode 100644
index 00000000000..bd67b3de3a8
--- /dev/null
+++ b/src/Cli/CommandLoader/DevCommandLoader.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace SilverStripe\Cli\CommandLoader;
+
+use SilverStripe\Dev\DevelopmentAdmin;
+
+/**
+ * Get commands for the controllers registered in DevelopmentAdmin
+ */
+class DevCommandLoader extends PolyCommandLoader
+{
+    protected function getCommands(): array
+    {
+        return DevelopmentAdmin::singleton()->getCommands();
+    }
+}
diff --git a/src/Cli/CommandLoader/DevTaskLoader.php b/src/Cli/CommandLoader/DevTaskLoader.php
new file mode 100644
index 00000000000..cea0384e895
--- /dev/null
+++ b/src/Cli/CommandLoader/DevTaskLoader.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace SilverStripe\Cli\CommandLoader;
+
+use SilverStripe\Dev\TaskRunner;
+
+/**
+ * Get commands for the dev:tasks namespace
+ */
+class DevTaskLoader extends PolyCommandLoader
+{
+    protected function getCommands(): array
+    {
+        $commands = [];
+        foreach (TaskRunner::singleton()->getTaskList() as $name => $class) {
+            $singleton = $class::singleton();
+            // Don't add disabled tasks.
+            // No need to check canRunInCli() - the superclass will take care of that.
+            if ($singleton->isEnabled()) {
+                $commands['dev/' . str_replace('tasks:', 'tasks/', $name)] = $class;
+            }
+        };
+        return $commands;
+    }
+}
diff --git a/src/Cli/CommandLoader/InjectorCommandLoader.php b/src/Cli/CommandLoader/InjectorCommandLoader.php
new file mode 100644
index 00000000000..d9aff575a2b
--- /dev/null
+++ b/src/Cli/CommandLoader/InjectorCommandLoader.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace SilverStripe\Cli\CommandLoader;
+
+use LogicException;
+use SilverStripe\Cli\Command\PolyCommandCliWrapper;
+use SilverStripe\Cli\Sake;
+use SilverStripe\Core\Injector\Injector;
+use SilverStripe\PolyExecution\PolyCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
+use Symfony\Component\Console\Exception\CommandNotFoundException;
+
+/**
+ * Command loader that loads commands from the injector if they were registered with Sake.
+ */
+class InjectorCommandLoader implements CommandLoaderInterface
+{
+    private array $commands = [];
+    private array $commandAliases = [];
+
+    public function get(string $name): Command
+    {
+        if (!$this->has($name)) {
+            throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
+        }
+        return $this->commands[$name] ?? $this->commandAliases[$name];
+    }
+
+    public function has(string $name): bool
+    {
+        $this->initCommands();
+        return array_key_exists($name, $this->commands) || array_key_exists($name, $this->commandAliases);
+    }
+
+    public function getNames(): array
+    {
+        $this->initCommands();
+        return array_keys($this->commands);
+    }
+
+    private function initCommands(): void
+    {
+        if (empty($this->commands)) {
+            $commandClasses = Sake::config()->get('commands');
+            foreach ($commandClasses as $class) {
+                if ($class === null) {
+                    // Allow unsetting commands via yaml
+                    continue;
+                }
+                $command = Injector::inst()->create($class);
+                // Wrap poly commands (if they're allowed to be run)
+                if ($command instanceof PolyCommand) {
+                    if (!$command::canRunInCli()) {
+                        continue;
+                    }
+                    $command = PolyCommandCliWrapper::create($command);
+                }
+                /** @var Command $command */
+                if (!$command->getName()) {
+                    throw new LogicException(sprintf(
+                        'The command defined in "%s" cannot have an empty name.',
+                        get_debug_type($command)
+                    ));
+                }
+                $this->commands[$command->getName()] = $command;
+                foreach ($command->getAliases() as $alias) {
+                    $this->commandAliases[$alias] = $command;
+                }
+            }
+        }
+    }
+}
diff --git a/src/Cli/CommandLoader/PolyCommandLoader.php b/src/Cli/CommandLoader/PolyCommandLoader.php
new file mode 100644
index 00000000000..78700665d87
--- /dev/null
+++ b/src/Cli/CommandLoader/PolyCommandLoader.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace SilverStripe\Cli\CommandLoader;
+
+use SilverStripe\Cli\Command\PolyCommandCliWrapper;
+use SilverStripe\Core\Injector\Injectable;
+use SilverStripe\PolyExecution\PolyCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
+use Symfony\Component\Console\Exception\CommandNotFoundException;
+
+/**
+ * Get commands for PolyCommand classes
+ */
+abstract class PolyCommandLoader implements CommandLoaderInterface
+{
+    use Injectable;
+
+    private array $commands = [];
+    private array $commandAliases = [];
+
+    public function get(string $name): Command
+    {
+        if (!$this->has($name)) {
+            throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
+        }
+        $info = $this->commands[$name] ?? $this->commandAliases[$name];
+        /** @var PolyCommand $commandClass */
+        $commandClass = $info['class'];
+        $polyCommand = $commandClass::create();
+        return PolyCommandCliWrapper::create($polyCommand, $info['alias']);
+    }
+
+    public function has(string $name): bool
+    {
+        $this->initCommands();
+        return array_key_exists($name, $this->commands) || array_key_exists($name, $this->commandAliases);
+    }
+
+    public function getNames(): array
+    {
+        $this->initCommands();
+        return array_keys($this->commands);
+    }
+
+    /**
+     * Get the array of PolyCommand objects this loader is responsible for.
+     * Do not filter canRunInCli().
+     *
+     * @return array<string, PolyCommand> Associative array of commands.
+     * The key is an alias, or if no alias exists, the name of the command.
+     */
+    abstract protected function getCommands(): array;
+
+    /**
+     * Limit to only the commands that are allowed to be run in CLI.
+     */
+    private function initCommands(): void
+    {
+        if (empty($this->commands)) {
+            $commands = $this->getCommands();
+            /** @var PolyCommand $class */
+            foreach ($commands as $alias => $class) {
+                if (!$class::canRunInCli()) {
+                    continue;
+                }
+                $commandName = $class::getName();
+                $hasAlias = $alias !== $commandName;
+                $this->commands[$commandName] = [
+                    'class' => $class,
+                    'alias' => $hasAlias ? $alias : null,
+                ];
+                if ($hasAlias) {
+                    $this->commandAliases[$alias] = [
+                        'class' => $class,
+                        'alias' => $alias,
+                    ];
+                }
+            }
+        }
+    }
+}
diff --git a/src/Cli/LegacyParamArgvInput.php b/src/Cli/LegacyParamArgvInput.php
new file mode 100644
index 00000000000..5d1cadc3115
--- /dev/null
+++ b/src/Cli/LegacyParamArgvInput.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace SilverStripe\Cli;
+
+use SilverStripe\Dev\Deprecation;
+use SilverStripe\ORM\ArrayLib;
+use Symfony\Component\Console\Input\ArgvInput;
+use Symfony\Component\Console\Input\InputDefinition;
+
+/**
+ * Represents an input coming from the CLI arguments - but converts legacy arg-style parameters to flags.
+ *
+ * e.g. `ddev dev:build flush=1` is converted to `ddev dev:build --flush`.
+ * Doesn't convert anything that isn't explicitly an InputOption in the relevant InputDefinition.
+ * Removes the parameters from the input args (e.g. doesn't become `ddev dev:build flush=1 --flush`).
+ *
+ * @deprecated 6.0.0 Use Symfony\Component\Console\Input\ArgvInput instead.
+ */
+class LegacyParamArgvInput extends ArgvInput
+{
+    /**
+     * Input from the command line.
+     *
+     * We need a separate copy of this because the one held by the parent class is private
+     * and not exposed until symfony/console 7.1
+     */
+    private array $argv;
+
+    public function __construct(?array $argv = null, ?InputDefinition $definition = null)
+    {
+        Deprecation::withNoReplacement(
+            fn() => Deprecation::notice('6.0.0', 'Use ' . ArgvInput::class . ' instead', Deprecation::SCOPE_CLASS)
+        );
+        $argv ??= $_SERVER['argv'] ?? [];
+        parent::__construct($argv, $definition);
+        // Strip the application name, matching what the parent class did with its copy
+        array_shift($argv);
+        $this->argv = $argv;
+    }
+
+    public function hasParameterOption(string|array $values, bool $onlyParams = false): bool
+    {
+        if (parent::hasParameterOption($values, $onlyParams)) {
+            return true;
+        }
+        return $this->hasLegacyParameterOption($values);
+    }
+
+    public function getParameterOption(string|array $values, string|bool|int|float|array|null $default = false, bool $onlyParams = false): mixed
+    {
+        if (parent::hasParameterOption($values, $onlyParams)) {
+            return parent::getParameterOption($values, $default, $onlyParams);
+        }
+        return $this->getLegacyParameterOption($values, $default);
+    }
+
+    /**
+     * Binds the current Input instance with the given arguments and options.
+     *
+     * Also converts any arg-style params into true flags, based on the options defined.
+     */
+    public function bind(InputDefinition $definition): void
+    {
+        // Convert arg-style params into flags
+        $tokens = $this->argv;
+        $convertedFlags = [];
+        $hadLegacyParams = false;
+        foreach ($definition->getOptions() as $option) {
+            $flagName = '--' . $option->getName();
+            // Check if there is a legacy param first. This saves us from accidentally getting
+            // values that come after the end of options (--) signal
+            if (!$this->hasLegacyParameterOption($flagName)) {
+                continue;
+            }
+            // Get the value from the legacy param
+            $value = $this->getLegacyParameterOption($flagName);
+            if ($value && !$this->hasLegacyParameterOption($flagName . '=' . $value)) {
+                // symfony/console will try to get the value from the next argument if the current argument ends with `=`
+                // We don't want to count that as the value, so double check it.
+                $value = null;
+            } elseif ($option->acceptValue()) {
+                if ($value === '' || $value === null) {
+                    $convertedFlags[] = $flagName;
+                } else {
+                    $convertedFlags[] = $flagName . '=' . $value;
+                }
+            } else {
+                // If the option doesn't accept a value, only add the flag if the value is true.
+                $valueAsBool = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true;
+                if ($valueAsBool) {
+                    $convertedFlags[] = $flagName;
+                }
+            }
+            $hadLegacyParams = true;
+            // Remove the legacy param from the token set
+            foreach ($tokens as $i => $token) {
+                if (str_starts_with($token, $option->getName() . '=')) {
+                    unset($tokens[$i]);
+                    break;
+                }
+            }
+        }
+        if (!empty($convertedFlags)) {
+            // Make sure it's before the end of options (--) signal if there is one.
+            $tokens = ArrayLib::insertBefore($tokens, $convertedFlags, '--', true, true);
+        }
+        if ($hadLegacyParams) {
+            // We only want the warning once regardless of how many params there are.
+            Deprecation::notice(
+                '6.0.0',
+                'Using `param=value` style flags is deprecated. Use `--flag=value` CLI flags instead.',
+                Deprecation::SCOPE_GLOBAL
+            );
+            // Set the new tokens so the parent class can operate on them.
+            // Specifically skip setting $this->argv in case someone decides to bind to a different
+            // input definition afterwards for whatever reason.
+            parent::setTokens($tokens);
+        }
+        parent::bind($definition);
+    }
+
+    protected function setTokens(array $tokens): void
+    {
+        $this->argv = $tokens;
+        parent::setTokens($tokens);
+    }
+
+    private function hasLegacyParameterOption(string|array $values): bool
+    {
+        $values = $this->getLegacyParamsForFlags((array) $values);
+        if (empty($values)) {
+            return false;
+        }
+        return parent::hasParameterOption($values, true);
+    }
+
+    public function getLegacyParameterOption(string|array $values, string|bool|int|float|array|null $default = false): mixed
+    {
+        $values = $this->getLegacyParamsForFlags((array) $values);
+        if (empty($values)) {
+            return $default;
+        }
+        return parent::getParameterOption($values, $default, true);
+    }
+
+    /**
+     * Given a set of flag names, return what they would be called in the legacy format.
+     */
+    private function getLegacyParamsForFlags(array $flags): array
+    {
+        $legacyParams = [];
+        foreach ($flags as $flag) {
+            // Only allow full flags e.g. `--flush`, not shortcuts like `-f`
+            if (!str_starts_with($flag, '--')) {
+                continue;
+            }
+            // Convert to legacy format, e.g. `--flush` becomes `flush=`
+            // but if there's already an equals e.g. `--flush=1` keep it (`flush=1`)
+            // because the developer is checking for a specific value set to the flag.
+            $flag = ltrim($flag, '-');
+            if (!str_contains($flag, '=')) {
+                $flag .= '=';
+            }
+            $legacyParams[] = $flag;
+        }
+        return $legacyParams;
+    }
+}
diff --git a/src/Cli/Sake.php b/src/Cli/Sake.php
new file mode 100644
index 00000000000..027b5fde039
--- /dev/null
+++ b/src/Cli/Sake.php
@@ -0,0 +1,282 @@
+<?php
+
+namespace SilverStripe\Cli;
+
+use SilverStripe\Cli\Command\NavigateCommand;
+use SilverStripe\Cli\Command\TasksCommand;
+use SilverStripe\Cli\CommandLoader\ArrayCommandLoader;
+use SilverStripe\Cli\CommandLoader\DevCommandLoader;
+use SilverStripe\Cli\CommandLoader\DevTaskLoader;
+use SilverStripe\Cli\CommandLoader\InjectorCommandLoader;
+use SilverStripe\Core\Config\Configurable;
+use SilverStripe\Core\CoreKernel;
+use SilverStripe\Core\Injector\Injector;
+use SilverStripe\Core\Kernel;
+use SilverStripe\Core\Manifest\VersionProvider;
+use SilverStripe\Dev\Deprecation;
+use Symfony\Component\Console\Application;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Command\DumpCompletionCommand;
+use Symfony\Component\Console\Command\HelpCommand;
+use Symfony\Component\Console\Command\ListCommand;
+use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
+use Symfony\Component\Console\Completion\CompletionInput;
+use Symfony\Component\Console\Completion\CompletionSuggestions;
+use Symfony\Component\Console\Completion\Suggestion;
+use Symfony\Component\Console\Input\InputDefinition;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * CLI application for running commands against a Silverstripe CMS project
+ * Boots up a full kernel, using the same configuration and database the web server uses.
+ */
+class Sake extends Application
+{
+    use Configurable;
+
+    /**
+     * Commands that can be run. These commands will be instantiated via the Injector.
+     * Does not include commands in the dev/ namespace (see command_loaders).
+     *
+     * @var array<Command>
+     */
+    private static array $commands = [
+        'navigate' => NavigateCommand::class,
+    ];
+
+    /**
+     * Command loaders for dynamically adding commands to sake.
+     * These loaders will be instantiated via the Injector.
+     *
+     * @var array<CommandLoaderInterface>
+     */
+    private static array $command_loaders = [
+        'dev-commands' => DevCommandLoader::class,
+        'dev-tasks' => DevTaskLoader::class,
+        'injected' => InjectorCommandLoader::class,
+    ];
+
+    /**
+     * Maximum number of tasks to display in the main command list.
+     *
+     * If there are more tasks than this, they will be hidden from the main command list - running `sake tasks` will show them.
+     * Set to 0 to always show tasks in the main list.
+     */
+    private static int $max_tasks_to_display = 20;
+
+    /**
+     * Set this to true to hide the "completion" command.
+     * Useful if you never intend to set up shell completion, or if you've already done so.
+     */
+    private static bool $hide_completion_command = false;
+
+    private ?Kernel $kernel;
+
+    private bool $ignoreTaskLimit = false;
+
+    public function __construct(?Kernel $kernel = null)
+    {
+        $this->kernel = $kernel;
+        parent::__construct('Silverstripe Sake');
+    }
+
+    public function getVersion(): string
+    {
+        return VersionProvider::singleton()->getVersion();
+    }
+
+    public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
+    {
+        $input = $input ?? new LegacyParamArgvInput();
+        $flush = $input->hasParameterOption('--flush', true) || $input->getFirstArgument() === 'flush';
+        $bootDatabase = !$input->hasParameterOption('--no-database', true);
+
+        $managingKernel = !$this->kernel;
+        if ($managingKernel) {
+            // Instantiate the kernel if we weren't given a pre-loaded one
+            $this->kernel = new CoreKernel(BASE_PATH);
+        }
+        try {
+            // Boot if not already booted
+            if (!$this->kernel->getBooted()) {
+                if ($this->kernel instanceof CoreKernel) {
+                    $this->kernel->setBootDatabase($bootDatabase);
+                }
+                $this->kernel->boot($flush);
+            }
+            // Allow developers to hook into symfony/console events
+            /** @var EventDispatcherInterface $dispatcher */
+            $dispatcher = Injector::inst()->get(EventDispatcherInterface::class . '.sake');
+            $this->setDispatcher($dispatcher);
+            // Add commands and finally execute
+            $this->addCommandLoadersFromConfig();
+            return parent::run($input, $output);
+        } finally {
+            // If we instantiated the kernel, we're also responsible for shutting it down.
+            if ($managingKernel) {
+                $this->kernel->shutdown();
+            }
+        }
+    }
+
+    public function all(?string $namespace = null): array
+    {
+        $commands = parent::all($namespace);
+        // If number of tasks is greater than the limit, hide them from the main comands list.
+        $maxTasks = Sake::config()->get('max_tasks_to_display');
+        if (!$this->ignoreTaskLimit && $maxTasks > 0 && $namespace === null) {
+            $tasks = [];
+            // Find all commands in the tasks: namespace
+            foreach (array_keys($commands) as $name) {
+                if (str_starts_with($name, 'tasks:') || str_starts_with($name, 'dev/tasks/')) {
+                    $tasks[] = $name;
+                }
+            }
+            if (count($tasks) > $maxTasks) {
+                // Hide the commands
+                foreach ($tasks as $name) {
+                    unset($commands[$name]);
+                }
+            }
+        }
+        return $commands;
+    }
+
+    /**
+     * Check whether tasks should currently be hidden from the main command list
+     */
+    public function shouldHideTasks(): bool
+    {
+        $maxLimit = Sake::config()->get('max_tasks_to_display');
+        return $maxLimit > 0 && count($this->all('tasks')) > $maxLimit;
+    }
+
+    /**
+     * Set whether the task limit should be ignored.
+     * Used by the tasks command and completion to allow listing tasks when there's too many of them
+     * to list in the main command list.
+     */
+    public function setIgnoreTaskLimit(bool $ignore): void
+    {
+        $this->ignoreTaskLimit = $ignore;
+    }
+
+    public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
+    {
+        // Make sure tasks can always be shown in completion even if there's too many of them to list
+        // in the main command list.
+        $this->setIgnoreTaskLimit(true);
+
+        // Remove legacy dev/* aliases from completion suggestions, but only
+        // if the user isn't explicitly looking for them (i.e. hasn't typed anything yet)
+        if (CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType()
+            && $input->getCompletionName() === 'command'
+            && $input->getCompletionValue() === ''
+        ) {
+            foreach ($this->all() as $name => $command) {
+                // skip hidden commands
+                // skip aliased commands as they get added below
+                if ($command->isHidden() || $command->getName() !== $name) {
+                    continue;
+                }
+                $suggestions->suggestValue(new Suggestion($command->getName(), $command->getDescription()));
+                foreach ($command->getAliases() as $name) {
+                    // Skip legacy dev aliases
+                    if (str_starts_with($name, 'dev/')) {
+                        continue;
+                    }
+                    $suggestions->suggestValue(new Suggestion($name, $command->getDescription()));
+                }
+            }
+
+            return;
+        } else {
+            // For everything else, use the superclass
+            parent::complete($input, $suggestions);
+        }
+        $this->setIgnoreTaskLimit(false);
+    }
+
+    protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int
+    {
+        $name = $command->getName() ?? '';
+        $nameUsedAs = $input->getFirstArgument() ?? '';
+        if (str_starts_with($nameUsedAs, 'dev/')) {
+            Deprecation::notice(
+                '6.0.0',
+                "Using the command with the name '$nameUsedAs' is deprecated. Use '$name' instead",
+                Deprecation::SCOPE_GLOBAL
+            );
+        }
+        return parent::doRunCommand($command, $input, $output);
+    }
+
+    protected function getDefaultInputDefinition(): InputDefinition
+    {
+        $definition = parent::getDefaultInputDefinition();
+        $definition->addOptions([
+            new InputOption('no-database', null, InputOption::VALUE_NONE, 'Run the command without connecting to the database'),
+            new InputOption('flush', 'f', InputOption::VALUE_NONE, 'Flush the cache before running the command'),
+        ]);
+        return $definition;
+    }
+
+    protected function getDefaultCommands(): array
+    {
+        $commands = parent::getDefaultCommands();
+
+        // Hide commands that are just cluttering up the list
+        $toHide = [
+            // List is the default command, and you have to have used it to see it anyway.
+            ListCommand::class,
+            // The --help flag is more common and is already displayed.
+            HelpCommand::class,
+        ];
+        // Completion is just clutter if you've already used it or aren't going to.
+        if (Sake::config()->get('hide_completion_command')) {
+            $toHide[] = DumpCompletionCommand::class;
+        }
+        foreach ($commands as $command) {
+            if (in_array(get_class($command), $toHide)) {
+                $command->setHidden(true);
+            }
+        }
+
+        $commands[] = $this->createFlushCommand();
+        $commands[] = new TasksCommand();
+
+        return $commands;
+    }
+
+    private function addCommandLoadersFromConfig(): void
+    {
+        $loaderClasses = Sake::config()->get('command_loaders');
+        $loaders = [];
+        foreach ($loaderClasses as $class) {
+            if ($class === null) {
+                // Allow unsetting loaders via yaml
+                continue;
+            }
+            $loaders[] = Injector::inst()->create($class);
+        }
+        $this->setCommandLoader(ArrayCommandLoader::create($loaders));
+    }
+
+    /**
+     * Creates a dummy "flush" command for when you just want to flush without running another command.
+     */
+    private function createFlushCommand(): Command
+    {
+        $command = new Command('flush');
+        $command->setDescription('Flush the cache (or use the <info>--flush</info> flag with any command)');
+        $command->setCode(function (InputInterface $input, OutputInterface $ouput) {
+            // Actual flushing happens in `run()` when booting the kernel, so there's nothing to do here.
+            $ouput->writeln('Cache flushed.');
+            return Command::SUCCESS;
+        });
+        return $command;
+    }
+}
diff --git a/src/Control/CLIRequestBuilder.php b/src/Control/CLIRequestBuilder.php
index e122288d5e2..2aa827c7632 100644
--- a/src/Control/CLIRequestBuilder.php
+++ b/src/Control/CLIRequestBuilder.php
@@ -3,6 +3,7 @@
 namespace SilverStripe\Control;
 
 use SilverStripe\Core\Environment;
+use Symfony\Component\Console\Input\InputInterface;
 
 /**
  * CLI specific request building logic
@@ -33,7 +34,7 @@ public static function cleanEnvironment(array $variables)
             'HTTP_USER_AGENT' => 'CLI',
         ], $variables['_SERVER']);
 
-        /**
+        /*
          * Process arguments and load them into the $_GET and $_REQUEST arrays
          * For example,
          * sake my/url somearg otherarg key=val --otherkey=val third=val&fourth=val
@@ -48,12 +49,12 @@ public static function cleanEnvironment(array $variables)
         if (isset($variables['_SERVER']['argv'][2])) {
             $args = array_slice($variables['_SERVER']['argv'] ?? [], 2);
             foreach ($args as $arg) {
-                if (strpos($arg ?? '', '=') == false) {
+                if (strpos($arg ?? '', '=') === false) {
                     $variables['_GET']['args'][] = $arg;
                 } else {
                     $newItems = [];
                     parse_str((substr($arg ?? '', 0, 2) == '--') ? substr($arg, 2) : $arg, $newItems);
-                    $variables['_GET'] = array_merge($variables['_GET'], $newItems);
+                    $variables['_GET'] = array_merge_recursive($variables['_GET'], $newItems);
                 }
             }
             $_REQUEST = array_merge($_REQUEST, $variables['_GET']);
@@ -64,7 +65,7 @@ public static function cleanEnvironment(array $variables)
             $variables['_GET']['url'] = $variables['_SERVER']['argv'][1];
             $variables['_SERVER']['REQUEST_URI'] = $variables['_SERVER']['argv'][1];
         }
-        
+
         // Set 'HTTPS' and 'SSL' flag for CLI depending on SS_BASE_URL scheme value.
         $scheme = parse_url(Environment::getEnv('SS_BASE_URL') ?? '', PHP_URL_SCHEME);
         if ($scheme == 'https') {
@@ -80,9 +81,8 @@ public static function cleanEnvironment(array $variables)
      * @param array $variables
      * @param string $input
      * @param string|null $url
-     * @return HTTPRequest
      */
-    public static function createFromVariables(array $variables, $input, $url = null)
+    public static function createFromVariables(array $variables, $input, $url = null): HTTPRequest
     {
         $request = parent::createFromVariables($variables, $input, $url);
         // unset scheme so that SS_BASE_URL can provide `is_https` information if required
@@ -93,4 +93,17 @@ public static function createFromVariables(array $variables, $input, $url = null
 
         return $request;
     }
+
+    public static function createFromInput(InputInterface $input): HTTPRequest
+    {
+        $variables = [];
+        $variables['_SERVER']['argv'] = [
+            'sake',
+            $input->getArgument('path'),
+            ...$input->getArgument('get-var'),
+        ];
+        $cleanVars = static::cleanEnvironment($variables);
+        Environment::setVariables($cleanVars);
+        return static::createFromVariables($cleanVars, []);
+    }
 }
diff --git a/src/Control/CliController.php b/src/Control/CliController.php
deleted file mode 100644
index 30d66250b93..00000000000
--- a/src/Control/CliController.php
+++ /dev/null
@@ -1,58 +0,0 @@
-<?php
-
-namespace SilverStripe\Control;
-
-use SilverStripe\Core\ClassInfo;
-use SilverStripe\Core\Injector\Injector;
-use SilverStripe\Dev\Deprecation;
-use SilverStripe\Security\Permission;
-use SilverStripe\Security\Security;
-
-/**
- * Base class invoked from CLI rather than the webserver (Cron jobs, handling email bounces).
- * You can call subclasses of CliController directly, which will trigger a
- * call to {@link process()} on every sub-subclass. For instance, calling
- * "sake DailyTask" from the commandline will call {@link process()} on every subclass
- * of DailyTask.
- *
- * @deprecated 5.4.0 Will be replaced with symfony/console commands
- */
-abstract class CliController extends Controller
-{
-    public function __construct()
-    {
-        parent::__construct();
-        Deprecation::notice('5.4.0', 'Will be replaced with symfony/console commands', Deprecation::SCOPE_CLASS);
-    }
-
-    private static $allowed_actions = [
-        'index'
-    ];
-
-    protected function init()
-    {
-        parent::init();
-        // Unless called from the command line, all CliControllers need ADMIN privileges
-        if (!Director::is_cli() && !Permission::check("ADMIN")) {
-            Security::permissionFailure();
-        }
-    }
-
-    public function index()
-    {
-        foreach (ClassInfo::subclassesFor(static::class) as $subclass) {
-            echo $subclass . "\n";
-            /** @var CliController $task */
-            $task = Injector::inst()->create($subclass);
-            $task->doInit();
-            $task->process();
-        }
-    }
-
-    /**
-     * Overload this method to contain the task logic.
-     */
-    public function process()
-    {
-    }
-}
diff --git a/src/Control/Director.php b/src/Control/Director.php
index 119d4e746d0..3ff9a456c7a 100644
--- a/src/Control/Director.php
+++ b/src/Control/Director.php
@@ -12,6 +12,7 @@
 use SilverStripe\Core\Injector\Injector;
 use SilverStripe\Core\Kernel;
 use SilverStripe\Core\Path;
+use SilverStripe\PolyExecution\PolyCommand;
 use SilverStripe\Versioned\Versioned;
 use SilverStripe\View\Requirements;
 use SilverStripe\View\Requirements_Backend;
@@ -345,6 +346,9 @@ public function handleRequest(HTTPRequest $request)
                 try {
                     /** @var RequestHandler $controllerObj */
                     $controllerObj = Injector::inst()->create($arguments['Controller']);
+                    if ($controllerObj instanceof PolyCommand) {
+                        $controllerObj = PolyCommandController::create($controllerObj);
+                    }
                     return $controllerObj->handleRequest($request);
                 } catch (HTTPResponse_Exception $responseException) {
                     return $responseException->getResponse();
diff --git a/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php b/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php
deleted file mode 100644
index 1a16be0c695..00000000000
--- a/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
-
-use SilverStripe\Control\Director;
-use SilverStripe\Control\HTTPRequest;
-use SilverStripe\Core\Kernel;
-use SilverStripe\Dev\Deprecation;
-
-/**
- * Allows a bypass when the request has been run in CLI mode
- *
- * @deprecated 5.4.0 Will be removed without equivalent functionality to replace it
- */
-class CliBypass implements Bypass
-{
-    public function __construct()
-    {
-        Deprecation::withNoReplacement(function () {
-            Deprecation::notice(
-                '5.4.0',
-                'Will be removed without equivalent functionality to replace it',
-                Deprecation::SCOPE_CLASS
-            );
-        });
-    }
-
-    /**
-     * Returns true if the current process is running in CLI mode
-     *
-     * @param HTTPRequest $request
-     *
-     * @return bool
-     */
-    public function checkRequestForBypass(HTTPRequest $request)
-    {
-        return Director::is_cli();
-    }
-}
diff --git a/src/Control/Middleware/DevelopmentAdminConfirmationMiddleware.php b/src/Control/Middleware/DevelopmentAdminConfirmationMiddleware.php
index 25976e9ec67..d85fda587d8 100644
--- a/src/Control/Middleware/DevelopmentAdminConfirmationMiddleware.php
+++ b/src/Control/Middleware/DevelopmentAdminConfirmationMiddleware.php
@@ -4,7 +4,6 @@
 
 use SilverStripe\Control\HTTPRequest;
 use SilverStripe\Core\Config\Config;
-use SilverStripe\Core\Injector\Injector;
 use SilverStripe\Dev\DevelopmentAdmin;
 use SilverStripe\Security\Permission;
 
@@ -25,7 +24,6 @@
  */
 class DevelopmentAdminConfirmationMiddleware extends PermissionAwareConfirmationMiddleware
 {
-
     /**
      * Check whether the user has permissions to perform the target operation
      * Otherwise we may want to skip the confirmation dialog.
@@ -43,21 +41,10 @@ public function hasAccess(HTTPRequest $request)
             return false;
         }
 
-        $registeredRoutes = DevelopmentAdmin::config()->get('registered_controllers');
-        while (!isset($registeredRoutes[$action]) && strpos($action, '/') !== false) {
-            // Check for the parent route if a specific route isn't found
-            $action = substr($action, 0, strrpos($action, '/'));
-        }
-
-        if (isset($registeredRoutes[$action]['controller'])) {
-            $initPermissions = Config::forClass($registeredRoutes[$action]['controller'])->get('init_permissions');
-            foreach ($initPermissions as $permission) {
-                if (Permission::check($permission)) {
-                    return true;
-                }
-            }
-        }
-
-        return false;
+        $url = rtrim($request->getURL(), '/');
+        $registeredRoutes = DevelopmentAdmin::singleton()->getLinks();
+        // Permissions were already checked when generating the links list, so if
+        // it's in the list the user has access.
+        return isset($registeredRoutes[$url]);
     }
 }
diff --git a/src/Control/Middleware/URLSpecialsMiddleware.php b/src/Control/Middleware/URLSpecialsMiddleware.php
index f32d779f544..ddbec1a2d0f 100644
--- a/src/Control/Middleware/URLSpecialsMiddleware.php
+++ b/src/Control/Middleware/URLSpecialsMiddleware.php
@@ -22,7 +22,7 @@
  *  - isTest GET parameter
  *  - dev/build URL
  *
- * @see https://docs.silverstripe.org/en/4/developer_guides/debugging/url_variable_tools/ special variables docs
+ * @see https://docs.silverstripe.org/en/developer_guides/debugging/url_variable_tools/ special variables docs
  *
  * {@inheritdoc}
  */
diff --git a/src/Control/PolyCommandController.php b/src/Control/PolyCommandController.php
new file mode 100644
index 00000000000..0e4df84e0bd
--- /dev/null
+++ b/src/Control/PolyCommandController.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace SilverStripe\Control;
+
+use SilverStripe\PolyExecution\PolyCommand;
+use SilverStripe\PolyExecution\HttpRequestInput;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Exception\InvalidArgumentException;
+use Symfony\Component\Console\Exception\InvalidOptionException;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+/**
+ * Controller that allows routing HTTP requests to PolyCommands
+ *
+ * This controller is automatically wrapped around any PolyCommand
+ * that is added to the regular routing configuration.
+ */
+class PolyCommandController extends Controller
+{
+    private PolyCommand $command;
+
+    public function __construct(PolyCommand $polyCommand)
+    {
+        $this->command = $polyCommand;
+        parent::__construct();
+    }
+
+    protected function init()
+    {
+        parent::init();
+        if (!$this->command::canRunInBrowser()) {
+            $this->httpError(404);
+        }
+    }
+
+    public function index(HTTPRequest $request): HTTPResponse
+    {
+        $response = $this->getResponse();
+
+        try {
+            $input = HttpRequestInput::create($request, $this->command->getOptions());
+        } catch (InvalidOptionException|InvalidArgumentException $e) {
+            $response->setBody($e->getMessage());
+            $response->setStatusCode(400);
+            $this->afterHandleRequest();
+            return $this->getResponse();
+        }
+
+        $buffer = new BufferedOutput();
+        $output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true, $buffer);
+        $exitCode = $this->command->run($input, $output);
+        $response->setBody($buffer->fetch());
+        $responseCode = match (true) {
+            $exitCode === Command::SUCCESS => 200,
+            $exitCode === Command::FAILURE => 500,
+            $exitCode === Command::INVALID => 400,
+            // If someone's using an unexpected exit code, we shouldn't guess what they meant,
+            // just assume they intentionally set it to something meaningful.
+            default => $exitCode,
+        };
+        $response->setStatusCode($responseCode);
+        return $this->getResponse();
+    }
+}
diff --git a/src/Core/CoreKernel.php b/src/Core/CoreKernel.php
index 750859d36dd..e3f92b8f7a7 100644
--- a/src/Core/CoreKernel.php
+++ b/src/Core/CoreKernel.php
@@ -30,7 +30,6 @@ public function setBootDatabase(bool $bool): static
     }
 
     /**
-     * @param false $flush
      * @throws HTTPResponse_Exception
      * @throws Exception
      */
diff --git a/src/Core/DatabaselessKernel.php b/src/Core/DatabaselessKernel.php
deleted file mode 100644
index c3c809dc391..00000000000
--- a/src/Core/DatabaselessKernel.php
+++ /dev/null
@@ -1,70 +0,0 @@
-<?php
-
-namespace SilverStripe\Core;
-
-use Exception;
-use SilverStripe\Dev\Deprecation;
-
-/**
- * Boot a kernel without requiring a database connection.
- * This is a workaround for the lack of composition in the boot stages
- * of CoreKernel, as well as for the framework's misguided assumptions
- * around the availability of a database for every execution path.
- *
- * @internal
- * @deprecated 5.4.0 Use SilverStripe\Core\CoreKernel::setBootDatabase() instead
- */
-class DatabaselessKernel extends BaseKernel
-{
-    /**
-     * Indicates whether the Kernel has been flushed on boot
-     * Null before boot
-     */
-    private ?bool $flush = null;
-
-    /**
-     * Allows disabling of the configured error handling.
-     * This can be useful to ensure the execution context (e.g. composer)
-     * can consistently use its own error handling.
-     *
-     * @var boolean
-     */
-    protected $bootErrorHandling = true;
-
-    public function __construct($basePath)
-    {
-        parent::__construct($basePath);
-        Deprecation::notice(
-            '5.4.0',
-            'Use ' . CoreKernel::class . '::setBootDatabase() instead',
-            Deprecation::SCOPE_CLASS
-        );
-    }
-
-    public function setBootErrorHandling(bool $bool)
-    {
-        $this->bootErrorHandling = $bool;
-        return $this;
-    }
-
-    /**
-     * @param false $flush
-     * @throws Exception
-     */
-    public function boot($flush = false)
-    {
-        $this->flush = $flush;
-
-        $this->bootPHP();
-        $this->bootManifests($flush);
-        $this->bootErrorHandling();
-        $this->bootConfigs();
-
-        $this->setBooted(true);
-    }
-
-    public function isFlushed(): ?bool
-    {
-        return $this->flush;
-    }
-}
diff --git a/src/Core/Kernel.php b/src/Core/Kernel.php
index a64590bcec7..0315ad5c97f 100644
--- a/src/Core/Kernel.php
+++ b/src/Core/Kernel.php
@@ -139,4 +139,9 @@ public function setEnvironment($environment);
      * @return bool|null null if the kernel hasn't been booted yet
      */
     public function isFlushed(): ?bool;
+
+    /**
+     * Returns whether the kernel has been booted
+     */
+    public function getBooted(): bool;
 }
diff --git a/src/Core/Manifest/ClassManifest.php b/src/Core/Manifest/ClassManifest.php
index a89a38f75f6..9b8b32b3618 100644
--- a/src/Core/Manifest/ClassManifest.php
+++ b/src/Core/Manifest/ClassManifest.php
@@ -549,7 +549,7 @@ public function regenerate($includeTests)
         $finder = new ManifestFileFinder();
         $finder->setOptions([
             'name_regex' => '/^[^_].*\\.php$/',
-            'ignore_files' => ['index.php', 'cli-script.php'],
+            'ignore_files' => ['index.php', 'bin/sake.php'],
             'ignore_tests' => !$includeTests,
             'file_callback' => function ($basename, $pathname, $depth) use ($includeTests) {
                 $this->handleFile($basename, $pathname, $includeTests);
diff --git a/src/Dev/BuildTask.php b/src/Dev/BuildTask.php
index 5497cc3f0f0..16e1d2466b3 100644
--- a/src/Dev/BuildTask.php
+++ b/src/Dev/BuildTask.php
@@ -2,103 +2,112 @@
 
 namespace SilverStripe\Dev;
 
-use SilverStripe\Control\HTTPRequest;
-use SilverStripe\Core\Config\Config;
-use SilverStripe\Core\Config\Configurable;
+use LogicException;
 use SilverStripe\Core\Extensible;
-use SilverStripe\Core\Injector\Injectable;
+use SilverStripe\PolyExecution\PolyCommand;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\ORM\FieldType\DBDatetime;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
 
 /**
- * Interface for a generic build task. Does not support dependencies. This will simply
- * run a chunk of code when called.
- *
- * To disable the task (in the case of potentially destructive updates or deletes), declare
- * the $Disabled property on the subclass.
+ * A task that can be run either from the CLI or via an HTTP request.
+ * This is often used for post-deployment tasks, e.g. migrating data to fit a new schema.
  */
-abstract class BuildTask
+abstract class BuildTask extends PolyCommand
 {
-    use Injectable;
-    use Configurable;
     use Extensible;
 
-    public function __construct()
-    {
-    }
-
     /**
-     * Set a custom url segment (to follow dev/tasks/)
-     *
-     * @config
-     * @var string
-     * @deprecated 5.4.0 Will be replaced with $commandName
+     * Shown in the overview on the {@link TaskRunner}
+     * HTML or CLI interface. Should be short and concise.
+     * Do not use HTML markup.
      */
-    private static $segment = null;
+    protected string $title;
 
     /**
-     * Make this non-nullable and change this to `bool` in CMS6 with a value of `true`
-     * @var bool|null
+     * Whether the task is allowed to be run or not.
+     * This property overrides `can_run_in_cli` and `can_run_in_browser` if set to false.
      */
-    private static ?bool $is_enabled = null;
+    private static bool $is_enabled = true;
 
     /**
-     * @var bool $enabled If set to FALSE, keep it from showing in the list
-     * and from being executable through URL or CLI.
-     * @deprecated - remove in CMS 6 and rely on $is_enabled instead
+     * Describe the implications the task has, and the changes it makes.
+     * Do not use HTML markup.
      */
-    protected $enabled = true;
+    protected static string $description = 'No description available';
 
-    /**
-     * @var string $title Shown in the overview on the {@link TaskRunner}
-     * HTML or CLI interface. Should be short and concise, no HTML allowed.
-     */
-    protected $title;
+    private static array $permissions_for_browser_execution = [
+        'ADMIN',
+        'anyone_with_dev_admin_permissions' => 'ALL_DEV_ADMIN',
+        'anyone_with_task_permissions' => 'BUILDTASK_CAN_RUN',
+    ];
 
-    /**
-     * @var string $description Describe the implications the task has,
-     * and the changes it makes. Accepts HTML formatting.
-     * @deprecated 5.4.0 Will be replaced with a static property with the same name
-     */
-    protected $description = 'No description available';
+    public function __construct()
+    {
+    }
 
     /**
-     * Implement this method in the task subclass to
-     * execute via the TaskRunner
+     * The code for running this task.
      *
-     * @param HTTPRequest $request
-     * @return void
+     * Output should be agnostic - do not include explicit HTML in the output unless there is no API
+     * on `PolyOutput` for what you want to do (in which case use the writeForFormat() method).
+     *
+     * Use symfony/console ANSI formatting to style the output.
+     * See https://symfony.com/doc/current/console/coloring.html
+     *
+     * @return int 0 if everything went fine, or an exit code
      */
-    abstract public function run($request);
+    abstract protected function execute(InputInterface $input, PolyOutput $output): int;
 
-    /**
-     * @return bool
-     */
-    public function isEnabled()
+    public function run(InputInterface $input, PolyOutput $output): int
     {
-        $isEnabled = $this->config()->get('is_enabled');
+        $output->writeForFormat(PolyOutput::FORMAT_ANSI, "<options=bold>Running task '{$this->getTitle()}'</>", true);
+        $output->writeForFormat(
+            PolyOutput::FORMAT_HTML,
+            "<h1>Running task '{$this->getTitle()}'</h1>",
+            false,
+            PolyOutput::OUTPUT_RAW
+        );
 
-        if ($isEnabled === null) {
-            return $this->enabled;
+        $before = DBDatetime::now();
+        $exitCode = $this->execute($input, $output);
+        $after = DBDatetime::now();
+
+        $message = "Task '{$this->getTitle()}' ";
+        if ($exitCode === Command::SUCCESS) {
+            $message .= 'completed successfully';
+        } else {
+            $message .= 'failed';
         }
-        return $isEnabled;
+        $timeTaken = DBDatetime::getTimeBetween($before, $after);
+        $message .= " in $timeTaken";
+        $output->writeln(['', "<options=bold>{$message}</>"]);
+        return $exitCode;
     }
 
-    /**
-     * @return string
-     */
-    public function getTitle()
+    public function isEnabled(): bool
     {
-        return $this->title ?: static::class;
+        return $this->config()->get('is_enabled');
     }
 
-    /**
-     * @return string HTML formatted description
-     * @deprecated 5.4.0 Will be replaced with a static method with the same name
-     */
-    public function getDescription()
+    public function getTitle(): string
     {
-        Deprecation::withNoReplacement(
-            fn() => Deprecation::notice('5.4.0', 'Will be replaced with a static method with the same name')
-        );
-        return $this->description;
+        return $this->title ?? static::class;
+    }
+
+    public static function getName(): string
+    {
+        return 'tasks:' . static::getNameWithoutNamespace();
+    }
+
+    public static function getNameWithoutNamespace(): string
+    {
+        $name = parent::getName() ?: str_replace('\\', '-', static::class);
+        // Don't allow `:` or `/` because it would affect routing and CLI namespacing
+        if (str_contains($name, ':') || str_contains($name, '/')) {
+            throw new LogicException('commandName must not contain `:` or `/`. Got ' . $name);
+        }
+        return $name;
     }
 }
diff --git a/src/Dev/Command/ConfigAudit.php b/src/Dev/Command/ConfigAudit.php
new file mode 100644
index 00000000000..8c823ec62f8
--- /dev/null
+++ b/src/Dev/Command/ConfigAudit.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace SilverStripe\Dev\Command;
+
+use SilverStripe\Core\ClassInfo;
+use SilverStripe\Core\Config\Config;
+use SilverStripe\Core\Injector\Injector;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+
+/**
+ * Command to audit the configuration.
+ * Can be run either via an HTTP request or the CLI.
+ */
+class ConfigAudit extends DevCommand
+{
+    protected static string $commandName = 'config:audit';
+
+    protected static string $description = 'Find configuration properties that are not defined (or inherited) by their respective classes';
+
+    private static string|array $permissions_for_browser_execution = [
+        'CAN_DEV_CONFIG',
+    ];
+
+    public function getTitle(): string
+    {
+        return 'Configuration';
+    }
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        $body = '';
+        $missing = [];
+
+        foreach ($this->arrayKeysRecursive(Config::inst()->getAll(), 2) as $className => $props) {
+            $props = array_keys($props ?? []);
+
+            if (!count($props ?? [])) {
+                // We can skip this entry
+                continue;
+            }
+
+            if ($className == strtolower(Injector::class)) {
+                // We don't want to check the injector config
+                continue;
+            }
+
+            foreach ($props as $prop) {
+                $defined = false;
+                // Check ancestry (private properties don't inherit natively)
+                foreach (ClassInfo::ancestry($className) as $cn) {
+                    if (property_exists($cn, $prop ?? '')) {
+                        $defined = true;
+                        break;
+                    }
+                }
+
+                if ($defined) {
+                    // No need to record this property
+                    continue;
+                }
+
+                $missing[] = sprintf("%s::$%s\n", $className, $prop);
+            }
+        }
+
+        $body = count($missing ?? [])
+            ? implode("\n", $missing)
+            : "All configured properties are defined\n";
+
+        $output->writeForFormat(
+            PolyOutput::FORMAT_HTML,
+            '<pre>',
+            options: PolyOutput::OUTPUT_RAW
+        );
+        $output->write($body);
+        $output->writeForFormat(
+            PolyOutput::FORMAT_HTML,
+            '</pre>',
+            options: PolyOutput::OUTPUT_RAW
+        );
+
+        return Command::SUCCESS;
+    }
+
+    protected function getHeading(): string
+    {
+        return 'Missing configuration property definitions';
+    }
+
+    /**
+     * Returns all the keys of a multi-dimensional array while maintining any nested structure.
+     * Does not include keys where the values are not arrays, so not suitable as a generic method.
+     */
+    private function arrayKeysRecursive(
+        array $array,
+        int $maxdepth = 20,
+        int $depth = 0,
+        array $arrayKeys = []
+    ): array {
+        if ($depth < $maxdepth) {
+            $depth++;
+            $keys = array_keys($array ?? []);
+
+            foreach ($keys as $key) {
+                if (!is_array($array[$key])) {
+                    continue;
+                }
+
+                $arrayKeys[$key] = $this->arrayKeysRecursive($array[$key], $maxdepth, $depth);
+            }
+        }
+
+        return $arrayKeys;
+    }
+}
diff --git a/src/Dev/Command/ConfigDump.php b/src/Dev/Command/ConfigDump.php
new file mode 100644
index 00000000000..64f2f7d4d4a
--- /dev/null
+++ b/src/Dev/Command/ConfigDump.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace SilverStripe\Dev\Command;
+
+use SilverStripe\Core\Config\Config;
+use SilverStripe\Dev\DevelopmentAdmin;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\Security\PermissionProvider;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Yaml\Yaml;
+
+/**
+ * Command to dump the configuration.
+ * Can be run either via an HTTP request or the CLI.
+ */
+class ConfigDump extends DevCommand implements PermissionProvider
+{
+    protected static string $commandName = 'config:dump';
+
+    protected static string $description = 'View the current config, useful for debugging';
+
+    private static array $permissions_for_browser_execution = [
+        'CAN_DEV_CONFIG',
+    ];
+
+    public function getTitle(): string
+    {
+        return 'Configuration';
+    }
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        $output->writeForFormat(
+            PolyOutput::FORMAT_HTML,
+            '<pre>',
+            options: PolyOutput::OUTPUT_RAW
+        );
+
+        $output->write(Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
+
+        $output->writeForFormat(
+            PolyOutput::FORMAT_HTML,
+            '</pre>',
+            options: PolyOutput::OUTPUT_RAW
+        );
+        return Command::SUCCESS;
+    }
+
+    protected function getHeading(): string
+    {
+        return 'Config manifest';
+    }
+
+    public function providePermissions(): array
+    {
+        return [
+            'CAN_DEV_CONFIG' => [
+                'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'),
+                'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'),
+                'category' => DevelopmentAdmin::permissionsCategory(),
+                'sort' => 100
+            ],
+        ];
+    }
+}
diff --git a/src/Dev/Command/DbBuild.php b/src/Dev/Command/DbBuild.php
new file mode 100644
index 00000000000..10e452a97a5
--- /dev/null
+++ b/src/Dev/Command/DbBuild.php
@@ -0,0 +1,347 @@
+<?php
+
+namespace SilverStripe\Dev\Command;
+
+use BadMethodCallException;
+use SilverStripe\Control\Director;
+use SilverStripe\Core\ClassInfo;
+use SilverStripe\Core\Environment;
+use SilverStripe\Core\Extensible;
+use SilverStripe\Core\Injector\Injector;
+use SilverStripe\Core\Manifest\ClassLoader;
+use SilverStripe\Dev\Deprecation;
+use SilverStripe\Dev\DevelopmentAdmin;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\ORM\Connect\TableBuilder;
+use SilverStripe\ORM\DataObject;
+use SilverStripe\ORM\DB;
+use SilverStripe\ORM\FieldType\DBClassName;
+use SilverStripe\Security\PermissionProvider;
+use SilverStripe\Security\Security;
+use SilverStripe\Versioned\Versioned;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+
+/**
+ * Command to build the database.
+ * Can be run either via an HTTP request or the CLI.
+ */
+class DbBuild extends DevCommand implements PermissionProvider
+{
+    use Extensible;
+
+    protected static string $commandName = 'db:build';
+
+    protected static string $description = 'Build/rebuild this environment. Run this whenever you have updated your project sources';
+
+    private static array $permissions_for_browser_execution = [
+        'CAN_DEV_BUILD',
+    ];
+
+    /**
+     * Obsolete classname values that should be remapped while building the database.
+     * Map old FQCN to new FQCN, e.g
+     * 'App\\OldNamespace\\MyClass' => 'App\\NewNamespace\\MyClass'
+     */
+    private static array $classname_value_remapping = [];
+
+    /**
+     * Config setting to enabled/disable the display of record counts on the build output
+     */
+    private static bool $show_record_counts = true;
+
+    public function getTitle(): string
+    {
+        return 'Environment Builder';
+    }
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        // The default time limit of 30 seconds is normally not enough
+        Environment::increaseTimeLimitTo(600);
+
+        // If this code is being run without a flush, we need to at least flush the class manifest
+        if (!$input->getOption('flush')) {
+            ClassLoader::inst()->getManifest()->regenerate(false);
+        }
+
+        $populate = !$input->getOption('no-populate');
+        if ($input->getOption('dont_populate')) {
+            $populate = false;
+            Deprecation::notice(
+                '6.0.0',
+                '`dont_populate` is deprecated. Use `no-populate` instead',
+                Deprecation::SCOPE_GLOBAL
+            );
+        }
+        $this->doBuild($output, $populate);
+        return Command::SUCCESS;
+    }
+
+    protected function getHeading(): string
+    {
+        $conn = DB::get_conn();
+        // Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database")
+        $dbType = substr(get_class($conn), 0, -8);
+        $dbVersion = $conn->getVersion();
+        $databaseName = $conn->getSelectedDatabase();
+        return sprintf('Building database %s using %s %s', $databaseName, $dbType, $dbVersion);
+    }
+
+    /**
+     * Updates the database schema, creating tables & fields as necessary.
+     *
+     * @param bool $populate Populate the database, as well as setting up its schema
+     */
+    public function doBuild(PolyOutput $output, bool $populate = true, bool $testMode = false): void
+    {
+        $this->extend('onBeforeBuild', $output, $populate, $testMode);
+
+        if ($output->isQuiet()) {
+            DB::quiet();
+        }
+
+        // Set up the initial database
+        if (!DB::is_active()) {
+            $output->writeln(['<options=bold>Creating database</>', '']);
+
+            // Load parameters from existing configuration
+            $databaseConfig = DB::getConfig();
+            if (empty($databaseConfig)) {
+                throw new BadMethodCallException("No database configuration available");
+            }
+
+            // Check database name is given
+            if (empty($databaseConfig['database'])) {
+                throw new BadMethodCallException(
+                    "No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME"
+                );
+            }
+            $database = $databaseConfig['database'];
+
+            // Establish connection
+            unset($databaseConfig['database']);
+            DB::connect($databaseConfig);
+
+            // Create database
+            DB::create_database($database);
+        }
+
+        // Build the database.  Most of the hard work is handled by DataObject
+        $dataClasses = ClassInfo::subclassesFor(DataObject::class);
+        array_shift($dataClasses);
+
+        $output->writeln(['<options=bold>Creating database tables</>', '']);
+        $output->startList(PolyOutput::LIST_UNORDERED);
+
+        $showRecordCounts = (bool) static::config()->get('show_record_counts');
+
+        // Initiate schema update
+        $dbSchema = DB::get_schema();
+        $tableBuilder = TableBuilder::singleton();
+        $tableBuilder->buildTables($dbSchema, $dataClasses, [], $output->isQuiet(), $testMode, $showRecordCounts);
+        ClassInfo::reset_db_cache();
+
+        $output->stopList();
+
+        if ($populate) {
+            $output->writeln(['<options=bold>Creating database records</>', '']);
+            $output->startList(PolyOutput::LIST_UNORDERED);
+
+            // Remap obsolete class names
+            $this->migrateClassNames();
+
+            // Require all default records
+            foreach ($dataClasses as $dataClass) {
+                // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
+                // Test_ indicates that it's the data class is part of testing system
+                if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) {
+                    $output->writeListItem($dataClass);
+                    DataObject::singleton($dataClass)->requireDefaultRecords();
+                }
+            }
+
+            $output->stopList();
+        }
+
+        touch(static::getLastGeneratedFilePath());
+
+        $output->writeln(['<options=bold>Database build completed!</>', '']);
+
+        foreach ($dataClasses as $dataClass) {
+            DataObject::singleton($dataClass)->onAfterBuild();
+        }
+
+        ClassInfo::reset_db_cache();
+
+        $this->extend('onAfterBuild', $output, $populate, $testMode);
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new InputOption(
+                'no-populate',
+                null,
+                InputOption::VALUE_NONE,
+                'Don\'t run <info>requireDefaultRecords()</info> on the models when building.'
+                . 'This will build the table but not insert any records'
+            ),
+            new InputOption(
+                'dont_populate',
+                null,
+                InputOption::VALUE_NONE,
+                'Deprecated - use <info>no-populate</info> instead'
+            )
+        ];
+    }
+
+    public function providePermissions(): array
+    {
+        return [
+            'CAN_DEV_BUILD' => [
+                'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'),
+                'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'),
+                'category' => DevelopmentAdmin::permissionsCategory(),
+                'sort' => 100
+            ],
+        ];
+    }
+
+    /**
+     * Given a base data class, a field name and a mapping of class replacements, look for obsolete
+     * values in the $dataClass's $fieldName column and replace it with $mapping
+     *
+     * @param string $dataClass The data class to look up
+     * @param string $fieldName The field name to look in for obsolete class names
+     * @param string[] $mapping Map of old to new classnames
+     */
+    protected function updateLegacyClassNameField(string $dataClass, string $fieldName, array $mapping): void
+    {
+        $schema = DataObject::getSchema();
+        // Check first to ensure that the class has the specified field to update
+        if (!$schema->databaseField($dataClass, $fieldName, false)) {
+            return;
+        }
+
+        // Load a list of any records that have obsolete class names
+        $table = $schema->tableName($dataClass);
+        $currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column();
+
+        // Get all invalid classes for this field
+        $invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? []));
+        if (!$invalidClasses) {
+            return;
+        }
+
+        $numberClasses = count($invalidClasses ?? []);
+        DB::alteration_message(
+            "Correcting obsolete {$fieldName} values for {$numberClasses} outdated types",
+            'obsolete'
+        );
+
+        // Build case assignment based on all intersected legacy classnames
+        $cases = [];
+        $params = [];
+        foreach ($invalidClasses as $invalidClass) {
+            $cases[] = "WHEN \"{$fieldName}\" = ? THEN ?";
+            $params[] = $invalidClass;
+            $params[] = $mapping[$invalidClass];
+        }
+
+        foreach ($this->getClassTables($dataClass) as $table) {
+            $casesSQL = implode(' ', $cases);
+            $sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END";
+            DB::prepared_query($sql, $params);
+        }
+    }
+
+    /**
+     * Get tables to update for this class
+     */
+    protected function getClassTables(string $dataClass): iterable
+    {
+        $schema = DataObject::getSchema();
+        $table = $schema->tableName($dataClass);
+
+        // Base table
+        yield $table;
+
+        // Remap versioned table class name values as well
+        /** @var Versioned|DataObject $dataClass */
+        $dataClass = DataObject::singleton($dataClass);
+        if ($dataClass->hasExtension(Versioned::class)) {
+            if ($dataClass->hasStages()) {
+                yield "{$table}_Live";
+            }
+            yield "{$table}_Versions";
+        }
+    }
+
+    /**
+     * Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes
+     * `ClassName` fields as well as polymorphic class name fields.
+     *
+     * @return array[]
+     */
+    protected function getClassNameRemappingFields(): array
+    {
+        $dataClasses = ClassInfo::getValidSubClasses(DataObject::class);
+        $schema = DataObject::getSchema();
+        $remapping = [];
+
+        foreach ($dataClasses as $className) {
+            $fieldSpecs = $schema->fieldSpecs($className);
+            foreach ($fieldSpecs as $fieldName => $fieldSpec) {
+                if (Injector::inst()->create($fieldSpec, 'Dummy') instanceof DBClassName) {
+                    $remapping[$className][] = $fieldName;
+                }
+            }
+        }
+
+        return $remapping;
+    }
+
+    /**
+     * Migrate all class names
+     */
+    protected function migrateClassNames(): void
+    {
+        $remappingConfig = static::config()->get('classname_value_remapping');
+        $remappingFields = $this->getClassNameRemappingFields();
+        foreach ($remappingFields as $className => $fieldNames) {
+            foreach ($fieldNames as $fieldName) {
+                $this->updateLegacyClassNameField($className, $fieldName, $remappingConfig);
+            }
+        }
+    }
+
+    /**
+     * Returns the timestamp of the time that the database was last built
+     * or an empty string if we can't find that information.
+     */
+    public static function lastBuilt(): string
+    {
+        $file = static::getLastGeneratedFilePath();
+        if (file_exists($file)) {
+            return filemtime($file);
+        }
+        return '';
+    }
+
+    public static function canRunInBrowser(): bool
+    {
+        // Must allow running in browser if DB hasn't been built yet or is broken
+        // or the permission checks will throw an error
+        return !Security::database_is_ready() || parent::canRunInBrowser();
+    }
+
+    private static function getLastGeneratedFilePath(): string
+    {
+        return TEMP_PATH
+            . DIRECTORY_SEPARATOR
+            . 'database-last-generated-'
+            . str_replace(['\\', '/', ':'], '.', Director::baseFolder());
+    }
+}
diff --git a/src/Dev/Command/DbCleanup.php b/src/Dev/Command/DbCleanup.php
new file mode 100644
index 00000000000..6442d88e891
--- /dev/null
+++ b/src/Dev/Command/DbCleanup.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace SilverStripe\Dev\Command;
+
+use SilverStripe\Core\ClassInfo;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\ORM\DataObject;
+use SilverStripe\ORM\DB;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+
+/**
+ * Command to clean up the database.
+ * Can be run either via an HTTP request or the CLI.
+ */
+class DbCleanup extends DevCommand
+{
+    protected static string $commandName = 'db:cleanup';
+
+    protected static string $description = 'Remove records that don\'t have corresponding rows in their parent class tables';
+
+    private static array $permissions_for_browser_execution = [
+        'CAN_DEV_BUILD',
+    ];
+
+    public function getTitle(): string
+    {
+        return 'Database Cleanup';
+    }
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        $schema = DataObject::getSchema();
+        $baseClasses = [];
+        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
+            if (get_parent_class($class ?? '') == DataObject::class) {
+                $baseClasses[] = $class;
+            }
+        }
+
+        $countDeleted = 0;
+        $output->startList(PolyOutput::LIST_UNORDERED);
+        foreach ($baseClasses as $baseClass) {
+            // Get data classes
+            $baseTable = $schema->baseDataTable($baseClass);
+            $subclasses = ClassInfo::subclassesFor($baseClass);
+            unset($subclasses[0]);
+            foreach ($subclasses as $k => $subclass) {
+                if (!DataObject::getSchema()->classHasTable($subclass)) {
+                    unset($subclasses[$k]);
+                }
+            }
+
+            if ($subclasses) {
+                $records = DB::query("SELECT * FROM \"$baseTable\"");
+
+
+                foreach ($subclasses as $subclass) {
+                    $subclassTable = $schema->tableName($subclass);
+                    $recordExists[$subclass] =
+                        DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn();
+                }
+
+                foreach ($records as $record) {
+                    foreach ($subclasses as $subclass) {
+                        $subclassTable = $schema->tableName($subclass);
+                        $id = $record['ID'];
+                        if (($record['ClassName'] != $subclass)
+                            && (!is_subclass_of($record['ClassName'], $subclass ?? ''))
+                            && isset($recordExists[$subclass][$id])
+                        ) {
+                            $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?";
+                            $output->writeListItem("$sql [{$id}]");
+                            DB::prepared_query($sql, [$id]);
+                            $countDeleted++;
+                        }
+                    }
+                }
+            }
+        }
+        $output->stopList();
+        $output->writeln("Deleted {$countDeleted} rows");
+        return Command::SUCCESS;
+    }
+
+    protected function getHeading(): string
+    {
+        return 'Deleting records with no corresponding row in their parent class tables';
+    }
+}
diff --git a/src/Dev/Command/DbDefaults.php b/src/Dev/Command/DbDefaults.php
new file mode 100644
index 00000000000..c387e55eddb
--- /dev/null
+++ b/src/Dev/Command/DbDefaults.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace SilverStripe\Dev\Command;
+
+use SilverStripe\Core\ClassInfo;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\ORM\DataObject;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+
+/**
+ * Command to build default records in the database.
+ * Can be run either via an HTTP request or the CLI.
+ */
+class DbDefaults extends DevCommand
+{
+    protected static string $commandName = 'db:defaults';
+
+    protected static string $description = 'Build the default data, calling requireDefaultRecords on all DataObject classes';
+
+    private static array $permissions_for_browser_execution = [
+        'CAN_DEV_BUILD',
+    ];
+
+    public function getTitle(): string
+    {
+        return 'Defaults Builder';
+    }
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        $dataClasses = ClassInfo::subclassesFor(DataObject::class);
+        array_shift($dataClasses);
+
+        $output->startList(PolyOutput::LIST_UNORDERED);
+        foreach ($dataClasses as $dataClass) {
+            singleton($dataClass)->requireDefaultRecords();
+            $output->writeListItem("Defaults loaded for $dataClass");
+        }
+        $output->stopList();
+
+        return Command::SUCCESS;
+    }
+
+    protected function getHeading(): string
+    {
+        return 'Building default data for all DataObject classes';
+    }
+}
diff --git a/src/Dev/Command/DevCommand.php b/src/Dev/Command/DevCommand.php
new file mode 100644
index 00000000000..d3677f09c2b
--- /dev/null
+++ b/src/Dev/Command/DevCommand.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace SilverStripe\Dev\Command;
+
+use SilverStripe\PolyExecution\PolyCommand;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Terminal;
+
+/**
+ * A command that can be run from CLI or via an HTTP request in a dev/* route
+ */
+abstract class DevCommand extends PolyCommand
+{
+    private static array $permissions_for_browser_execution = [
+        'ADMIN',
+        'anyone_with_dev_admin_permissions' => 'ALL_DEV_ADMIN',
+    ];
+
+    public function run(InputInterface $input, PolyOutput $output): int
+    {
+        $terminal = new Terminal();
+        $heading = $this->getHeading();
+        if ($heading) {
+            // Output heading
+            $underline = str_repeat('-', min($terminal->getWidth(), strlen($heading)));
+            $output->writeForFormat(PolyOutput::FORMAT_ANSI, ["<options=bold>{$heading}</>", $underline], true);
+            $output->writeForFormat(PolyOutput::FORMAT_HTML, "<h2>{$heading}</h2>", false, PolyOutput::OUTPUT_RAW);
+        } else {
+            // Only print the title in CLI (and only if there's no heading)
+            // The DevAdminController outputs the title already for HTTP stuff.
+            $title = $this->getTitle();
+            $underline = str_repeat('-', min($terminal->getWidth(), strlen($title)));
+            $output->writeForFormat(PolyOutput::FORMAT_ANSI, ["<options=bold>{$title}</>", $underline], true);
+        }
+
+        return $this->execute($input, $output);
+    }
+
+    /**
+     * The code for running this command.
+     *
+     * Output should be agnostic - do not include explicit HTML in the output unless there is no API
+     * on `PolyOutput` for what you want to do (in which case use the writeForFormat() method).
+     *
+     * Use symfony/console ANSI formatting to style the output.
+     * See https://symfony.com/doc/current/console/coloring.html
+     *
+     * @return int 0 if everything went fine, or an exit code
+     */
+    abstract protected function execute(InputInterface $input, PolyOutput $output): int;
+
+    /**
+     * Content to output before command is executed.
+     * In HTML format this will be an h2.
+     */
+    abstract protected function getHeading(): string;
+}
diff --git a/src/Dev/Command/GenerateSecureToken.php b/src/Dev/Command/GenerateSecureToken.php
new file mode 100644
index 00000000000..65237f8055d
--- /dev/null
+++ b/src/Dev/Command/GenerateSecureToken.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace SilverStripe\Dev\Command;
+
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\Security\RandomGenerator;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+
+/**
+ * Command to generate a secure token.
+ * Can be run either via an HTTP request or the CLI.
+ */
+class GenerateSecureToken extends DevCommand
+{
+    protected static string $commandName = 'generatesecuretoken';
+
+    protected static string $description = 'Generate a secure token';
+
+    public function getTitle(): string
+    {
+        return 'Secure token';
+    }
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        $token = RandomGenerator::create()->randomToken($input->getOption('algorithm'));
+
+        $output->writeForFormat(PolyOutput::FORMAT_HTML, '<code>', options: PolyOutput::OUTPUT_RAW);
+        $output->writeln($token);
+        $output->writeForFormat(PolyOutput::FORMAT_HTML, '</code>', options: PolyOutput::OUTPUT_RAW);
+
+        return Command::SUCCESS;
+    }
+
+    protected function getHeading(): string
+    {
+        return 'Generating new token';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new InputOption(
+                'algorithm',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'The hashing algorithm used to generate the token. Can be any identifier listed in <href=https://www.php.net/manual/en/function.hash-algos.php>hash_algos()</>',
+                'sha1',
+                hash_algos()
+            ),
+        ];
+    }
+}
diff --git a/src/Dev/Deprecation.php b/src/Dev/Deprecation.php
index 9c7d4f5dab1..ab4d22a34fa 100644
--- a/src/Dev/Deprecation.php
+++ b/src/Dev/Deprecation.php
@@ -280,7 +280,7 @@ public static function notice($atVersion, $string = '', $scope = Deprecation::SC
             $data = null;
             if ($scope === Deprecation::SCOPE_CONFIG) {
                 // Deprecated config set via yaml will only be shown in the browser when using ?flush=1
-                // It will not show in CLI when running dev/build flush=1
+                // It will not show in CLI when running db:build --flush
                 $data = [
                     'key' => sha1($string),
                     'message' => $string,
diff --git a/src/Dev/DevBuildController.php b/src/Dev/DevBuildController.php
deleted file mode 100644
index 1559296865a..00000000000
--- a/src/Dev/DevBuildController.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php
-
-namespace SilverStripe\Dev;
-
-use SilverStripe\Control\Controller;
-use SilverStripe\Control\Director;
-use SilverStripe\Control\HTTPRequest;
-use SilverStripe\Control\HTTPResponse;
-use SilverStripe\ORM\DatabaseAdmin;
-use SilverStripe\Security\Permission;
-use SilverStripe\Security\PermissionProvider;
-use SilverStripe\Security\Security;
-
-/**
- * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild
- */
-class DevBuildController extends Controller implements PermissionProvider
-{
-
-    private static $url_handlers = [
-        '' => 'build'
-    ];
-
-    private static $allowed_actions = [
-        'build'
-    ];
-
-    private static $init_permissions = [
-        'ADMIN',
-        'ALL_DEV_ADMIN',
-        'CAN_DEV_BUILD',
-    ];
-
-    public function __construct()
-    {
-        parent::__construct();
-        Deprecation::withNoReplacement(function () {
-            Deprecation::notice(
-                '5.4.0',
-                'Will be replaced with SilverStripe\Dev\Command\DbBuild',
-                Deprecation::SCOPE_CLASS
-            );
-        });
-    }
-
-    protected function init(): void
-    {
-        parent::init();
-
-        if (!$this->canInit()) {
-            Security::permissionFailure($this);
-        }
-    }
-
-    public function build(HTTPRequest $request): HTTPResponse
-    {
-        if (Director::is_cli()) {
-            $da = DatabaseAdmin::create();
-            return $da->handleRequest($request);
-        } else {
-            $renderer = DebugView::create();
-            echo $renderer->renderHeader();
-            echo $renderer->renderInfo("Environment Builder", Director::absoluteBaseURL());
-            echo "<div class=\"build\">";
-
-            $da = DatabaseAdmin::create();
-            $response = $da->handleRequest($request);
-
-            echo "</div>";
-            echo $renderer->renderFooter();
-
-            return $response;
-        }
-    }
-
-    public function canInit(): bool
-    {
-        return (
-            Director::isDev()
-            // We need to ensure that DevelopmentAdminTest can simulate permission failures when running
-            // "dev/tasks" from CLI.
-            || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
-            || Permission::check(static::config()->get('init_permissions'))
-        );
-    }
-
-    public function providePermissions(): array
-    {
-        return [
-            'CAN_DEV_BUILD' => [
-                'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'),
-                'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'),
-                'category' => DevelopmentAdmin::permissionsCategory(),
-                'sort' => 100
-            ],
-        ];
-    }
-}
diff --git a/src/Dev/DevConfigController.php b/src/Dev/DevConfigController.php
deleted file mode 100644
index 056f0ee04fe..00000000000
--- a/src/Dev/DevConfigController.php
+++ /dev/null
@@ -1,214 +0,0 @@
-<?php
-
-namespace SilverStripe\Dev;
-
-use SilverStripe\Control\Controller;
-use SilverStripe\Control\Director;
-use SilverStripe\Control\HTTPResponse;
-use SilverStripe\Core\ClassInfo;
-use SilverStripe\Core\Config\Config;
-use SilverStripe\Core\Injector\Injector;
-use SilverStripe\Security\Permission;
-use SilverStripe\Security\PermissionProvider;
-use SilverStripe\Security\Security;
-use Symfony\Component\Yaml\Yaml;
-
-/**
- * Outputs the full configuration.
- *
- * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\ConfigDump
- */
-class DevConfigController extends Controller implements PermissionProvider
-{
-
-    /**
-     * @var array
-     */
-    private static $url_handlers = [
-        'audit' => 'audit',
-        '' => 'index'
-    ];
-
-    /**
-     * @var array
-     */
-    private static $allowed_actions = [
-        'index',
-        'audit',
-    ];
-
-    private static $init_permissions = [
-        'ADMIN',
-        'ALL_DEV_ADMIN',
-        'CAN_DEV_CONFIG',
-    ];
-
-
-    public function __construct()
-    {
-        parent::__construct();
-        Deprecation::withNoReplacement(function () {
-            Deprecation::notice(
-                '5.4.0',
-                'Will be replaced with SilverStripe\Dev\Command\ConfigDump',
-                Deprecation::SCOPE_CLASS
-            );
-        });
-    }
-
-    protected function init(): void
-    {
-        parent::init();
-
-        if (!$this->canInit()) {
-            Security::permissionFailure($this);
-        }
-    }
-
-    /**
-     * Note: config() method is already defined, so let's just use index()
-     *
-     * @return string|HTTPResponse
-     */
-    public function index()
-    {
-        $body = '';
-        $subtitle = "Config manifest";
-
-        if (Director::is_cli()) {
-            $body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? ''));
-            $body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
-        } else {
-            $renderer = DebugView::create();
-            $body .= $renderer->renderHeader();
-            $body .= $renderer->renderInfo("Configuration", Director::absoluteBaseURL());
-            $body .= "<div class=\"options\">";
-            $body .= sprintf("<h2>%s</h2>", $subtitle);
-            $body .= "<pre>";
-            $body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
-            $body .= "</pre>";
-            $body .= "</div>";
-            $body .= $renderer->renderFooter();
-        }
-
-        return $this->getResponse()->setBody($body);
-    }
-
-    /**
-     * Output the extraneous config properties which are defined in .yaml but not in a corresponding class
-     *
-     * @return string|HTTPResponse
-     */
-    public function audit()
-    {
-        $body = '';
-        $missing = [];
-        $subtitle = "Missing Config property definitions";
-
-        foreach ($this->array_keys_recursive(Config::inst()->getAll(), 2) as $className => $props) {
-            $props = array_keys($props ?? []);
-
-            if (!count($props ?? [])) {
-                // We can skip this entry
-                continue;
-            }
-
-            if ($className == strtolower(Injector::class)) {
-                // We don't want to check the injector config
-                continue;
-            }
-
-            foreach ($props as $prop) {
-                $defined = false;
-                // Check ancestry (private properties don't inherit natively)
-                foreach (ClassInfo::ancestry($className) as $cn) {
-                    if (property_exists($cn, $prop ?? '')) {
-                        $defined = true;
-                        break;
-                    }
-                }
-
-                if ($defined) {
-                    // No need to record this property
-                    continue;
-                }
-
-                $missing[] = sprintf("%s::$%s\n", $className, $prop);
-            }
-        }
-
-        $output = count($missing ?? [])
-            ? implode("\n", $missing)
-            : "All configured properties are defined\n";
-
-        if (Director::is_cli()) {
-            $body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? ''));
-            $body .= $output;
-        } else {
-            $renderer = DebugView::create();
-            $body .= $renderer->renderHeader();
-            $body .= $renderer->renderInfo(
-                "Configuration",
-                Director::absoluteBaseURL(),
-                "Config properties that are not defined (or inherited) by their respective classes"
-            );
-            $body .= "<div class=\"options\">";
-            $body .= sprintf("<h2>%s</h2>", $subtitle);
-            $body .= sprintf("<pre>%s</pre>", $output);
-            $body .= "</div>";
-            $body .= $renderer->renderFooter();
-        }
-
-        return $this->getResponse()->setBody($body);
-    }
-
-    public function canInit(): bool
-    {
-        return (
-            Director::isDev()
-            // We need to ensure that DevelopmentAdminTest can simulate permission failures when running
-            // "dev/tasks" from CLI.
-            || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
-            || Permission::check(static::config()->get('init_permissions'))
-        );
-    }
-
-    public function providePermissions(): array
-    {
-        return [
-            'CAN_DEV_CONFIG' => [
-                'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'),
-                'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'),
-                'category' => DevelopmentAdmin::permissionsCategory(),
-                'sort' => 100
-            ],
-        ];
-    }
-
-    /**
-     * Returns all the keys of a multi-dimensional array while maintining any nested structure
-     *
-     * @param array $array
-     * @param int $maxdepth
-     * @param int $depth
-     * @param array $arrayKeys
-     * @return array
-     */
-    private function array_keys_recursive($array, $maxdepth = 20, $depth = 0, $arrayKeys = [])
-    {
-        if ($depth < $maxdepth) {
-            $depth++;
-            $keys = array_keys($array ?? []);
-
-            foreach ($keys as $key) {
-                if (!is_array($array[$key])) {
-                    continue;
-                }
-
-                $arrayKeys[$key] = $this->array_keys_recursive($array[$key], $maxdepth, $depth);
-            }
-        }
-
-        return $arrayKeys;
-    }
-}
diff --git a/src/Dev/DevConfirmationController.php b/src/Dev/DevConfirmationController.php
index 2a64b4b4c22..7cf2e05ce34 100644
--- a/src/Dev/DevConfirmationController.php
+++ b/src/Dev/DevConfirmationController.php
@@ -3,7 +3,6 @@
 namespace SilverStripe\Dev;
 
 use SilverStripe\Control\Director;
-use SilverStripe\ORM\DatabaseAdmin;
 use SilverStripe\Security\Confirmation;
 
 /**
diff --git a/src/Dev/DevelopmentAdmin.php b/src/Dev/DevelopmentAdmin.php
index ccf279c309e..0fc196058f9 100644
--- a/src/Dev/DevelopmentAdmin.php
+++ b/src/Dev/DevelopmentAdmin.php
@@ -2,79 +2,81 @@
 
 namespace SilverStripe\Dev;
 
-use Exception;
+use LogicException;
 use SilverStripe\Control\Controller;
 use SilverStripe\Control\Director;
 use SilverStripe\Control\HTTPRequest;
-use SilverStripe\Control\HTTPResponse;
+use SilverStripe\Control\RequestHandler;
 use SilverStripe\Core\ClassInfo;
 use SilverStripe\Core\Config\Config;
 use SilverStripe\Core\Injector\Injector;
-use SilverStripe\Dev\Deprecation;
-use SilverStripe\ORM\DatabaseAdmin;
+use SilverStripe\Dev\Command\DevCommand;
+use SilverStripe\PolyExecution\HtmlOutputFormatter;
+use SilverStripe\PolyExecution\HttpRequestInput;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\ORM\FieldType\DBField;
 use SilverStripe\Security\Permission;
 use SilverStripe\Security\PermissionProvider;
 use SilverStripe\Security\Security;
 use SilverStripe\Versioned\Versioned;
+use SilverStripe\View\ViewableData;
 
 /**
  * Base class for development tools.
  *
- * Configured in framework/_config/dev.yml, with the config key registeredControllers being
- * used to generate the list of links for /dev.
+ * Configured via the `commands` and `controllers` configuration properties
  */
 class DevelopmentAdmin extends Controller implements PermissionProvider
 {
-
-    private static $url_handlers = [
+    private static array $url_handlers = [
         '' => 'index',
-        'build/defaults' => 'buildDefaults',
-        'generatesecuretoken' => 'generatesecuretoken',
-        '$Action' => 'runRegisteredController',
+        '$Action' => 'runRegisteredAction',
     ];
 
-    private static $allowed_actions = [
+    private static array $allowed_actions = [
         'index',
-        'buildDefaults',
-        'runRegisteredController',
-        'generatesecuretoken',
+        'runRegisteredAction',
     ];
 
     /**
-     * Controllers for dev admin views
+     * Commands for dev admin views.
+     *
+     * Register any DevCommand classes that you want to be under the `/dev/*` HTTP
+     * route and also accessible by CLI.
      *
      * e.g [
-     *     'urlsegment' => [
-     *         'controller' => 'SilverStripe\Dev\DevelopmentAdmin',
-     *         'links' => [
-     *             'urlsegment' => 'description',
-     *             ...
-     *         ]
-     *     ]
+     *     'command-one' => 'App\Dev\CommandOne',
      * ]
+     */
+    private static array $commands = [];
+
+    /**
+     * Controllers for dev admin views.
+     *
+     * This is for HTTP-only controllers routed under `/dev/*` which
+     * cannot be managed via CLI (e.g. an interactive GraphQL IDE).
+     * For most purposes, register a PolyCommand under $commands instead.
      *
-     * @var array
-     * @deprecated 5.4.0 Will be replaced with "controllers" and "commands" configuration properties
+     * e.g [
+     *     'urlsegment' => [
+     *         'class' => 'App\Dev\MyHttpOnlyController',
+     *         'description' => 'See a list of build tasks to run',
+     *     ],
+     * ]
      */
-    private static $registered_controllers = [];
+    private static array $controllers = [];
 
     /**
      * Assume that CLI equals admin permissions
      * If set to false, normal permission model will apply even in CLI mode
-     * Applies to all development admin tasks (E.g. TaskRunner, DatabaseAdmin)
-     *
-     * @config
-     * @var bool
+     * Applies to all development admin tasks (E.g. TaskRunner, DbBuild)
      */
-    private static $allow_all_cli = true;
+    private static bool $allow_all_cli = true;
 
     /**
      * Deny all non-cli requests (browser based ones) to dev admin
-     *
-     * @config
-     * @var bool
      */
-    private static $deny_non_cli = false;
+    private static bool $deny_non_cli = false;
 
     protected function init()
     {
@@ -89,7 +91,7 @@ protected function init()
             return;
         }
 
-        // Backwards compat: Default to "draft" stage, which is important
+        // Default to "draft" stage, which is important
         // for tasks like dev/build which call DataObject->requireDefaultRecords(),
         // but also for other administrative tasks which have assumptions about the default stage.
         if (class_exists(Versioned::class)) {
@@ -97,193 +99,229 @@ protected function init()
         }
     }
 
+    /**
+     * Renders the main /dev menu in the browser
+     */
     public function index()
     {
-        $links = $this->getLinks();
-        // Web mode
-        if (!Director::is_cli()) {
-            $renderer = DebugView::create();
-            echo $renderer->renderHeader();
-            echo $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL());
-            $base = Director::baseURL();
-
-            echo '<div class="options"><ul>';
-            $evenOdd = "odd";
-            foreach ($links as $action => $description) {
-                echo "<li class=\"$evenOdd\"><a href=\"{$base}dev/$action\"><b>/dev/$action:</b>"
-                    . " $description</a></li>\n";
-                $evenOdd = ($evenOdd == "odd") ? "even" : "odd";
+        $renderer = DebugView::create();
+        $base = Director::baseURL();
+        $formatter = HtmlOutputFormatter::create();
+
+        $list = [];
+
+        foreach ($this->getLinks() as $path => $info) {
+            $class = $info['class'];
+            $description = $info['description'] ?? '';
+            $parameters = null;
+            $help = null;
+            if (is_a($class, DevCommand::class, true)) {
+                $parameters = $class::singleton()->getOptionsForTemplate();
+                $description = DBField::create_field('HTMLText', $formatter->format($class::getDescription()));
+                $help = DBField::create_field('HTMLText', nl2br($formatter->format($class::getHelp())), false);
             }
+            $data = [
+                'Description' => $description,
+                'Link' => "{$base}$path",
+                'Path' => $path,
+                'Parameters' => $parameters,
+                'Help' => $help,
+            ];
+            $list[] = $data;
+        }
 
-            echo $renderer->renderFooter();
+        $data = [
+            'ArrayLinks' => $list,
+            'Header' => $renderer->renderHeader(),
+            'Footer' => $renderer->renderFooter(),
+            'Info' => $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()),
+        ];
 
-        // CLI mode
-        } else {
-            echo "SILVERSTRIPE DEVELOPMENT TOOLS\n--------------------------\n\n";
-            echo "You can execute any of the following commands:\n\n";
-            foreach ($links as $action => $description) {
-                echo "  sake dev/$action: $description\n";
-            }
-            echo "\n\n";
-        }
+        return ViewableData::create()->renderWith(static::class, $data);
     }
 
-    public function runRegisteredController(HTTPRequest $request)
+    /**
+     * Run the command, or hand execution to the controller.
+     * Note this method is for execution from the web only. CLI takes a different path.
+     */
+    public function runRegisteredAction(HTTPRequest $request)
     {
-        $controllerClass = null;
-
-        $baseUrlPart = $request->param('Action');
-        $reg = Config::inst()->get(static::class, 'registered_controllers');
-        if (isset($reg[$baseUrlPart])) {
-            $controllerClass = $reg[$baseUrlPart]['controller'];
+        $returnUrl = $this->getBackURL();
+        $fullPath = $request->getURL();
+        $routes = $this->getRegisteredRoutes();
+        $class = null;
+
+        // If full path directly matches, use that class.
+        if (isset($routes[$fullPath])) {
+            $class = $routes[$fullPath]['class'];
+            if (is_a($class, DevCommand::class, true)) {
+                // Tell the request we've matched the full URL
+                $request->shift($request->remaining());
+            }
         }
 
-        if ($controllerClass && class_exists($controllerClass ?? '')) {
-            return $controllerClass::create();
+        // The full path doesn't directly match any registered command or controller.
+        // Look for a controller that can handle the request. We reject commands at this stage.
+        // The full path will be for an action on the controller and may include nested actions,
+        // so we need to check all urlsegment sections within the request URL.
+        if (!$class) {
+            $parts = explode('/', $fullPath);
+            array_pop($parts);
+            while (count($parts) > 0) {
+                $newPath = implode('/', $parts);
+                // Don't check dev itself - that's the controller we're currently in.
+                if ($newPath === 'dev') {
+                    break;
+                }
+                // Check for a controller that matches this partial path.
+                $class = $routes[$newPath]['class'] ?? null;
+                if ($class !== null && is_a($class, RequestHandler::class, true)) {
+                    break;
+                }
+                array_pop($parts);
+            }
         }
 
-        $msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action');
-        if (Director::is_cli()) {
-            // in CLI we cant use httpError because of a bug with stuff being in the output already, see DevAdminControllerTest
-            throw new Exception($msg);
-        } else {
+        if (!$class) {
+            $msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action');
             $this->httpError(404, $msg);
         }
-    }
-
-    /*
-     * Internal methods
-     */
 
-    /**
-     * @deprecated 5.2.0 use getLinks() instead to include permission checks
-     * @return array of url => description
-     */
-    protected static function get_links()
-    {
-        Deprecation::notice('5.2.0', 'Use getLinks() instead to include permission checks');
-        $links = [];
-
-        $reg = Config::inst()->get(static::class, 'registered_controllers');
-        foreach ($reg as $registeredController) {
-            if (isset($registeredController['links'])) {
-                foreach ($registeredController['links'] as $url => $desc) {
-                    $links[$url] = $desc;
-                }
-            }
+        // Hand execution to the controller
+        if (is_a($class, RequestHandler::class, true)) {
+            return $class::create();
         }
-        return $links;
-    }
 
-    protected function getLinks(): array
-    {
-        $canViewAll = $this->canViewAll();
-        $links = [];
-        $reg = Config::inst()->get(static::class, 'registered_controllers');
-        foreach ($reg as $registeredController) {
-            if (isset($registeredController['links'])) {
-                if (!ClassInfo::exists($registeredController['controller'])) {
-                    continue;
-                }
+        /** @var DevCommand $command */
+        $command = $class::create();
+        $input = HttpRequestInput::create($request, $command->getOptions());
+        // DO NOT use a buffer here to capture the output - we explicitly want the output to be streamed
+        // to the client as its available, so that if there's an error the client gets all of the output
+        // available until the error occurs.
+        $output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true);
+        $renderer = DebugView::create();
+
+        // Output header etc
+        $headerOutput = [
+            $renderer->renderHeader(),
+            $renderer->renderInfo(
+                $command->getTitle(),
+                Director::absoluteBaseURL()
+            ),
+            '<div class="options">',
+        ];
+        $output->writeForFormat(
+            PolyOutput::FORMAT_HTML,
+            $headerOutput,
+            options: PolyOutput::OUTPUT_RAW
+        );
 
-                if (!$canViewAll) {
-                    // Check access to controller
-                    $controllerSingleton = Injector::inst()->get($registeredController['controller']);
-                    if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
-                        continue;
-                    }
-                }
+        // Run command
+        $command->run($input, $output);
 
-                foreach ($registeredController['links'] as $url => $desc) {
-                    $links[$url] = $desc;
-                }
-            }
+        // Output footer etc
+        $output->writeForFormat(
+            PolyOutput::FORMAT_HTML,
+            [
+                '</div>',
+                $renderer->renderFooter(),
+            ],
+            options: PolyOutput::OUTPUT_RAW
+        );
+
+        // Return to whence we came (e.g. if we had been redirected to dev/build)
+        if ($returnUrl) {
+            return $this->redirect($returnUrl);
         }
-        return $links;
     }
 
     /**
-     * @deprecated 5.4.0 Will be removed without equivalent functionality to replace it
+     * Get a map of all registered DevCommands.
+     * The key is the route used for browser execution.
      */
-    protected function getRegisteredController($baseUrlPart)
+    public function getCommands(): array
     {
-        Deprecation::notice('5.4.0', 'Will be removed without equivalent functionality to replace it');
-        $reg = Config::inst()->get(static::class, 'registered_controllers');
+        $commands = [];
+        foreach (Config::inst()->get(static::class, 'commands') as $name => $class) {
+            // Allow unsetting a command via YAML
+            if ($class === null) {
+                continue;
+            }
+            // Check that the class exists and is a DevCommand
+            if (!ClassInfo::exists($class)) {
+                throw new LogicException("Class '$class' doesn't exist");
+            }
+            if (!is_a($class, DevCommand::class, true)) {
+                throw new LogicException("Class '$class' must be a subclass of " . DevCommand::class);
+            }
 
-        if (isset($reg[$baseUrlPart])) {
-            $controllerClass = $reg[$baseUrlPart]['controller'];
-            return $controllerClass;
+            // Add to list of commands
+            $commands['dev/' . $name] = $class;
         }
-
-        return null;
+        return $commands;
     }
 
-
-    /*
-     * Unregistered (hidden) actions
-     */
-
     /**
-     * Build the default data, calling requireDefaultRecords on all
-     * DataObject classes
-     * Should match the $url_handlers rule:
-     *      'build/defaults' => 'buildDefaults',
-     *
-     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Commands\DbDefaults
+     * Get a map of routes that can be run via this controller in an HTTP request.
+     * The key is the URI path, and the value is an associative array of information about the route.
      */
-    public function buildDefaults()
+    public function getRegisteredRoutes(): array
     {
-        Deprecation::withNoReplacement(function () {
-            Deprecation::notice(
-                '5.4.0',
-                'Will be replaced with SilverStripe\Dev\Command\DbDefaults'
-            );
-        });
-
-        $da = DatabaseAdmin::create();
-
-        $renderer = null;
-        if (!Director::is_cli()) {
-            $renderer = DebugView::create();
-            echo $renderer->renderHeader();
-            echo $renderer->renderInfo("Defaults Builder", Director::absoluteBaseURL());
-            echo "<div class=\"build\">";
+        $canViewAll = $this->canViewAll();
+        $items = [];
+
+        foreach ($this->getCommands() as $urlSegment => $commandClass) {
+            // Note we've already checked if command classes exist and are DevCommand
+            // Check command can run in current context
+            if (!$canViewAll && !$commandClass::canRunInBrowser()) {
+                continue;
+            }
+
+            $items[$urlSegment] = ['class' => $commandClass];
         }
 
-        $da->buildDefaults();
+        foreach (static::config()->get('controllers') as $urlSegment => $info) {
+            // Allow unsetting a controller via YAML
+            if ($info === null) {
+                continue;
+            }
+            $controllerClass = $info['class'];
+            // Check that the class exists and is a RequestHandler
+            if (!ClassInfo::exists($controllerClass)) {
+                throw new LogicException("Class '$controllerClass' doesn't exist");
+            }
+            if (!is_a($controllerClass, RequestHandler::class, true)) {
+                throw new LogicException("Class '$controllerClass' must be a subclass of " . RequestHandler::class);
+            }
+
+            if (!$canViewAll) {
+                // Check access to controller
+                $controllerSingleton = Injector::inst()->get($controllerClass);
+                if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) {
+                    continue;
+                }
+            }
 
-        if (!Director::is_cli()) {
-            echo "</div>";
-            echo $renderer->renderFooter();
+            $items['dev/' . $urlSegment] = $info;
         }
+
+        return $items;
     }
 
     /**
-     * Generate a secure token which can be used as a crypto key.
-     * Returns the token and suggests PHP configuration to set it.
-     *
-     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Commands\GenerateSecureToken
+     * Get a map of links to be displayed in the /dev route.
+     * The key is the URI path, and the value is an associative array of information about the route.
      */
-    public function generatesecuretoken()
+    public function getLinks(): array
     {
-        Deprecation::withNoReplacement(function () {
-            Deprecation::notice(
-                '5.4.0',
-                'Will be replaced with SilverStripe\Dev\Command\GenerateSecureToken'
-            );
-        });
-
-        $generator = Injector::inst()->create('SilverStripe\\Security\\RandomGenerator');
-        $token = $generator->randomToken('sha1');
-        $body = <<<TXT
-Generated new token. Please add the following code to your YAML configuration:
-
-Security:
-  token: $token
-
-TXT;
-        $response = new HTTPResponse($body);
-        return $response->addHeader('Content-Type', 'text/plain');
+        $links = $this->getRegisteredRoutes();
+        foreach ($links as $i => $info) {
+            // Allow a controller without a link, e.g. DevConfirmationController
+            if ($info['skipLink'] ?? false) {
+                unset($links[$i]);
+            }
+        }
+        return $links;
     }
 
     public function errors()
@@ -310,17 +348,17 @@ public static function permissionsCategory(): string
 
     protected function canViewAll(): bool
     {
-        // Special case for dev/build: Defer permission checks to DatabaseAdmin->init() (see #4957)
-        $requestedDevBuild = (stripos($this->getRequest()->getURL() ?? '', 'dev/build') === 0)
-            && (stripos($this->getRequest()->getURL() ?? '', 'dev/build/defaults') === false);
-
-        // We allow access to this controller regardless of live-status or ADMIN permission only
-        // if on CLI.  Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
-        $allowAllCLI = static::config()->get('allow_all_cli');
+        // If dev/build was requested, we must defer to DbBuild permission checks explicitly
+        // because otherwise the permission checks may result in an error
+        $url = rtrim($this->getRequest()->getURL(), '/');
+        if ($url === 'dev/build') {
+            return false;
+        }
+        // We allow access to this controller regardless of live-status or ADMIN permission only if on CLI.
+        // Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
         return (
-            $requestedDevBuild
-            || Director::isDev()
-            || (Director::is_cli() && $allowAllCLI)
+            Director::isDev()
+            || (Director::is_cli() && static::config()->get('allow_all_cli'))
             // Its important that we don't run this check if dev/build was requested
             || Permission::check(['ADMIN', 'ALL_DEV_ADMIN'])
         );
diff --git a/src/Dev/MigrationTask.php b/src/Dev/MigrationTask.php
index 58981ffdaab..49bdcfb925e 100644
--- a/src/Dev/MigrationTask.php
+++ b/src/Dev/MigrationTask.php
@@ -2,77 +2,49 @@
 
 namespace SilverStripe\Dev;
 
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+
 /**
  * A migration task is a build task that is reversible.
  *
- * <b>Creating Migration Tasks</b>
- *
  * To create your own migration task, you need to define your own subclass of MigrationTask
- * and implement the following methods
- *
- * <i>app/src/MyMigrationTask.php</i>
- *
- * <code>
- * class MyMigrationTask extends MigrationTask {
- *
- *  private static $segment = 'MyMigrationTask'; // segment in the dev/tasks/ namespace for URL access
- *  protected $title = "My Database Migrations"; // title of the script
- *  protected $description = "My Description"; // description of what it does
- *
- *  public function run($request) {
- *      if ($request->getVar('Direction') == 'down') {
- *          $this->down();
- *      } else {
- *          $this->up();
- *      }
- *  }
- *
- *  public function up() {
- *      // do something when going from old -> new
- *  }
- *
- *  public function down() {
- *      // do something when going from new -> old
- *  }
- * }
- * </code>
- *
- * <b>Running Migration Tasks</b>
- * You can find all tasks under the dev/tasks/ namespace.
- * To run the above script you would need to run the following and note - Either the site has to be
- * in [devmode](debugging) or you need to add ?isDev=1 to the URL.
- *
- * <code>
- * // url to visit if in dev mode.
- * https://www.yoursite.com/dev/tasks/MyMigrationTask
- *
- * // url to visit if you are in live mode but need to run this
- * https://www.yoursite.com/dev/tasks/MyMigrationTask?isDev=1
- * </code>
+ * and implement the abstract methods.
  */
 abstract class MigrationTask extends BuildTask
 {
-
-    private static $segment = 'MigrationTask';
-
-    protected $title = "Database Migrations";
-
-    protected $description = "Provide atomic database changes (subclass this and implement yourself)";
-
-    public function run($request)
+    protected function execute(InputInterface $input, PolyOutput $output): int
     {
-        if ($request->param('Direction') == 'down') {
+        if ($input->getOption('direction') === 'down') {
             $this->down();
         } else {
             $this->up();
         }
+        return Command::SUCCESS;
     }
 
-    public function up()
-    {
-    }
+    /**
+     * Migrate from old to new
+     */
+    abstract public function up();
+
+    /**
+     * Revert the migration (new to old)
+     */
+    abstract public function down();
 
-    public function down()
+    public function getOptions(): array
     {
+        return [
+            new InputOption(
+                'direction',
+                null,
+                InputOption::VALUE_REQUIRED,
+                '"up" if migrating from old to new, "down" to revert a migration',
+                suggestedValues: ['up', 'down'],
+            ),
+        ];
     }
 }
diff --git a/src/Dev/State/ExtensionTestState.php b/src/Dev/State/ExtensionTestState.php
index 0cf274367a7..e0ffae7c197 100644
--- a/src/Dev/State/ExtensionTestState.php
+++ b/src/Dev/State/ExtensionTestState.php
@@ -88,7 +88,7 @@ public function setUpOnce($class)
         }
 
         // clear singletons, they're caching old extension info
-        // which is used in DatabaseAdmin->doBuild()
+        // which is used in DbBuild->doBuild()
         Injector::inst()->unregisterObjects([
             DataObject::class,
             Extension::class
diff --git a/src/Dev/TaskRunner.php b/src/Dev/TaskRunner.php
index ecd87c1a26b..bb4ee65ea9b 100644
--- a/src/Dev/TaskRunner.php
+++ b/src/Dev/TaskRunner.php
@@ -11,7 +11,11 @@
 use SilverStripe\Core\Convert;
 use SilverStripe\Core\Injector\Injector;
 use SilverStripe\Core\Manifest\ModuleResourceLoader;
+use SilverStripe\PolyExecution\HtmlOutputFormatter;
+use SilverStripe\PolyExecution\HttpRequestInput;
+use SilverStripe\PolyExecution\PolyOutput;
 use SilverStripe\ORM\ArrayList;
+use SilverStripe\ORM\FieldType\DBField;
 use SilverStripe\Security\Permission;
 use SilverStripe\Security\PermissionProvider;
 use SilverStripe\Security\Security;
@@ -20,7 +24,6 @@
 
 class TaskRunner extends Controller implements PermissionProvider
 {
-
     use Configurable;
 
     private static $url_handlers = [
@@ -59,25 +62,17 @@ public function index()
     {
         $baseUrl = Director::absoluteBaseURL();
         $tasks = $this->getTasks();
-
-        if (Director::is_cli()) {
-            // CLI mode
-            $output = 'SILVERSTRIPE DEVELOPMENT TOOLS: Tasks' . PHP_EOL . '--------------------------' . PHP_EOL . PHP_EOL;
-
-            foreach ($tasks as $task) {
-                $output .= sprintf(' * %s: sake dev/tasks/%s%s', $task['title'], $task['segment'], PHP_EOL);
-            }
-
-            return $output;
-        }
-
         $list = ArrayList::create();
-
         foreach ($tasks as $task) {
+            if (!$task['class']::canRunInBrowser()) {
+                continue;
+            }
             $list->push(ArrayData::create([
                 'TaskLink' => Controller::join_links($baseUrl, 'dev/tasks/', $task['segment']),
                 'Title' => $task['title'],
                 'Description' => $task['description'],
+                'Parameters' => $task['parameters'],
+                'Help' => $task['help'],
             ]));
         }
 
@@ -104,26 +99,26 @@ public function runTask($request)
         $name = $request->param('TaskName');
         $tasks = $this->getTasks();
 
-        $title = function ($content) {
-            printf(Director::is_cli() ? "%s\n\n" : '<h1>%s</h1>', $content);
-        };
-
         $message = function ($content) {
-            printf(Director::is_cli() ? "%s\n" : '<p>%s</p>', $content);
+            printf('<p>%s</p>', $content);
         };
 
         foreach ($tasks as $task) {
             if ($task['segment'] == $name) {
                 /** @var BuildTask $inst */
                 $inst = Injector::inst()->create($task['class']);
-                $title(sprintf('Running Task %s', $inst->getTitle()));
 
-                if (!$this->taskEnabled($task['class'])) {
+                if (!$this->taskEnabled($task['class']) || !$task['class']::canRunInBrowser()) {
                     $message('The task is disabled or you do not have sufficient permission to run it');
                     return;
                 }
 
-                $inst->run($request);
+                $input = HttpRequestInput::create($request, $inst->getOptions());
+                // DO NOT use a buffer here to capture the output - we explicitly want the output to be streamed
+                // to the client as its available, so that if there's an error the client gets all of the output
+                // available until the error occurs.
+                $output = PolyOutput::create(PolyOutput::FORMAT_HTML, $input->getVerbosity(), true);
+                $inst->run($input, $output);
                 return;
             }
         }
@@ -132,44 +127,51 @@ public function runTask($request)
     }
 
     /**
-     * @return array Array of associative arrays for each task (Keys: 'class', 'title', 'description')
+     * Get an associative array of task names to classes for all enabled BuildTasks
      */
-    protected function getTasks()
+    public function getTaskList(): array
+    {
+        $taskList = [];
+        $taskClasses = ClassInfo::subclassesFor(BuildTask::class, false);
+        foreach ($taskClasses as $taskClass) {
+            if ($this->taskEnabled($taskClass)) {
+                $taskList[$taskClass::getName()] = $taskClass;
+            }
+        }
+        return $taskList;
+    }
+
+    /**
+     * Get the class names of all build tasks for use in HTTP requests
+     */
+    protected function getTasks(): array
     {
         $availableTasks = [];
+        $formatter = HtmlOutputFormatter::create();
 
+        /** @var BuildTask $class */
         foreach ($this->getTaskList() as $class) {
-            $singleton = BuildTask::singleton($class);
-            $description = $singleton->getDescription();
-            $description = trim($description ?? '');
+            if (!$class::canRunInBrowser()) {
+                continue;
+            }
 
-            $desc = (Director::is_cli())
-                ? Convert::html2raw($description)
-                : $description;
+            $singleton = BuildTask::singleton($class);
+            $description = DBField::create_field('HTMLText', $formatter->format($class::getDescription()));
+            $help = DBField::create_field('HTMLText', nl2br($formatter->format($class::getHelp())), false);
 
             $availableTasks[] = [
                 'class' => $class,
                 'title' => $singleton->getTitle(),
-                'segment' => $singleton->config()->segment ?: str_replace('\\', '-', $class ?? ''),
-                'description' => $desc,
+                'segment' => $class::getNameWithoutNamespace(),
+                'description' => $description,
+                'parameters' => $singleton->getOptionsForTemplate(),
+                'help' => $help,
             ];
         }
 
         return $availableTasks;
     }
 
-    protected function getTaskList(): array
-    {
-        $taskClasses = ClassInfo::subclassesFor(BuildTask::class, false);
-        foreach ($taskClasses as $index => $task) {
-            if (!$this->taskEnabled($task)) {
-                unset($taskClasses[$index]);
-            }
-        }
-
-        return $taskClasses;
-    }
-
     /**
      * @param string $class
      * @return boolean
@@ -181,6 +183,7 @@ protected function taskEnabled($class)
             return false;
         }
 
+        /** @var BuildTask $task */
         $task = Injector::inst()->get($class);
         if (!$task->isEnabled()) {
             return false;
@@ -197,8 +200,7 @@ protected function canViewAllTasks(): bool
     {
         return (
             Director::isDev()
-            // We need to ensure that DevelopmentAdminTest can simulate permission failures when running
-            // "dev/tasks" from CLI.
+            // We need to ensure that unit tests can simulate permission failures when navigating to "dev/tasks"
             || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli'))
             || Permission::check(static::config()->get('init_permissions'))
         );
diff --git a/src/Dev/Tasks/CleanupTestDatabasesTask.php b/src/Dev/Tasks/CleanupTestDatabasesTask.php
index 10c399afbd3..70c30a29bba 100644
--- a/src/Dev/Tasks/CleanupTestDatabasesTask.php
+++ b/src/Dev/Tasks/CleanupTestDatabasesTask.php
@@ -2,12 +2,11 @@
 
 namespace SilverStripe\Dev\Tasks;
 
-use SilverStripe\Control\Director;
 use SilverStripe\Dev\BuildTask;
-use SilverStripe\Dev\Deprecation;
+use SilverStripe\PolyExecution\PolyOutput;
 use SilverStripe\ORM\Connect\TempDatabase;
-use SilverStripe\Security\Permission;
-use SilverStripe\Security\Security;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
 
 /**
  * Cleans up leftover databases from aborted test executions (starting with ss_tmpdb)
@@ -15,33 +14,20 @@
  */
 class CleanupTestDatabasesTask extends BuildTask
 {
+    protected static string $commandName = 'CleanupTestDatabasesTask';
 
-    private static $segment = 'CleanupTestDatabasesTask';
+    protected string $title = 'Deletes all temporary test databases';
 
-    protected $title = 'Deletes all temporary test databases';
+    protected static string $description = 'Cleans up leftover databases from aborted test executions (starting with ss_tmpdb)';
 
-    protected $description = 'Cleans up leftover databases from aborted test executions (starting with ss_tmpdb)';
+    private static string|array|null $permissions_for_browser_execution = [
+        'anyone_with_dev_admin_permissions' => null,
+        'anyone_with_task_permissions' => null,
+    ];
 
-    public function run($request)
+    protected function execute(InputInterface $input, PolyOutput $output): int
     {
-        if (!$this->canView()) {
-            $response = Security::permissionFailure();
-            if ($response) {
-                $response->output();
-            }
-            die;
-        }
         TempDatabase::create()->deleteAll();
-    }
-
-    public function canView(): bool
-    {
-        Deprecation::withNoReplacement(function () {
-            Deprecation::notice(
-                '5.4.0',
-                'Will be replaced with canRunInBrowser()'
-            );
-        });
-        return Permission::check('ADMIN') || Director::is_cli();
+        return Command::SUCCESS;
     }
 }
diff --git a/src/Dev/Tasks/i18nTextCollectorTask.php b/src/Dev/Tasks/i18nTextCollectorTask.php
index 8ecd4b279b7..39a84593b67 100644
--- a/src/Dev/Tasks/i18nTextCollectorTask.php
+++ b/src/Dev/Tasks/i18nTextCollectorTask.php
@@ -2,83 +2,71 @@
 
 namespace SilverStripe\Dev\Tasks;
 
-use SilverStripe\Control\HTTPRequest;
 use SilverStripe\Core\Environment;
 use SilverStripe\Core\Injector\Injector;
-use SilverStripe\Dev\Debug;
 use SilverStripe\Dev\BuildTask;
+use SilverStripe\PolyExecution\PolyOutput;
 use SilverStripe\i18n\TextCollection\i18nTextCollector;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
 
 /**
  * Collects i18n strings
+ *
+ * It will search for existent modules that use the i18n feature, parse the _t() calls
+ * and write the resultant files in the lang folder of each module.
  */
 class i18nTextCollectorTask extends BuildTask
 {
+    protected static string $commandName = 'i18nTextCollectorTask';
 
-    private static $segment = 'i18nTextCollectorTask';
+    protected string $title = "i18n Textcollector Task";
 
-    protected $title = "i18n Textcollector Task";
+    protected static string $description = 'Traverses through files in order to collect the '
+                                            . '"entity master tables" stored in each module.';
 
-    protected $description = "
-		Traverses through files in order to collect the 'entity master tables'
-		stored in each module.
-
-		Parameters:
-		- locale: Sets default locale
-		- writer: Custom writer class (defaults to i18nTextCollector_Writer_RailsYaml)
-		- module: One or more modules to limit collection (comma-separated)
-		- merge: Merge new strings with existing ones already defined in language files (default: TRUE)
-	";
-
-    /**
-     * This is the main method to build the master string tables with the original strings.
-     * It will search for existent modules that use the i18n feature, parse the _t() calls
-     * and write the resultant files in the lang folder of each module.
-     *
-     * @uses DataObject::collectI18nStatics()
-     *
-     * @param HTTPRequest $request
-     */
-    public function run($request)
+    protected function execute(InputInterface $input, PolyOutput $output): int
     {
         Environment::increaseTimeLimitTo();
-        $collector = i18nTextCollector::create($request->getVar('locale'));
+        $collector = i18nTextCollector::create($input->getOption('locale'));
 
-        $merge = $this->getIsMerge($request);
+        $merge = $this->getIsMerge($input);
 
         // Custom writer
-        $writerName = $request->getVar('writer');
+        $writerName = $input->getOption('writer');
         if ($writerName) {
             $writer = Injector::inst()->get($writerName);
             $collector->setWriter($writer);
         }
 
         // Get restrictions
-        $restrictModules = ($request->getVar('module'))
-            ? explode(',', $request->getVar('module'))
+        $restrictModules = ($input->getOption('module'))
+            ? explode(',', $input->getOption('module'))
             : null;
 
         $collector->run($restrictModules, $merge);
 
-        Debug::message(__CLASS__ . " completed!", false);
+        return Command::SUCCESS;
     }
 
     /**
      * Check if we should merge
-     *
-     * @param HTTPRequest $request
-     * @return bool
      */
-    protected function getIsMerge($request)
+    protected function getIsMerge(InputInterface $input): bool
     {
-        $merge = $request->getVar('merge');
-
-        // Default to true if not given
-        if (!isset($merge)) {
-            return true;
-        }
-
+        $merge = $input->getOption('merge');
         // merge=0 or merge=false will disable merge
         return !in_array($merge, ['0', 'false']);
     }
+
+    public function getOptions(): array
+    {
+        return [
+            new InputOption('locale', null, InputOption::VALUE_REQUIRED, 'Sets default locale'),
+            new InputOption('writer', null, InputOption::VALUE_REQUIRED, 'Custom writer class (must implement the <info>SilverStripe\i18n\Messages\Writer</> interface)'),
+            new InputOption('module', null, InputOption::VALUE_REQUIRED, 'One or more modules to limit collection (comma-separated)'),
+            new InputOption('merge', null, InputOption::VALUE_NEGATABLE, 'Merge new strings with existing ones already defined in language files', true),
+        ];
+    }
 }
diff --git a/src/Dev/Validation/DatabaseAdminExtension.php b/src/Dev/Validation/DbBuildExtension.php
similarity index 55%
rename from src/Dev/Validation/DatabaseAdminExtension.php
rename to src/Dev/Validation/DbBuildExtension.php
index db0c83351d8..c245c660cf2 100644
--- a/src/Dev/Validation/DatabaseAdminExtension.php
+++ b/src/Dev/Validation/DbBuildExtension.php
@@ -4,24 +4,21 @@
 
 use ReflectionException;
 use SilverStripe\Core\Extension;
-use SilverStripe\ORM\DatabaseAdmin;
+use SilverStripe\Dev\Command\DbBuild;
 
 /**
  * Hook up static validation to the deb/build process
  *
- * @extends Extension<DatabaseAdmin>
+ * @extends Extension<DbBuild>
  */
-class DatabaseAdminExtension extends Extension
+class DbBuildExtension extends Extension
 {
     /**
-     * Extension point in @see DatabaseAdmin::doBuild()
+     * Extension point in @see DbBuild::doBuild()
      *
-     * @param bool $quiet
-     * @param bool $populate
-     * @param bool $testMode
      * @throws ReflectionException
      */
-    protected function onAfterBuild(bool $quiet, bool $populate, bool $testMode): void
+    protected function onAfterBuild(): void
     {
         $service = RelationValidationService::singleton();
 
diff --git a/src/Logging/HTTPOutputHandler.php b/src/Logging/ErrorOutputHandler.php
similarity index 86%
rename from src/Logging/HTTPOutputHandler.php
rename to src/Logging/ErrorOutputHandler.php
index 39df450f131..2b18ba79ad2 100644
--- a/src/Logging/HTTPOutputHandler.php
+++ b/src/Logging/ErrorOutputHandler.php
@@ -11,14 +11,11 @@
 use SilverStripe\Dev\Deprecation;
 
 /**
- * Output the error to the browser, with the given HTTP status code.
- * We recommend that you use a formatter that generates HTML with this.
- *
- * @deprecated 5.4.0 Will be renamed to ErrorOutputHandler
+ * Output the error to either the browser or the terminal, depending on
+ * the context we're running in.
  */
-class HTTPOutputHandler extends AbstractProcessingHandler
+class ErrorOutputHandler extends AbstractProcessingHandler
 {
-
     /**
      * @var string
      */
@@ -61,7 +58,7 @@ public function getContentType()
      * Default text/html
      *
      * @param string $contentType
-     * @return HTTPOutputHandler Return $this to allow chainable calls
+     * @return ErrorOutputHandler Return $this to allow chainable calls
      */
     public function setContentType($contentType)
     {
@@ -96,7 +93,7 @@ public function setStatusCode($statusCode)
      * Set a formatter to use if Director::is_cli() is true
      *
      * @param FormatterInterface $cliFormatter
-     * @return HTTPOutputHandler Return $this to allow chainable calls
+     * @return ErrorOutputHandler Return $this to allow chainable calls
      */
     public function setCLIFormatter(FormatterInterface $cliFormatter)
     {
@@ -179,6 +176,11 @@ protected function write(LogRecord $record): void
             }
         }
 
+        if (Director::is_cli()) {
+            echo $record['formatted'];
+            return;
+        }
+
         if (Controller::has_curr()) {
             $response = Controller::curr()->getResponse();
         } else {
@@ -197,14 +199,4 @@ protected function write(LogRecord $record): void
         $response->setBody($record['formatted']);
         $response->output();
     }
-
-    /**
-     * This method used to be used for unit testing but is no longer required.
-     * @deprecated 5.4.0 Use SilverStripe\Control\Director::is_cli() instead
-     */
-    protected function isCli(): bool
-    {
-        Deprecation::notice('5.4.0', 'Use ' . Director::class . '::is_cli() instead');
-        return Director::is_cli();
-    }
 }
diff --git a/src/ORM/Connect/DBSchemaManager.php b/src/ORM/Connect/DBSchemaManager.php
index c889bb66918..15049ec252e 100644
--- a/src/ORM/Connect/DBSchemaManager.php
+++ b/src/ORM/Connect/DBSchemaManager.php
@@ -19,9 +19,7 @@ abstract class DBSchemaManager
 {
 
     /**
-     *
-     * @config
-     * Check tables when running /dev/build, and repair them if necessary.
+     * Check tables when building the db, and repair them if necessary.
      * In case of large databases or more fine-grained control on how to handle
      * data corruption in tables, you can disable this behaviour and handle it
      * outside of this class, e.g. through a nightly system task with extended logging capabilities.
@@ -32,11 +30,11 @@ abstract class DBSchemaManager
 
     /**
      * For large databases you can declare a list of DataObject classes which will be excluded from
-     * CHECK TABLE and REPAIR TABLE queries during dev/build. Note that the entire inheritance chain
+     * CHECK TABLE and REPAIR TABLE queries when building the db. Note that the entire inheritance chain
      * for that class will be excluded, including both ancestors and descendants.
      *
      * Only use this configuration if you know what you are doing and have identified specific models
-     * as being problematic during your dev/build process.
+     * as being problematic when building the db.
      */
     private static array $exclude_models_from_db_checks = [];
 
diff --git a/src/ORM/Connect/TempDatabase.php b/src/ORM/Connect/TempDatabase.php
index 52c87c61075..598339ea22e 100644
--- a/src/ORM/Connect/TempDatabase.php
+++ b/src/ORM/Connect/TempDatabase.php
@@ -233,7 +233,7 @@ protected function rebuildTables($extraDataObjects = [])
     {
         DataObject::reset();
 
-        // clear singletons, they're caching old extension info which is used in DatabaseAdmin->doBuild()
+        // clear singletons, they're caching old extension info which is used in DbBuild->doBuild()
         Injector::inst()->unregisterObjects(DataObject::class);
 
         $dataClasses = ClassInfo::subclassesFor(DataObject::class);
diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php
index da89460b85e..8e77e4fa997 100644
--- a/src/ORM/DataObject.php
+++ b/src/ORM/DataObject.php
@@ -186,7 +186,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
 
     /**
      * Value for 2nd argument to constructor, indicating that a record is a singleton representing the whole type,
-     * e.g. to call requireTable() in dev/build
+     * e.g. to call requireTable() when building the db
      * Defaults will not be populated and data passed will be ignored
      */
     const CREATE_SINGLETON = 1;
@@ -3781,7 +3781,7 @@ public function requireDefaultRecords()
      * Invoked after every database build is complete (including after table creation and
      * default record population).
      *
-     * See {@link DatabaseAdmin::doBuild()} for context.
+     * See {@link DbBuild::doBuild()} for context.
      */
     public function onAfterBuild()
     {
diff --git a/src/ORM/DataObjectSchema.php b/src/ORM/DataObjectSchema.php
index 9fc2ca6646e..e0f1ee5f579 100644
--- a/src/ORM/DataObjectSchema.php
+++ b/src/ORM/DataObjectSchema.php
@@ -313,7 +313,7 @@ protected function cacheTableNames()
      * Generate table name for a class.
      *
      * Note: some DB schema have a hard limit on table name length. This is not enforced by this method.
-     * See dev/build errors for details in case of table name violation.
+     * See build errors for details in case of table name violation.
      *
      * @param string $class
      *
diff --git a/src/ORM/DatabaseAdmin.php b/src/ORM/DatabaseAdmin.php
deleted file mode 100644
index 29f56474a23..00000000000
--- a/src/ORM/DatabaseAdmin.php
+++ /dev/null
@@ -1,566 +0,0 @@
-<?php
-
-namespace SilverStripe\ORM;
-
-use BadMethodCallException;
-use Generator;
-use SilverStripe\Control\Controller;
-use SilverStripe\Control\Director;
-use SilverStripe\Core\ClassInfo;
-use SilverStripe\Core\Environment;
-use SilverStripe\Core\Injector\Injector;
-use SilverStripe\Core\Manifest\ClassLoader;
-use SilverStripe\Dev\Deprecation;
-use SilverStripe\Dev\DevBuildController;
-use SilverStripe\Dev\DevelopmentAdmin;
-use SilverStripe\ORM\Connect\DatabaseException;
-use SilverStripe\ORM\Connect\TableBuilder;
-use SilverStripe\ORM\FieldType\DBClassName;
-use SilverStripe\ORM\FieldType\DBClassNameVarchar;
-use SilverStripe\Security\Permission;
-use SilverStripe\Security\Security;
-use SilverStripe\Versioned\Versioned;
-
-/**
- * DatabaseAdmin class
- *
- * Utility functions for administrating the database. These can be accessed
- * via URL, e.g. http://www.yourdomain.com/db/build.
- *
- * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild
- */
-class DatabaseAdmin extends Controller
-{
-
-    /// SECURITY ///
-    private static $allowed_actions = [
-        'index',
-        'build',
-        'cleanup',
-        'import'
-    ];
-
-    /**
-     * Obsolete classname values that should be remapped in dev/build
-     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.classname_value_remapping
-     */
-    private static $classname_value_remapping = [
-        'File'               => 'SilverStripe\\Assets\\File',
-        'Image'              => 'SilverStripe\\Assets\\Image',
-        'Folder'             => 'SilverStripe\\Assets\\Folder',
-        'Group'              => 'SilverStripe\\Security\\Group',
-        'LoginAttempt'       => 'SilverStripe\\Security\\LoginAttempt',
-        'Member'             => 'SilverStripe\\Security\\Member',
-        'MemberPassword'     => 'SilverStripe\\Security\\MemberPassword',
-        'Permission'         => 'SilverStripe\\Security\\Permission',
-        'PermissionRole'     => 'SilverStripe\\Security\\PermissionRole',
-        'PermissionRoleCode' => 'SilverStripe\\Security\\PermissionRoleCode',
-        'RememberLoginHash'  => 'SilverStripe\\Security\\RememberLoginHash',
-    ];
-
-    /**
-     * Config setting to enabled/disable the display of record counts on the dev/build output
-     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.show_record_counts
-     */
-    private static $show_record_counts = true;
-
-    public function __construct()
-    {
-        parent::__construct();
-        Deprecation::withNoReplacement(function () {
-            Deprecation::notice(
-                '5.4.0',
-                'Will be replaced with SilverStripe\Dev\Command\DbBuild',
-                Deprecation::SCOPE_CLASS
-            );
-        });
-    }
-
-    protected function init()
-    {
-        parent::init();
-
-        if (!$this->canInit()) {
-            Security::permissionFailure(
-                $this,
-                "This page is secured and you need elevated permissions to access it. " .
-                "Enter your credentials below and we will send you right along."
-            );
-        }
-    }
-
-    /**
-     * Get the data classes, grouped by their root class
-     *
-     * @return array Array of data classes, grouped by their root class
-     */
-    public function groupedDataClasses()
-    {
-        // Get all root data objects
-        $allClasses = get_declared_classes();
-        $rootClasses = [];
-        foreach ($allClasses as $class) {
-            if (get_parent_class($class ?? '') == DataObject::class) {
-                $rootClasses[$class] = [];
-            }
-        }
-
-        // Assign every other data object one of those
-        foreach ($allClasses as $class) {
-            if (!isset($rootClasses[$class]) && is_subclass_of($class, DataObject::class)) {
-                foreach ($rootClasses as $rootClass => $dummy) {
-                    if (is_subclass_of($class, $rootClass ?? '')) {
-                        $rootClasses[$rootClass][] = $class;
-                        break;
-                    }
-                }
-            }
-        }
-        return $rootClasses;
-    }
-
-
-    /**
-     * When we're called as /dev/build, that's actually the index. Do the same
-     * as /dev/build/build.
-     */
-    public function index()
-    {
-        return $this->build();
-    }
-
-    /**
-     * Updates the database schema, creating tables & fields as necessary.
-     */
-    public function build()
-    {
-        // The default time limit of 30 seconds is normally not enough
-        Environment::increaseTimeLimitTo(600);
-
-        // If this code is being run outside of a dev/build or without a ?flush query string param,
-        // the class manifest hasn't been flushed, so do it here
-        $request = $this->getRequest();
-        if (!array_key_exists('flush', $request->getVars() ?? []) && strpos($request->getURL() ?? '', 'dev/build') !== 0) {
-            ClassLoader::inst()->getManifest()->regenerate(false);
-        }
-
-        $url = $this->getReturnURL();
-        if ($url) {
-            echo "<p>Setting up the database; you will be returned to your site shortly....</p>";
-            $this->doBuild(true);
-            echo "<p>Done!</p>";
-            $this->redirect($url);
-        } else {
-            $quiet = $this->request->requestVar('quiet') !== null;
-            $fromInstaller = $this->request->requestVar('from_installer') !== null;
-            $populate = $this->request->requestVar('dont_populate') === null;
-            $this->doBuild($quiet || $fromInstaller, $populate);
-        }
-    }
-
-    /**
-     * Gets the url to return to after build
-     *
-     * @return string|null
-     */
-    protected function getReturnURL()
-    {
-        $url = $this->request->getVar('returnURL');
-
-        // Check that this url is a site url
-        if (empty($url) || !Director::is_site_url($url)) {
-            return null;
-        }
-
-        // Convert to absolute URL
-        return Director::absoluteURL((string) $url, true);
-    }
-
-    /**
-     * Build the default data, calling requireDefaultRecords on all
-     * DataObject classes
-     */
-    public function buildDefaults()
-    {
-        $dataClasses = ClassInfo::subclassesFor(DataObject::class);
-        array_shift($dataClasses);
-
-        if (!Director::is_cli()) {
-            echo "<ul>";
-        }
-
-        foreach ($dataClasses as $dataClass) {
-            singleton($dataClass)->requireDefaultRecords();
-            if (Director::is_cli()) {
-                echo "Defaults loaded for $dataClass\n";
-            } else {
-                echo "<li>Defaults loaded for $dataClass</li>\n";
-            }
-        }
-
-        if (!Director::is_cli()) {
-            echo "</ul>";
-        }
-    }
-
-    /**
-     * Returns the timestamp of the time that the database was last built
-     *
-     * @return string Returns the timestamp of the time that the database was
-     *                last built
-     *
-     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()
-     */
-    public static function lastBuilt()
-    {
-        Deprecation::withNoReplacement(function () {
-            Deprecation::notice(
-                '5.4.0',
-                'Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()'
-            );
-        });
-
-        $file = TEMP_PATH
-            . DIRECTORY_SEPARATOR
-            . 'database-last-generated-'
-            . str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? '');
-
-        if (file_exists($file ?? '')) {
-            return filemtime($file ?? '');
-        }
-        return null;
-    }
-
-
-    /**
-     * Updates the database schema, creating tables & fields as necessary.
-     *
-     * @param boolean $quiet    Don't show messages
-     * @param boolean $populate Populate the database, as well as setting up its schema
-     * @param bool    $testMode
-     */
-    public function doBuild($quiet = false, $populate = true, $testMode = false)
-    {
-        $this->extend('onBeforeBuild', $quiet, $populate, $testMode);
-
-        if ($quiet) {
-            DB::quiet();
-        } else {
-            $conn = DB::get_conn();
-            // Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database")
-            $dbType = substr(get_class($conn), 0, -8);
-            $dbVersion = $conn->getVersion();
-            $databaseName = $conn->getSelectedDatabase();
-
-            if (Director::is_cli()) {
-                echo sprintf("\n\nBuilding database %s using %s %s\n\n", $databaseName, $dbType, $dbVersion);
-            } else {
-                echo sprintf("<h2>Building database %s using %s %s</h2>", $databaseName, $dbType, $dbVersion);
-            }
-        }
-
-        // Set up the initial database
-        if (!DB::is_active()) {
-            if (!$quiet) {
-                echo '<p><b>Creating database</b></p>';
-            }
-
-            // Load parameters from existing configuration
-            $databaseConfig = DB::getConfig();
-            if (empty($databaseConfig) && empty($_REQUEST['db'])) {
-                throw new BadMethodCallException("No database configuration available");
-            }
-            $parameters = (!empty($databaseConfig)) ? $databaseConfig : $_REQUEST['db'];
-
-            // Check database name is given
-            if (empty($parameters['database'])) {
-                throw new BadMethodCallException(
-                    "No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME"
-                );
-            }
-            $database = $parameters['database'];
-
-            // Establish connection
-            unset($parameters['database']);
-            DB::connect($parameters);
-
-            // Check to ensure that the re-instated SS_DATABASE_SUFFIX functionality won't unexpectedly
-            // rename the database. To be removed for SS5
-            if ($suffix = Environment::getEnv('SS_DATABASE_SUFFIX')) {
-                $previousName = preg_replace("/{$suffix}$/", '', $database ?? '');
-
-                if (!isset($_GET['force_suffix_rename']) && DB::get_conn()->databaseExists($previousName)) {
-                    throw new DatabaseException(
-                        "SS_DATABASE_SUFFIX was previously broken, but has now been fixed. This will result in your "
-                        . "database being named \"{$database}\" instead of \"{$previousName}\" from now on. If this "
-                        . "change is intentional, please visit dev/build?force_suffix_rename=1 to continue"
-                    );
-                }
-            }
-
-            // Create database
-            DB::create_database($database);
-        }
-
-        // Build the database.  Most of the hard work is handled by DataObject
-        $dataClasses = ClassInfo::subclassesFor(DataObject::class);
-        array_shift($dataClasses);
-
-        if (!$quiet) {
-            if (Director::is_cli()) {
-                echo "\nCREATING DATABASE TABLES\n\n";
-            } else {
-                echo "\n<p><b>Creating database tables</b></p><ul>\n\n";
-            }
-        }
-
-        $showRecordCounts = (boolean)$this->config()->show_record_counts;
-
-        // Initiate schema update
-        $dbSchema = DB::get_schema();
-        $tableBuilder = TableBuilder::singleton();
-        $tableBuilder->buildTables($dbSchema, $dataClasses, [], $quiet, $testMode, $showRecordCounts);
-        ClassInfo::reset_db_cache();
-
-        if (!$quiet && !Director::is_cli()) {
-            echo "</ul>";
-        }
-
-        if ($populate) {
-            if (!$quiet) {
-                if (Director::is_cli()) {
-                    echo "\nCREATING DATABASE RECORDS\n\n";
-                } else {
-                    echo "\n<p><b>Creating database records</b></p><ul>\n\n";
-                }
-            }
-
-            // Remap obsolete class names
-            $this->migrateClassNames();
-
-            // Require all default records
-            foreach ($dataClasses as $dataClass) {
-                // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
-                // Test_ indicates that it's the data class is part of testing system
-                if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) {
-                    if (!$quiet) {
-                        if (Director::is_cli()) {
-                            echo " * $dataClass\n";
-                        } else {
-                            echo "<li>$dataClass</li>\n";
-                        }
-                    }
-
-                    DataObject::singleton($dataClass)->requireDefaultRecords();
-                }
-            }
-
-            if (!$quiet && !Director::is_cli()) {
-                echo "</ul>";
-            }
-        }
-
-        touch(TEMP_PATH
-            . DIRECTORY_SEPARATOR
-            . 'database-last-generated-'
-            . str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? ''));
-
-        if (isset($_REQUEST['from_installer'])) {
-            echo "OK";
-        }
-
-        if (!$quiet) {
-            echo (Director::is_cli()) ? "\n Database build completed!\n\n" : "<p>Database build completed!</p>";
-        }
-
-        foreach ($dataClasses as $dataClass) {
-            DataObject::singleton($dataClass)->onAfterBuild();
-        }
-
-        ClassInfo::reset_db_cache();
-
-        $this->extend('onAfterBuild', $quiet, $populate, $testMode);
-    }
-
-    public function canInit(): bool
-    {
-        // We allow access to this controller regardless of live-status or ADMIN permission only
-        // if on CLI or with the database not ready. The latter makes it less error-prone to do an
-        // initial schema build without requiring a default-admin login.
-        // Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
-        $allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli');
-        return (
-            Director::isDev()
-            || !Security::database_is_ready()
-            // We need to ensure that DevelopmentAdminTest can simulate permission failures when running
-            // "dev/tests" from CLI.
-            || (Director::is_cli() && $allowAllCLI)
-            || Permission::check(DevBuildController::config()->get('init_permissions'))
-        );
-    }
-
-    /**
-     * Given a base data class, a field name and a mapping of class replacements, look for obsolete
-     * values in the $dataClass's $fieldName column and replace it with $mapping
-     *
-     * @param string   $dataClass The data class to look up
-     * @param string   $fieldName The field name to look in for obsolete class names
-     * @param string[] $mapping   Map of old to new classnames
-     */
-    protected function updateLegacyClassNameField($dataClass, $fieldName, $mapping)
-    {
-        $schema = DataObject::getSchema();
-        // Check first to ensure that the class has the specified field to update
-        if (!$schema->databaseField($dataClass, $fieldName, false)) {
-            return;
-        }
-
-        // Load a list of any records that have obsolete class names
-        $table = $schema->tableName($dataClass);
-        $currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column();
-
-        // Get all invalid classes for this field
-        $invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? []));
-        if (!$invalidClasses) {
-            return;
-        }
-
-        $numberClasses = count($invalidClasses ?? []);
-        DB::alteration_message(
-            "Correcting obsolete {$fieldName} values for {$numberClasses} outdated types",
-            'obsolete'
-        );
-
-        // Build case assignment based on all intersected legacy classnames
-        $cases = [];
-        $params = [];
-        foreach ($invalidClasses as $invalidClass) {
-            $cases[] = "WHEN \"{$fieldName}\" = ? THEN ?";
-            $params[] = $invalidClass;
-            $params[] = $mapping[$invalidClass];
-        }
-
-        foreach ($this->getClassTables($dataClass) as $table) {
-            $casesSQL = implode(' ', $cases);
-            $sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END";
-            DB::prepared_query($sql, $params);
-        }
-    }
-
-    /**
-     * Get tables to update for this class
-     *
-     * @param string $dataClass
-     * @return Generator|string[]
-     */
-    protected function getClassTables($dataClass)
-    {
-        $schema = DataObject::getSchema();
-        $table = $schema->tableName($dataClass);
-
-        // Base table
-        yield $table;
-
-        // Remap versioned table class name values as well
-        /** @var Versioned|DataObject $dataClass */
-        $dataClass = DataObject::singleton($dataClass);
-        if ($dataClass->hasExtension(Versioned::class)) {
-            if ($dataClass->hasStages()) {
-                yield "{$table}_Live";
-            }
-            yield "{$table}_Versions";
-        }
-    }
-
-    /**
-     * Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes
-     * `ClassName` fields as well as polymorphic class name fields.
-     *
-     * @return array[]
-     */
-    protected function getClassNameRemappingFields()
-    {
-        $dataClasses = ClassInfo::getValidSubClasses(DataObject::class);
-        $schema = DataObject::getSchema();
-        $remapping = [];
-
-        foreach ($dataClasses as $className) {
-            $fieldSpecs = $schema->fieldSpecs($className);
-            foreach ($fieldSpecs as $fieldName => $fieldSpec) {
-                $dummy = Injector::inst()->create($fieldSpec, 'Dummy');
-                if ($dummy instanceof DBClassName || $dummy instanceof DBClassNameVarchar) {
-                    $remapping[$className][] = $fieldName;
-                }
-            }
-        }
-
-        return $remapping;
-    }
-
-    /**
-     * Remove invalid records from tables - that is, records that don't have
-     * corresponding records in their parent class tables.
-     */
-    public function cleanup()
-    {
-        $baseClasses = [];
-        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
-            if (get_parent_class($class ?? '') == DataObject::class) {
-                $baseClasses[] = $class;
-            }
-        }
-
-        $schema = DataObject::getSchema();
-        foreach ($baseClasses as $baseClass) {
-            // Get data classes
-            $baseTable = $schema->baseDataTable($baseClass);
-            $subclasses = ClassInfo::subclassesFor($baseClass);
-            unset($subclasses[0]);
-            foreach ($subclasses as $k => $subclass) {
-                if (!DataObject::getSchema()->classHasTable($subclass)) {
-                    unset($subclasses[$k]);
-                }
-            }
-
-            if ($subclasses) {
-                $records = DB::query("SELECT * FROM \"$baseTable\"");
-
-
-                foreach ($subclasses as $subclass) {
-                    $subclassTable = $schema->tableName($subclass);
-                    $recordExists[$subclass] =
-                        DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn();
-                }
-
-                foreach ($records as $record) {
-                    foreach ($subclasses as $subclass) {
-                        $subclassTable = $schema->tableName($subclass);
-                        $id = $record['ID'];
-                        if (($record['ClassName'] != $subclass)
-                            && (!is_subclass_of($record['ClassName'], $subclass ?? ''))
-                            && isset($recordExists[$subclass][$id])
-                        ) {
-                            $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?";
-                            echo "<li>$sql [{$id}]</li>";
-                            DB::prepared_query($sql, [$id]);
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    /**
-     * Migrate all class names
-     */
-    protected function migrateClassNames()
-    {
-        $remappingConfig = $this->config()->get('classname_value_remapping');
-        $remappingFields = $this->getClassNameRemappingFields();
-        foreach ($remappingFields as $className => $fieldNames) {
-            foreach ($fieldNames as $fieldName) {
-                $this->updateLegacyClassNameField($className, $fieldName, $remappingConfig);
-            }
-        }
-    }
-}
diff --git a/src/ORM/FieldType/DBClassNameTrait.php b/src/ORM/FieldType/DBClassNameTrait.php
index 6a892b59aaf..132074054ff 100644
--- a/src/ORM/FieldType/DBClassNameTrait.php
+++ b/src/ORM/FieldType/DBClassNameTrait.php
@@ -56,7 +56,7 @@ public function getBaseClass(): string
         if ($this->record) {
             return $schema->baseDataClass($this->record);
         }
-        // During dev/build only the table is assigned
+        // When building the db only the table is assigned
         $tableClass = $schema->tableClass($this->getTable());
         if ($tableClass && ($baseClass = $schema->baseDataClass($tableClass))) {
             return $baseClass;
diff --git a/src/PolyExecution/AnsiToHtmlConverter.php b/src/PolyExecution/AnsiToHtmlConverter.php
new file mode 100644
index 00000000000..9d3bbb35a50
--- /dev/null
+++ b/src/PolyExecution/AnsiToHtmlConverter.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace SilverStripe\PolyExecution;
+
+use SensioLabs\AnsiConverter\AnsiToHtmlConverter as BaseAnsiConverter;
+use SensioLabs\AnsiConverter\Theme\Theme;
+use SilverStripe\Core\Injector\Injectable;
+
+/**
+ * Converts an ANSI text to HTML5 but doesn't give an opinionated default colour that isn't specified in the ANSI.
+ */
+class AnsiToHtmlConverter extends BaseAnsiConverter
+{
+    use Injectable;
+
+    public function __construct(Theme $theme = null, $inlineStyles = true, $charset = 'UTF-8')
+    {
+        $theme ??= AnsiToHtmlTheme::create();
+        parent::__construct($theme, $inlineStyles, $charset);
+    }
+
+    public function convert($text)
+    {
+        // remove cursor movement sequences
+        $text = preg_replace('#\e\[(K|s|u|2J|2K|\d+(A|B|C|D|E|F|G|J|K|S|T)|\d+;\d+(H|f))#', '', $text);
+        // remove character set sequences
+        $text = preg_replace('#\e(\(|\))(A|B|[0-2])#', '', $text);
+
+        $text = htmlspecialchars($text, PHP_VERSION_ID >= 50400 ? ENT_QUOTES | ENT_SUBSTITUTE : ENT_QUOTES, $this->charset);
+
+        // convert hyperlinks to `<a>` tags (this is new to this subclass)
+        $text = preg_replace('#\033]8;;(?<href>[^\033]*)\033\\\(?<text>[^\033]*)\033]8;;\033\\\#', '<a href="$1">$2</a>', $text);
+
+        // carriage return
+        $text = preg_replace('#^.*\r(?!\n)#m', '', $text);
+
+        $tokens = $this->tokenize($text);
+
+        // a backspace remove the previous character but only from a text token
+        foreach ($tokens as $i => $token) {
+            if ('backspace' == $token[0]) {
+                $j = $i;
+                while (--$j >= 0) {
+                    if ('text' == $tokens[$j][0] && strlen($tokens[$j][1]) > 0) {
+                        $tokens[$j][1] = substr($tokens[$j][1], 0, -1);
+
+                        break;
+                    }
+                }
+            }
+        }
+
+        $html = '';
+        foreach ($tokens as $token) {
+            if ('text' == $token[0]) {
+                $html .= $token[1];
+            } elseif ('color' == $token[0]) {
+                $html .= $this->convertAnsiToColor($token[1]);
+            }
+        }
+
+        // These lines commented out from the parent class implementation.
+        // We don't want this opinionated default colouring - it doesn't appear in the ANSI format so it doesn't belong in the output.
+        // if ($this->inlineStyles) {
+        //     $html = sprintf('<span style="background-color: %s; color: %s">%s</span>', $this->inlineColors['black'], $this->inlineColors['white'], $html);
+        // } else {
+        //     $html = sprintf('<span class="ansi_color_bg_black ansi_color_fg_white">%s</span>', $html);
+        // }
+        // We do need an opening and closing span though, or the HTML markup is broken
+        $html = '<span>' . $html . '</span>';
+
+        // remove empty span
+        $html = preg_replace('#<span[^>]*></span>#', '', $html);
+        // remove unnecessary span
+        $html = preg_replace('#<span>(.*?(?!</span>)[^<]*)</span>#', '$1', $html);
+
+        return $html;
+    }
+
+    protected function convertAnsiToColor($ansi)
+    {
+        // Set $bg and $fg to null so we don't have a default opinionated colouring
+        $bg = null;
+        $fg = null;
+        $style = [];
+        $classes = [];
+        if ('0' != $ansi && '' != $ansi) {
+            $options = explode(';', $ansi);
+
+            foreach ($options as $option) {
+                if ($option >= 30 && $option < 38) {
+                    $fg = $option - 30;
+                } elseif ($option >= 40 && $option < 48) {
+                    $bg = $option - 40;
+                } elseif (39 == $option) {
+                    $fg = null; // reset to default
+                } elseif (49 == $option) {
+                    $bg = null; // reset to default
+                }
+            }
+
+            // options: bold => 1, underscore => 4, blink => 5, reverse => 7, conceal => 8
+            if (in_array(1, $options)) {
+                $style[] = 'font-weight: bold';
+                $classes[] = 'ansi_bold';
+            }
+
+            if (in_array(4, $options)) {
+                $style[] = 'text-decoration: underline';
+                $classes[] = 'ansi_underline';
+            }
+
+            if (in_array(7, $options)) {
+                $tmp = $fg;
+                $fg = $bg;
+                $bg = $tmp;
+            }
+        }
+
+        // Biggest changes start here and go to the end of the method.
+        // We're explicitly only setting the styling that was included in the ANSI formatting. The original applies
+        // default colours regardless.
+        if ($bg !== null) {
+            $style[] = sprintf('background-color: %s', $this->inlineColors[$this->colorNames[$bg]]);
+            $classes[] = sprintf('ansi_color_bg_%s', $this->colorNames[$bg]);
+        }
+        if ($fg !== null) {
+            $style[] = sprintf('color: %s', $this->inlineColors[$this->colorNames[$fg]]);
+            $classes[] = sprintf('ansi_color_fg_%s', $this->colorNames[$fg]);
+        }
+
+        if ($this->inlineStyles && !empty($style)) {
+            return sprintf('</span><span style="%s">', implode('; ', $style));
+        }
+        if (!$this->inlineStyles && !empty($classes)) {
+            return sprintf('</span><span class="%s">', implode('; ', $classes));
+        }
+
+        // Because of the way the parent class is implemented, we need to stop the old span and start a new one
+        // even if we don't have any styling to apply.
+        return '</span><span>';
+    }
+}
diff --git a/src/PolyExecution/AnsiToHtmlTheme.php b/src/PolyExecution/AnsiToHtmlTheme.php
new file mode 100644
index 00000000000..69e363f2cc6
--- /dev/null
+++ b/src/PolyExecution/AnsiToHtmlTheme.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace SilverStripe\PolyExecution;
+
+use SensioLabs\AnsiConverter\Theme\Theme;
+use SilverStripe\Core\Injector\Injectable;
+
+/**
+ * Theme for converting ANSI colours to something suitable in a browser against a white background
+ */
+class AnsiToHtmlTheme extends Theme
+{
+    use Injectable;
+
+    public function asArray()
+    {
+        $colourMap = parent::asArray();
+        $colourMap['cyan'] = 'royalblue';
+        $colourMap['yellow'] = 'goldenrod';
+        return $colourMap;
+    }
+
+    public function asArrayBackground()
+    {
+        $colourMap = parent::asArrayBackground();
+        $colourMap['cyan'] = 'royalblue';
+        $colourMap['yellow'] = 'goldenrod';
+        return $colourMap;
+    }
+}
diff --git a/src/PolyExecution/HtmlOutputFormatter.php b/src/PolyExecution/HtmlOutputFormatter.php
new file mode 100644
index 00000000000..51694014416
--- /dev/null
+++ b/src/PolyExecution/HtmlOutputFormatter.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace SilverStripe\PolyExecution;
+
+use SilverStripe\Core\Injector\Injectable;
+use Symfony\Component\Console\Formatter\OutputFormatterInterface;
+use Symfony\Component\Console\Formatter\OutputFormatterStyleInterface;
+
+/**
+ * Wraps an ANSI formatter and converts the ANSI formatting to styled HTML.
+ */
+class HtmlOutputFormatter implements OutputFormatterInterface
+{
+    use Injectable;
+
+    private OutputFormatterInterface $ansiFormatter;
+    private AnsiToHtmlConverter $ansiConverter;
+
+    public function __construct(OutputFormatterInterface $formatter)
+    {
+        $this->ansiFormatter = $formatter;
+        $this->ansiConverter = AnsiToHtmlConverter::create();
+    }
+
+    public function setDecorated(bool $decorated): void
+    {
+        $this->ansiFormatter->setDecorated($decorated);
+    }
+
+    public function isDecorated(): bool
+    {
+        return $this->ansiFormatter->isDecorated();
+    }
+
+    public function setStyle(string $name, OutputFormatterStyleInterface $style): void
+    {
+        $this->ansiFormatter->setStyle($name, $style);
+    }
+
+    public function hasStyle(string $name): bool
+    {
+        return $this->ansiFormatter->hasStyle($name);
+    }
+
+    public function getStyle(string $name): OutputFormatterStyleInterface
+    {
+        return $this->ansiFormatter->getStyle($name);
+    }
+
+    public function format(?string $message): ?string
+    {
+        $formatted = $this->ansiFormatter->format($message);
+        if ($this->isDecorated()) {
+            return $this->ansiConverter->convert($formatted);
+        }
+        return $formatted;
+    }
+}
diff --git a/src/PolyExecution/HttpRequestInput.php b/src/PolyExecution/HttpRequestInput.php
new file mode 100644
index 00000000000..f1b17c3ea80
--- /dev/null
+++ b/src/PolyExecution/HttpRequestInput.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace SilverStripe\PolyExecution;
+
+use SilverStripe\Control\HTTPRequest;
+use SilverStripe\Core\Injector\Injectable;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Input\InputDefinition;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Input that populates options from an HTTPRequest
+ *
+ * Use this for inputs to PolyCommand when called from a web request.
+ */
+class HttpRequestInput extends ArrayInput
+{
+    use Injectable;
+
+    protected bool $interactive = false;
+
+    /**
+     * @param array<InputOption> $commandOptions Any options that apply for the command itself.
+     * Do not include global options (e.g. flush) - they are added explicitly in the constructor.
+     */
+    public function __construct(HTTPRequest $request, array $commandOptions = [])
+    {
+        $definition = new InputDefinition([
+            // Also add global options that are applicable for HTTP requests
+            new InputOption('quiet', null, InputOption::VALUE_NONE, 'Do not output any message'),
+            new InputOption('verbose', null, InputOption::VALUE_OPTIONAL, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug'),
+            // The actual flushing already happened before this point, but we still need
+            // to declare the option in case someone's checking against it
+            new InputOption('flush', null, InputOption::VALUE_NONE, 'Flush the cache before running the command'),
+            ...$commandOptions
+        ]);
+        $optionValues = $this->getOptionValuesFromRequest($request, $definition);
+        parent::__construct($optionValues, $definition);
+    }
+
+    /**
+     * Get the verbosity that should be used based on the request vars.
+     * This is used to set the verbosity for PolyOutput.
+     */
+    public function getVerbosity(): int
+    {
+        if ($this->getOption('quiet')) {
+            return OutputInterface::VERBOSITY_QUIET;
+        }
+        $verbose = $this->getOption('verbose');
+        if ($verbose === '1' || $verbose === 1 || $verbose === true) {
+            return OutputInterface::VERBOSITY_VERBOSE;
+        }
+        if ($verbose === '2' || $verbose === 2) {
+            return OutputInterface::VERBOSITY_VERY_VERBOSE;
+        }
+        if ($verbose === '3' || $verbose === 3) {
+            return OutputInterface::VERBOSITY_DEBUG;
+        }
+        return OutputInterface::VERBOSITY_NORMAL;
+    }
+
+    private function getOptionValuesFromRequest(HTTPRequest $request, InputDefinition $definition): array
+    {
+        $options = [];
+        foreach ($definition->getOptions() as $option) {
+            // We'll check for the long name and all shortcuts.
+            // Note the `--` and `-` prefixes are already stripped at this point.
+            $candidateParams = [$option->getName()];
+            $shortcutString = $option->getShortcut();
+            if ($shortcutString !== null) {
+                $shortcuts = explode('|', $shortcutString);
+                foreach ($shortcuts as $shortcut) {
+                    $candidateParams[] = $shortcut;
+                }
+            }
+            // Get a value if there is one
+            $value = null;
+            foreach ($candidateParams as $candidateParam) {
+                $value = $request->requestVar($candidateParam);
+            }
+            $default = $option->getDefault();
+            // Set correct default value
+            if ($value === null && $default !== null) {
+                $value = $default;
+            }
+            // Ignore missing values if values aren't required
+            if (($value === null || $value === []) && $option->isValueRequired()) {
+                continue;
+            }
+            // Convert value to array if it should be one
+            if ($value !== null && $option->isArray() && !is_array($value)) {
+                $value = [$value];
+            }
+            // If there's a value (or the option accepts one and didn't get one), set the option.
+            if ($value !== null || $option->acceptValue()) {
+                // If the option doesn't accept a value, determine the correct boolean state for it.
+                // If we weren't able to determine if the value's boolean-ness, default to truthy=true
+                // because that's what you'd end up with with `if ($request->requestVar('myVar'))`
+                if (!$option->acceptValue()) {
+                    $value = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true;
+                }
+                // We need to prefix with `--` so the superclass knows it's an
+                // option rather than an argument.
+                $options['--' . $option->getName()] = $value;
+            }
+        }
+        return $options;
+    }
+}
diff --git a/src/PolyExecution/PolyCommand.php b/src/PolyExecution/PolyCommand.php
new file mode 100644
index 00000000000..4774994184f
--- /dev/null
+++ b/src/PolyExecution/PolyCommand.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace SilverStripe\PolyExecution;
+
+use RuntimeException;
+use SilverStripe\Control\Director;
+use SilverStripe\Core\Config\Configurable;
+use SilverStripe\Core\Injector\Injectable;
+use SilverStripe\Dev\DevelopmentAdmin;
+use SilverStripe\PolyExecution\HtmlOutputFormatter;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\ORM\FieldType\DBField;
+use SilverStripe\Security\Permission;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+
+/**
+ * Abstract class for commands which can be run either via an HTTP request or the CLI.
+ */
+abstract class PolyCommand
+{
+    use Configurable;
+    use Injectable;
+
+    /**
+     * Defines whether this command can be run in the CLI via sake.
+     * Overridden if DevelopmentAdmin sets allow_all_cli to true.
+     *
+     * Note that in dev mode the command can always be run.
+     */
+    private static bool $can_run_in_cli = true;
+
+    /**
+     * Defines whether this command can be run in the browser via a web request.
+     * If true, user must have the requisite permissions.
+     *
+     * Note that in dev mode the command can always be run.
+     */
+    private static bool $can_run_in_browser = true;
+
+    /**
+     * Permissions required for users to execute this command via the browser.
+     * If `can_run_in_browser` is false, these permissions are ignored.
+     * Must be defined in the subclass.
+     */
+    private static array $permissions_for_browser_execution = [];
+
+    /**
+     * Name of the command. Also used as the end of the URL segment for browser execution.
+     * Must be defined in the subclass.
+     */
+    protected static string $commandName = '';
+
+    /**
+     * Description of what the command does. Can use symfony console styling.
+     * See https://symfony.com/doc/current/console/coloring.html.
+     * Must be defined in the subclass.
+     */
+    protected static string $description = '';
+
+    /**
+     * Get the title for this command.
+     */
+    abstract public function getTitle(): string;
+
+    /**
+     * Execute this command.
+     *
+     * Output should be agnostic - do not include explicit HTML in the output unless there is no API
+     * on `PolyOutput` for what you want to do (in which case use the writeForFormat() method).
+     *
+     * Use symfony/console ANSI formatting to style the output.
+     * See https://symfony.com/doc/current/console/coloring.html
+     *
+     * @return int 0 if everything went fine, or an exit code
+     */
+    abstract public function run(InputInterface $input, PolyOutput $output): int;
+
+    /**
+     * Get the name of this command.
+     */
+    public static function getName(): string
+    {
+        return static::$commandName;
+    }
+
+    /**
+     * Get the description of this command. Includes unparsed symfony/console styling.
+     */
+    public static function getDescription(): string
+    {
+        return _t(static::class . '.description', static::$description);
+    }
+
+    /**
+     * Return additional help context to avoid an overly long description.
+     */
+    public static function getHelp(): string
+    {
+        return '';
+    }
+
+    /**
+     * Get input options that can be passed into the command.
+     *
+     * In CLI execution these will be passed as flags.
+     * In HTTP execution these will be passed in the query string.
+     *
+     * @return array<InputOption>
+     */
+    public function getOptions(): array
+    {
+        return [];
+    }
+
+    public function getOptionsForTemplate(): array
+    {
+        $formatter = HtmlOutputFormatter::create();
+        $forTemplate = [];
+        foreach ($this->getOptions() as $option) {
+            $default = $option->getDefault();
+            if (is_bool($default)) {
+                // Use 1/0 for boolean, since that's what you'd pass in the query string
+                $default = $default ? '1' : '0';
+            }
+            if (is_array($default)) {
+                $default = implode(',', $default);
+            }
+            $forTemplate[] = [
+                'Name' => $option->getName(),
+                'Description' => DBField::create_field('HTMLText', $formatter->format($option->getDescription())),
+                'Default' => $default,
+            ];
+        }
+        return $forTemplate;
+    }
+
+    /**
+     * Check whether this command can be run in CLI via sake
+     */
+    public static function canRunInCli(): bool
+    {
+        static::checkPrerequisites();
+        return Director::isDev()
+            || static::config()->get('can_run_in_cli')
+            || DevelopmentAdmin::config()->get('allow_all_cli');
+    }
+
+    /**
+     * Check whether this command can be run in the browser via a web request
+     */
+    public static function canRunInBrowser(): bool
+    {
+        static::checkPrerequisites();
+        // Can always run in browser in dev mode
+        if (Director::isDev()) {
+            return true;
+        }
+        if (!static::config()->get('can_run_in_browser')) {
+            return false;
+        }
+        // Check permissions if there are any
+        $permissions = static::config()->get('permissions_for_browser_execution');
+        if ($permissions) {
+            return Permission::check($permissions);
+        }
+        return true;
+    }
+
+    private static function checkPrerequisites(): void
+    {
+        $mandatoryMethods = [
+            'getName' => 'commandName',
+            'getDescription' => 'description',
+        ];
+        foreach ($mandatoryMethods as $getter => $property) {
+            if (!static::$getter()) {
+                throw new RuntimeException($property . ' property needs to be set.');
+            }
+        }
+    }
+}
diff --git a/src/PolyExecution/PolyOutput.php b/src/PolyExecution/PolyOutput.php
new file mode 100644
index 00000000000..36b070412a8
--- /dev/null
+++ b/src/PolyExecution/PolyOutput.php
@@ -0,0 +1,250 @@
+<?php
+
+namespace SilverStripe\PolyExecution;
+
+use InvalidArgumentException;
+use LogicException;
+use SensioLabs\AnsiConverter\AnsiToHtmlConverter;
+use SilverStripe\Core\Injector\Injectable;
+use Symfony\Component\Console\Formatter\OutputFormatter;
+use Symfony\Component\Console\Formatter\OutputFormatterInterface;
+use Symfony\Component\Console\Output\Output;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Output that correctly formats for HTML or for the terminal, depending on the output type.
+ * Used for functionality that can be used both via CLI and via the browser.
+ */
+class PolyOutput extends Output
+{
+    use Injectable;
+
+    public const LIST_UNORDERED = 'ul';
+    public const LIST_ORDERED = 'ol';
+
+    /** Use this if you want HTML markup in the output */
+    public const FORMAT_HTML = 'Html';
+    /** Use this for outputing to a terminal, or for plain text output */
+    public const FORMAT_ANSI = 'Ansi';
+
+    private string $outputFormat;
+
+    private ?OutputInterface $wrappedOutput = null;
+
+    private ?AnsiToHtmlConverter $ansiConverter = null;
+
+    /**
+     * Array of list types that are opened, and the options that were used to open them.
+     */
+    private array $listTypeStack = [];
+
+    /**
+     * @param string $outputFormat The format to use for the output (one of the FORMAT_* constants)
+     * @param int The verbosity level (one of the VERBOSITY_* constants in OutputInterface)
+     * @param boolean $decorated Whether to decorate messages (if false, decoration tags will simply be removed)
+     * @param OutputInterface|null $wrappedOutput An optional output pipe messages through.
+     * Useful for capturing output instead of echoing directly to the client, for example.
+     */
+    public function __construct(
+        string $outputFormat,
+        int $verbosity = OutputInterface::VERBOSITY_NORMAL,
+        bool $decorated = false,
+        ?OutputInterface $wrappedOutput = null
+    ) {
+        $this->setOutputFormat($outputFormat);
+        // Intentionally don't call parent constructor, because it doesn't use the setter methods.
+        if ($wrappedOutput) {
+            $this->setWrappedOutput($wrappedOutput);
+        } else {
+            $this->setFormatter(new OutputFormatter());
+        }
+        $this->setDecorated($decorated);
+        $this->setVerbosity($verbosity);
+    }
+
+    /**
+     * Writes messages to the output - but only if we're using the given output format.
+     * Useful for adding explicit HTML markup (divs, etc) to wrap the main output.
+     * If including HTML markup, use OUTPUT_RAW in the options.
+     *
+     * @param string $listType One of the LIST_* consts, e.g. PolyOutput::LIST_UNORDERED
+     * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
+     * 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL
+     */
+    public function writeForFormat(
+        string $outputFormat,
+        string|iterable $messages,
+        bool $newline = false,
+        int $options = OutputInterface::OUTPUT_NORMAL
+    ): void {
+        if ($this->outputFormat === $outputFormat) {
+            $this->write($messages, $newline, $options);
+        }
+    }
+
+    /**
+     * Start a list.
+     * In HTML format this will write the opening `<ul>` or `<ol>` tag.
+     * In ANSI format this will set up information for rendering list items.
+     *
+     * Call writeListItem() to add items to the list, then call stopList() when you're done.
+     *
+     * @param string $listType One of the LIST_* consts, e.g. PolyOutput::LIST_UNORDERED
+     * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
+     * 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL
+     */
+    public function startList(string $listType = PolyOutput::LIST_UNORDERED, int $options = OutputInterface::OUTPUT_NORMAL): void
+    {
+        $this->listTypeStack[] = ['type' => $listType, 'options' => $options];
+        if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
+            $this->write("<{$listType}>", options: $this->forceRawOutput($options));
+        }
+    }
+
+    /**
+     * Stop a list.
+     * In HTML format this will write the closing `</ul>` or `</ol>` tag.
+     * In ANSI format this will mark the list as closed (useful when nesting lists)
+     */
+    public function stopList(): void
+    {
+        if (empty($this->listTypeStack)) {
+            throw new LogicException('No list to close.');
+        }
+        $info = array_pop($this->listTypeStack);
+        if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
+            $this->write("</{$info['type']}>", options: $this->forceRawOutput($info['options']));
+        }
+    }
+
+    /**
+     * Writes messages formatted as a list.
+     * Make sure to call startList() before writing list items, and call stopList() when you're done.
+     *
+     * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants),
+     * by default this will inherit the options used to start the list.
+     */
+    public function writeListItem(string|iterable $items, ?int $options = null): void
+    {
+        if (empty($this->listTypeStack)) {
+            throw new LogicException('No lists started. Call startList() first.');
+        }
+        if (is_string($items)) {
+            $items = [$items];
+        }
+        $method = "writeListItem{$this->outputFormat}";
+        $this->$method($items, $options);
+    }
+
+    public function setFormatter(OutputFormatterInterface $formatter): void
+    {
+        if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
+            $formatter = HtmlOutputFormatter::create($formatter);
+        }
+        parent::setFormatter($formatter);
+    }
+
+    /**
+     * Set whether this will output in HTML or ANSI format.
+     *
+     * @throws InvalidArgumentException if the format isn't one of the FORMAT_* constants
+     */
+    public function setOutputFormat(string $outputFormat): void
+    {
+        if (!in_array($outputFormat, [PolyOutput::FORMAT_ANSI, PolyOutput::FORMAT_HTML])) {
+            throw new InvalidArgumentException("Unexpected format - got '$outputFormat'.");
+        }
+        $this->outputFormat = $outputFormat;
+    }
+
+    /**
+     * Get the format used for output.
+     */
+    public function getOutputFormat(): string
+    {
+        return $this->outputFormat;
+    }
+
+    /**
+     * Set an output to wrap inside this one. Useful for capturing output in a buffer.
+     */
+    public function setWrappedOutput(OutputInterface $wrappedOutput): void
+    {
+        $this->wrappedOutput = $wrappedOutput;
+        $this->setFormatter($this->wrappedOutput->getFormatter());
+        // Give wrapped output a debug verbosity - that way it'll output everything we tell it to.
+        // Actual verbosity is handled by PolyOutput's parent Output class.
+        $this->wrappedOutput->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
+    }
+
+    protected function doWrite(string $message, bool $newline): void
+    {
+        if ($this->outputFormat === PolyOutput::FORMAT_HTML) {
+            $output = $message . ($newline ? '<br>' . PHP_EOL : '');
+        } else {
+            $output = $message . ($newline ? PHP_EOL : '');
+        }
+        if ($this->wrappedOutput) {
+            $this->wrappedOutput->write($output, options: OutputInterface::OUTPUT_RAW);
+        } else {
+            echo $output;
+        }
+    }
+
+    private function writeListItemHtml(iterable $items, ?int $options): void
+    {
+        if ($options === null) {
+            $listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)];
+            $options = $listInfo['options'];
+        }
+        foreach ($items as $item) {
+            $this->write('<li>', options: $this->forceRawOutput($options));
+            $this->write($item, options: $options);
+            $this->write('</li>', options: $this->forceRawOutput($options));
+        }
+    }
+
+    private function writeListItemAnsi(iterable $items, ?int $options): void
+    {
+        $listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)];
+        $listType = $listInfo['type'];
+        if ($listType === PolyOutput::LIST_ORDERED) {
+            echo '';
+        }
+        if ($options === null) {
+            $options = $listInfo['options'];
+        }
+        foreach ($items as $i => $item) {
+            switch ($listType) {
+                case PolyOutput::LIST_UNORDERED:
+                    $bullet = '*';
+                    break;
+                case PolyOutput::LIST_ORDERED:
+                    // Start at 1
+                    $numberOffset = $listInfo['offset'] ?? 1;
+                    $bullet = ($i + $numberOffset) . '.';
+                    break;
+                default:
+                    throw new InvalidArgumentException("Unexpected list type - got '$listType'.");
+            }
+            $indent = str_repeat(' ', count($this->listTypeStack));
+            $this->writeln("{$indent}{$bullet} {$item}", $options);
+        }
+        // Update the number offset so the next item in the list has the correct number
+        if ($listType === PolyOutput::LIST_ORDERED) {
+            $this->listTypeStack[array_key_last($this->listTypeStack)]['offset'] = $numberOffset + $i + 1;
+        }
+    }
+
+    private function getVerbosityOption(int $options): int
+    {
+        // Logic copied from Output::write() - uses bitwise operations to separate verbosity from output type.
+        $verbosities = OutputInterface::VERBOSITY_QUIET | OutputInterface::VERBOSITY_NORMAL | OutputInterface::VERBOSITY_VERBOSE | OutputInterface::VERBOSITY_VERY_VERBOSE | OutputInterface::VERBOSITY_DEBUG;
+        return $verbosities & $options ?: OutputInterface::VERBOSITY_NORMAL;
+    }
+
+    private function forceRawOutput(int $options): int
+    {
+        return $this->getVerbosityOption($options) | OutputInterface::OUTPUT_RAW;
+    }
+}
diff --git a/src/PolyExecution/PolyOutputLogHandler.php b/src/PolyExecution/PolyOutputLogHandler.php
new file mode 100644
index 00000000000..2caa07dc536
--- /dev/null
+++ b/src/PolyExecution/PolyOutputLogHandler.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace SilverStripe\PolyExecution;
+
+use Monolog\Handler\AbstractProcessingHandler;
+use Monolog\Level;
+use Monolog\LogRecord;
+use SilverStripe\Core\Injector\Injectable;
+
+/**
+ * Log handler that uses a PolyOutput to output log entries to the browser or CLI.
+ */
+class PolyOutputLogHandler extends AbstractProcessingHandler
+{
+    use Injectable;
+
+    private PolyOutput $output;
+
+    public function __construct(PolyOutput $output, int|string|Level $level = Level::Debug, bool $bubble = true)
+    {
+        $this->output = $output;
+        parent::__construct($level, $bubble);
+    }
+
+    protected function write(LogRecord $record): void
+    {
+        $message = rtrim($record->formatted, PHP_EOL);
+        $this->output->write($message, true, PolyOutput::OUTPUT_RAW);
+    }
+}
diff --git a/src/Security/Confirmation/Handler.php b/src/Security/Confirmation/Handler.php
index 2c61912403c..dcc7162ac30 100644
--- a/src/Security/Confirmation/Handler.php
+++ b/src/Security/Confirmation/Handler.php
@@ -7,11 +7,6 @@
 use SilverStripe\Control\Director;
 use SilverStripe\Control\HTTPRequest;
 use SilverStripe\Control\RequestHandler;
-use SilverStripe\Forms\Form as BaseForm;
-use SilverStripe\Forms\FieldList;
-use SilverStripe\Forms\TextField;
-use SilverStripe\Forms\FormAction;
-use SilverStripe\Forms\RequiredFields;
 
 /**
  * Confirmation form handler implementation
diff --git a/src/Security/Member.php b/src/Security/Member.php
index 1b87e2169fb..c6103015042 100644
--- a/src/Security/Member.php
+++ b/src/Security/Member.php
@@ -772,7 +772,7 @@ protected function onBeforeWrite()
             }
         }
 
-        // We don't send emails out on dev/tests sites to prevent accidentally spamming users.
+        // We don't send emails out during tests to prevent accidentally spamming users.
         // However, if TestMailer is in use this isn't a risk.
         if ((Director::isLive() || Injector::inst()->get(MailerInterface::class) instanceof TestMailer)
             && $this->isChanged('Password')
diff --git a/src/Security/RandomGenerator.php b/src/Security/RandomGenerator.php
index b0fc390cf6a..71e58b73463 100644
--- a/src/Security/RandomGenerator.php
+++ b/src/Security/RandomGenerator.php
@@ -3,12 +3,15 @@
 namespace SilverStripe\Security;
 
 use Exception;
+use SilverStripe\Core\Injector\Injectable;
 
 /**
  * Convenience class for generating cryptographically secure pseudo-random strings/tokens
  */
 class RandomGenerator
 {
+    use Injectable;
+
     /**
      * Generates a random token that can be used for session IDs, CSRF tokens etc., based on
      * hash algorithms.
diff --git a/src/Security/Security.php b/src/Security/Security.php
index 80d6fe8d4bb..b2e96d449d9 100644
--- a/src/Security/Security.php
+++ b/src/Security/Security.php
@@ -1067,7 +1067,7 @@ public static function encrypt_password($password, $salt = null, $algorithm = nu
 
     /**
      * Checks the database is in a state to perform security checks.
-     * See {@link DatabaseAdmin->init()} for more information.
+     * See DbBuild permission checks for more information.
      *
      * @return bool
      */
diff --git a/src/View/SSViewer_DataPresenter.php b/src/View/SSViewer_DataPresenter.php
index 7c5b6e5ecd0..c2032d3940c 100644
--- a/src/View/SSViewer_DataPresenter.php
+++ b/src/View/SSViewer_DataPresenter.php
@@ -4,6 +4,7 @@
 
 use InvalidArgumentException;
 use SilverStripe\Core\ClassInfo;
+use SilverStripe\ORM\ArrayList;
 use SilverStripe\ORM\FieldType\DBField;
 
 /**
@@ -432,6 +433,11 @@ protected function castValue($value, $source)
             return $value;
         }
 
+        // Wrap list arrays in ViewableData so templates can handle them
+        if (is_array($value) && array_is_list($value)) {
+            return ArrayList::create($value);
+        }
+
         // Get provided or default cast
         $casting = empty($source['casting'])
             ? ViewableData::config()->uninherited('default_cast')
diff --git a/src/i18n/TextCollection/i18nTextCollector.php b/src/i18n/TextCollection/i18nTextCollector.php
index 3d3d6fa6f8f..53b095888cb 100644
--- a/src/i18n/TextCollection/i18nTextCollector.php
+++ b/src/i18n/TextCollection/i18nTextCollector.php
@@ -6,7 +6,6 @@
 use LogicException;
 use SilverStripe\Core\ClassInfo;
 use SilverStripe\Core\Config\Config;
-use SilverStripe\Core\Config\Configurable;
 use SilverStripe\Core\Extension;
 use SilverStripe\Core\Injector\Injectable;
 use SilverStripe\Core\Manifest\ClassLoader;
@@ -39,8 +38,8 @@
  *
  * Usage through URL: http://localhost/dev/tasks/i18nTextCollectorTask
  * Usage through URL (module-specific): http://localhost/dev/tasks/i18nTextCollectorTask/?module=mymodule
- * Usage on CLI: sake dev/tasks/i18nTextCollectorTask
- * Usage on CLI (module-specific): sake dev/tasks/i18nTextCollectorTask module=mymodule
+ * Usage on CLI: sake tasks:i18nTextCollectorTask
+ * Usage on CLI (module-specific): sake tasks:i18nTextCollectorTask --module=mymodule
  *
  * @author Bernat Foj Capell <bernat@silverstripe.com>
  * @author Ingo Schommer <FIRSTNAME@silverstripe.com>
diff --git a/templates/SilverStripe/Dev/DevelopmentAdmin.ss b/templates/SilverStripe/Dev/DevelopmentAdmin.ss
new file mode 100644
index 00000000000..f833bf0fcbf
--- /dev/null
+++ b/templates/SilverStripe/Dev/DevelopmentAdmin.ss
@@ -0,0 +1,37 @@
+$Header.RAW
+$Info.RAW
+
+<% if $Title %>
+    <div class="info">
+        <h1>$Title</h1>
+    </div>
+<% end_if %>
+
+<div class="options">
+    <% if $Form %>
+        <%-- confirmation handler --%>
+        $Form
+    <% else %>
+        <ul>
+            <% loop $ArrayLinks %>
+                <li class="$EvenOdd">
+                    <a href="$Link"><b>/$Path:</b> $Description</a>
+                    <% if $Help %>
+                        <details class="more-details">
+                            <summary>Display additional information</summary>
+                            $Help
+                        </details>
+                    <% end_if %>
+                    <% if $Parameters %>
+                        <div>Parameters:
+                            <% include SilverStripe/Dev/Parameters %>
+                        </div>
+                    <% end_if %>
+                </li>
+            <% end_loop %>
+        </ul>
+    <% end_if %>
+</div>
+
+$Footer.RAW
+
diff --git a/templates/SilverStripe/Dev/Parameters.ss b/templates/SilverStripe/Dev/Parameters.ss
new file mode 100644
index 00000000000..6fafb1dffeb
--- /dev/null
+++ b/templates/SilverStripe/Dev/Parameters.ss
@@ -0,0 +1,8 @@
+<dl class="params">
+    <% loop $Parameters %>
+        <div class="param">
+            <dt class="param__name">$Name</dt>
+            <dd class="param__description">$Description<% if $Default %> [default: $Default]<% end_if %></dd>
+        </div>
+    <% end_loop %>
+</dl>
diff --git a/templates/SilverStripe/Dev/TaskRunner.ss b/templates/SilverStripe/Dev/TaskRunner.ss
index 10020f74d2c..e14dda4a2da 100644
--- a/templates/SilverStripe/Dev/TaskRunner.ss
+++ b/templates/SilverStripe/Dev/TaskRunner.ss
@@ -9,7 +9,19 @@ $Info.RAW
                     <div class="task__item">
                         <div>
                             <h3 class="task__title">$Title</h3>
-                            <div class="task__description">$Description</div>
+                            <div class="task__description">
+                                $Description
+                                <% if $Help %>
+                                    <details class="task__help">
+                                        <summary>Display additional information</summary>
+                                        $Help
+                                    </details>
+                                <% end_if %>
+                            </div>
+                            <% if $Parameters %>
+                                Parameters:
+                                <% include SilverStripe/Dev/Parameters %>
+                            <% end_if %>
                         </div>
                         <div>
                             <a href="{$TaskLink.ATT}" class="task__button">Run task</a>
diff --git a/tests/bootstrap/cli.php b/tests/bootstrap/cli.php
index c7bb21504a5..d5317a123d9 100644
--- a/tests/bootstrap/cli.php
+++ b/tests/bootstrap/cli.php
@@ -25,9 +25,6 @@
 $frameworkPath = dirname(dirname(__FILE__));
 $frameworkDir = basename($frameworkPath ?? '');
 
-$_SERVER['SCRIPT_FILENAME'] = $frameworkPath . DIRECTORY_SEPARATOR . 'cli-script.php';
-$_SERVER['SCRIPT_NAME'] = '.' . DIRECTORY_SEPARATOR . $frameworkDir . DIRECTORY_SEPARATOR . 'cli-script.php';
-
 // Copied from cli-script.php, to enable same behaviour through phpunit runner.
 if (isset($_SERVER['argv'][2])) {
     $args = array_slice($_SERVER['argv'] ?? [], 2);
diff --git a/tests/php/Cli/Command/NavigateCommandTest.php b/tests/php/Cli/Command/NavigateCommandTest.php
new file mode 100644
index 00000000000..3dd9851f2a9
--- /dev/null
+++ b/tests/php/Cli/Command/NavigateCommandTest.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\Command;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use SilverStripe\Cli\Command\NavigateCommand;
+use SilverStripe\Cli\Tests\Command\NavigateCommandTest\TestController;
+use SilverStripe\Control\Director;
+use SilverStripe\Dev\SapphireTest;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+class NavigateCommandTest extends SapphireTest
+{
+    protected $usesDatabase = false;
+
+    public static function provideExecute(): array
+    {
+        return [
+            [
+                'path' => 'missing-route',
+                'getVars' => [],
+                'expectedExitCode' => 2,
+                'expectedOutput' => '',
+            ],
+            [
+                'path' => 'test-controller',
+                'getVars' => [],
+                'expectedExitCode' => 0,
+                'expectedOutput' => 'This is the index for TestController.' . PHP_EOL,
+            ],
+            [
+                'path' => 'test-controller/actionOne',
+                'getVars' => [],
+                'expectedExitCode' => 0,
+                'expectedOutput' => 'This is action one!' . PHP_EOL,
+            ],
+            [
+                'path' => 'test-controller/errorResponse',
+                'getVars' => [],
+                'expectedExitCode' => 1,
+                'expectedOutput' => '',
+            ],
+            [
+                'path' => 'test-controller/missing-action',
+                'getVars' => [],
+                'expectedExitCode' => 2,
+                'expectedOutput' => '',
+            ],
+            [
+                'path' => 'test-controller',
+                'getVars' => [
+                    'var1=1',
+                    'var2=abcd',
+                    'var3=',
+                    'var4[]=a',
+                    'var4[]=b',
+                    'var4[]=c',
+                ],
+                'expectedExitCode' => 0,
+                'expectedOutput' => 'This is the index for TestController. var1=1 var2=abcd var4=a,b,c' . PHP_EOL,
+            ],
+            [
+                'path' => 'test-controller',
+                'getVars' => [
+                    'var1=1&var2=abcd&var3=&var4[]=a&var4[]=b&var4[]=c',
+                ],
+                'expectedExitCode' => 0,
+                'expectedOutput' => 'This is the index for TestController. var1=1 var2=abcd var4=a,b,c' . PHP_EOL,
+            ],
+        ];
+    }
+
+    #[DataProvider('provideExecute')]
+    public function testExecute(string $path, array $getVars, int $expectedExitCode, string $expectedOutput): void
+    {
+        // Intentionally override existing rules
+        Director::config()->set('rules', ['test-controller' => TestController::class]);
+        $navigateCommand = new NavigateCommand();
+        $inputParams = [
+            'path' => $path,
+            'get-var' => $getVars,
+        ];
+        $input = new ArrayInput($inputParams, $navigateCommand->getDefinition());
+        $input->setInteractive(false);
+        $buffer = new BufferedOutput();
+        $output = new PolyOutput(PolyOutput::FORMAT_ANSI, decorated: false, wrappedOutput: $buffer);
+
+        $exitCode = $navigateCommand->run($input, $output);
+
+        // Don't asset specific output for failed or invalid responses
+        // The response body for those is handled outside of the navigate command's control
+        if ($expectedExitCode === 0) {
+            $this->assertSame($expectedOutput, $buffer->fetch());
+        }
+        $this->assertSame($expectedExitCode, $exitCode);
+    }
+}
diff --git a/tests/php/Cli/Command/NavigateCommandTest/TestController.php b/tests/php/Cli/Command/NavigateCommandTest/TestController.php
new file mode 100644
index 00000000000..d7bc07c42c3
--- /dev/null
+++ b/tests/php/Cli/Command/NavigateCommandTest/TestController.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\Command\NavigateCommandTest;
+
+use SilverStripe\Control\Controller;
+use SilverStripe\Control\HTTPRequest;
+use SilverStripe\Control\HTTPResponse;
+
+class TestController extends Controller
+{
+    private static $allowed_actions = [
+        'actionOne',
+        'errorResponse',
+    ];
+
+    public function index(HTTPRequest $request): HTTPResponse
+    {
+        $var1 = $request->getVar('var1');
+        $var2 = $request->getVar('var2');
+        $var3 = $request->getVar('var3');
+        $var4 = $request->getVar('var4');
+
+        $output = 'This is the index for TestController.';
+
+        if ($var1) {
+            $output .= ' var1=' . $var1;
+        }
+        if ($var2) {
+            $output .= ' var2=' . $var2;
+        }
+        if ($var3) {
+            $output .= ' var3=' . $var3;
+        }
+        if ($var4) {
+            $output .= ' var4=' . implode(',', $var4);
+        }
+
+        $this->response->setBody($output);
+        return $this->response;
+    }
+
+    public function actionOne(HTTPRequest $request): HTTPResponse
+    {
+        $this->response->setBody('This is action one!');
+        return $this->response;
+    }
+
+    public function errorResponse(HTTPRequest $request): HTTPResponse
+    {
+        $this->httpError(500);
+    }
+}
diff --git a/tests/php/Cli/Command/PolyCommandCliWrapperTest.php b/tests/php/Cli/Command/PolyCommandCliWrapperTest.php
new file mode 100644
index 00000000000..b8b1855c5f0
--- /dev/null
+++ b/tests/php/Cli/Command/PolyCommandCliWrapperTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\Command;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use SilverStripe\Cli\Command\PolyCommandCliWrapper;
+use SilverStripe\Cli\Tests\Command\PolyCommandCliWrapperTest\TestPolyCommand;
+use SilverStripe\Dev\SapphireTest;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+class PolyCommandCliWrapperTest extends SapphireTest
+{
+    protected $usesDatabase = false;
+
+    public static function provideExecute(): array
+    {
+        return [
+            'no-params' => [
+                'exitCode' => 0,
+                'params' => [],
+                'expectedOutput' => 'Has option 1: false' . PHP_EOL
+                    . 'option 2 value: ' . PHP_EOL,
+            ],
+            'with-params' => [
+                'exitCode' => 1,
+                'params' => [
+                    '--option1' => true,
+                    '--option2' => 'abc',
+                ],
+                'expectedOutput' => 'Has option 1: true' . PHP_EOL
+                    . 'option 2 value: abc' . PHP_EOL,
+            ],
+        ];
+    }
+
+    #[DataProvider('provideExecute')]
+    public function testExecute(int $exitCode, array $params, string $expectedOutput): void
+    {
+        $polyCommand = new TestPolyCommand();
+        $polyCommand->setExitCode($exitCode);
+        $wrapper = new PolyCommandCliWrapper($polyCommand);
+        $input = new ArrayInput($params, $wrapper->getDefinition());
+        $input->setInteractive(false);
+        $buffer = new BufferedOutput();
+        $output = new PolyOutput(PolyOutput::FORMAT_ANSI, decorated: false, wrappedOutput: $buffer);
+
+        $this->assertSame($exitCode, $wrapper->run($input, $output));
+        $this->assertSame($expectedOutput, $buffer->fetch());
+    }
+}
diff --git a/tests/php/Cli/Command/PolyCommandCliWrapperTest/TestPolyCommand.php b/tests/php/Cli/Command/PolyCommandCliWrapperTest/TestPolyCommand.php
new file mode 100644
index 00000000000..42afb678449
--- /dev/null
+++ b/tests/php/Cli/Command/PolyCommandCliWrapperTest/TestPolyCommand.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\Command\PolyCommandCliWrapperTest;
+
+use SilverStripe\Dev\TestOnly;
+use SilverStripe\PolyExecution\PolyCommand;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+
+class TestPolyCommand extends PolyCommand implements TestOnly
+{
+    protected static string $commandName = 'test:poly';
+
+    protected static string $description = 'simple command for testing CLI wrapper';
+
+    private int $exitCode = 0;
+
+    public function getTitle(): string
+    {
+        return 'This is the title!';
+    }
+
+    public function run(InputInterface $input, PolyOutput $output): int
+    {
+        $output->writeln('Has option 1: ' . ($input->getOption('option1') ? 'true' : 'false'));
+        $output->writeln('option 2 value: ' . $input->getOption('option2'));
+        return $this->exitCode;
+    }
+
+    public function setExitCode(int $code): void
+    {
+        $this->exitCode = $code;
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new InputOption('option1', null, InputOption::VALUE_NONE),
+            new InputOption('option2', null, InputOption::VALUE_REQUIRED),
+        ];
+    }
+}
diff --git a/tests/php/Cli/LegacyParamArgvInputTest.php b/tests/php/Cli/LegacyParamArgvInputTest.php
new file mode 100644
index 00000000000..d7b7229eb77
--- /dev/null
+++ b/tests/php/Cli/LegacyParamArgvInputTest.php
@@ -0,0 +1,159 @@
+<?php
+
+namespace SilverStripe\Cli\Tests;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use SilverStripe\Cli\LegacyParamArgvInput;
+use SilverStripe\Dev\SapphireTest;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputDefinition;
+use Symfony\Component\Console\Input\InputOption;
+
+class LegacyParamArgvInputTest extends SapphireTest
+{
+    protected $usesDatabase = false;
+
+    public static function provideHasParameterOption(): array
+    {
+        return [
+            'sake flush=1' => [
+                'argv' => [
+                    'sake',
+                    'flush=1'
+                ],
+                'checkFor' => '--flush',
+                'expected' => true,
+            ],
+            'sake flush=0' => [
+                'argv' => [
+                    'sake',
+                    'flush=0'
+                ],
+                'checkFor' => '--flush',
+                'expected' => true,
+            ],
+            'sake flush=1 --' => [
+                'argv' => [
+                    'sake',
+                    'flush=1',
+                    '--'
+                ],
+                'checkFor' => '--flush',
+                'expected' => true,
+            ],
+            'sake -- flush=1' => [
+                'argv' => [
+                    'sake',
+                    '--',
+                    'flush=1'
+                ],
+                'checkFor' => '--flush',
+                'expected' => false,
+            ],
+        ];
+    }
+
+    #[DataProvider('provideHasParameterOption')]
+    public function testHasParameterOption(array $argv, string $checkFor, bool $expected): void
+    {
+        $input = new LegacyParamArgvInput($argv);
+        $this->assertSame($expected, $input->hasParameterOption($checkFor));
+    }
+
+    public static function provideGetParameterOption(): array
+    {
+        $scenarios = static::provideHasParameterOption();
+        $scenarios['sake flush=1']['expected'] = '1';
+        $scenarios['sake flush=0']['expected'] = '0';
+        $scenarios['sake flush=1 --']['expected'] = '1';
+        $scenarios['sake -- flush=1']['expected'] = false;
+        return $scenarios;
+    }
+
+    #[DataProvider('provideGetParameterOption')]
+    public function testGetParameterOption(array $argv, string $checkFor, false|string $expected): void
+    {
+        $input = new LegacyParamArgvInput($argv);
+        $this->assertSame($expected, $input->getParameterOption($checkFor));
+    }
+
+    public static function provideBind(): array
+    {
+        return [
+            'sake flush=1 arg=value' => [
+                'argv' => [
+                    'sake',
+                    'flush=1',
+                    'arg=value',
+                ],
+                'options' => [
+                    new InputOption('--flush', null, InputOption::VALUE_NONE),
+                    new InputOption('--arg', null, InputOption::VALUE_REQUIRED),
+                ],
+                'expected' => [
+                    'flush' => true,
+                    'arg' => 'value',
+                ],
+            ],
+            'sake flush=yes arg=abc' => [
+                'argv' => [
+                    'sake',
+                    'flush=yes',
+                    'arg=abc',
+                ],
+                'options' => [
+                    new InputOption('flush', null, InputOption::VALUE_NONE),
+                    new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
+                ],
+                'expected' => [
+                    'flush' => true,
+                    'arg' => 'abc',
+                ],
+            ],
+            'sake flush=0 arg=' => [
+                'argv' => [
+                    'sake',
+                    'flush=0',
+                    'arg=',
+                ],
+                'options' => [
+                    new InputOption('flush', null, InputOption::VALUE_NONE),
+                    new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
+                ],
+                'expected' => [
+                    'flush' => false,
+                    'arg' => null,
+                ],
+            ],
+            'sake flush=1 -- arg=abc' => [
+                'argv' => [
+                    'sake',
+                    'flush=1',
+                    '--',
+                    'arg=abc',
+                ],
+                'options' => [
+                    new InputOption('flush', null, InputOption::VALUE_NONE),
+                    new InputOption('arg', null, InputOption::VALUE_OPTIONAL),
+                    // Since arg=abc is now included as an argument, we need to allow an argument.
+                    new InputArgument('needed-to-avoid-error', InputArgument::REQUIRED),
+                ],
+                'expected' => [
+                    'flush' => true,
+                    'arg' => null,
+                ],
+            ],
+        ];
+    }
+
+    #[DataProvider('provideBind')]
+    public function testBind(array $argv, array $options, array $expected): void
+    {
+        $input = new LegacyParamArgvInput($argv);
+        $definition = new InputDefinition($options);
+        $input->bind($definition);
+        foreach ($expected as $option => $value) {
+            $this->assertSame($value, $input->getOption($option));
+        }
+    }
+}
diff --git a/tests/php/Cli/SakeTest.php b/tests/php/Cli/SakeTest.php
new file mode 100644
index 00000000000..8434484c2ba
--- /dev/null
+++ b/tests/php/Cli/SakeTest.php
@@ -0,0 +1,307 @@
+<?php
+
+namespace SilverStripe\Cli\Tests;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Error\Deprecated;
+use ReflectionClass;
+use SilverStripe\Cli\Sake;
+use SilverStripe\Cli\Tests\SakeTest\TestBuildTask;
+use SilverStripe\Cli\Tests\SakeTest\TestCommandLoader;
+use SilverStripe\Cli\Tests\SakeTest\TestConfigCommand;
+use SilverStripe\Cli\Tests\SakeTest\TestConfigPolyCommand;
+use SilverStripe\Core\ClassInfo;
+use SilverStripe\Core\Injector\Injector;
+use SilverStripe\Core\Kernel;
+use SilverStripe\Core\Manifest\VersionProvider;
+use SilverStripe\Dev\BuildTask;
+use SilverStripe\Dev\Deprecation;
+use SilverStripe\Dev\DevelopmentAdmin;
+use SilverStripe\Dev\SapphireTest;
+use SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestException;
+use Symfony\Component\Console\Command\DumpCompletionCommand;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+class SakeTest extends SapphireTest
+{
+    protected $usesDatabase = false;
+
+    private $oldErrorHandler = null;
+
+    public static function provideList(): array
+    {
+        return [
+            'display all' => [
+                'addExtra' => true,
+                'hideCompletion' => true,
+            ],
+            'display none' => [
+                'addExtra' => false,
+                'hideCompletion' => false,
+            ],
+        ];
+    }
+
+    /**
+     * Test adding commands and command loaders to Sake via configuration API
+     */
+    #[DataProvider('provideList')]
+    public function testList(bool $addExtra, bool $hideCompletion): void
+    {
+        $sake = new Sake(Injector::inst()->get(Kernel::class));
+        $sake->setAutoExit(false);
+        $input = new ArrayInput(['list']);
+        $input->setInteractive(false);
+        $output = new BufferedOutput();
+
+        if ($addExtra) {
+            Sake::config()->merge('commands', [
+                TestConfigPolyCommand::class,
+                TestConfigCommand::class,
+            ]);
+            Sake::config()->merge('command_loaders', [
+                TestCommandLoader::class,
+            ]);
+        }
+        Sake::config()->set('hide_completion_command', $hideCompletion);
+        // Make sure all tasks are displayed - we'll test hiding them in testHideTasks
+        Sake::config()->set('max_tasks_to_display', 0);
+
+        $sake->run($input, $output);
+
+        $commandNames = [
+            'loader:test-command',
+            'test:from-config:standard',
+            'test:from-config:poly',
+        ];
+        $commandDescriptions = [
+            'command for testing adding custom command loaders',
+            'command for testing adding standard commands via config',
+            'command for testing adding poly commands via config',
+        ];
+
+        $listOutput = $output->fetch();
+
+        // Check if the extra commands are there or not
+        if ($addExtra) {
+            foreach ($commandNames as $name) {
+                $this->assertStringContainsString($name, $listOutput);
+            }
+            foreach ($commandDescriptions as $description) {
+                $this->assertStringContainsString($description, $listOutput);
+            }
+        } else {
+            foreach ($commandNames as $name) {
+                $this->assertStringNotContainsString($name, $listOutput);
+            }
+            foreach ($commandDescriptions as $description) {
+                $this->assertStringNotContainsString($description, $listOutput);
+            }
+        }
+
+        // Build task could display automagically as a matter of class inheritance.
+        $task = new TestBuildTask();
+        $this->assertStringContainsString($task->getName(), $listOutput);
+        $this->assertStringContainsString(TestBuildTask::getDescription(), $listOutput);
+
+        // Check if the completion command is there or not
+        $command = new DumpCompletionCommand();
+        $completionRegex = "/{$command->getName()}\s+{$command->getDescription()}/";
+        if ($hideCompletion) {
+            $this->assertDoesNotMatchRegularExpression($completionRegex, $listOutput);
+        } else {
+            $this->assertMatchesRegularExpression($completionRegex, $listOutput);
+        }
+
+        // Make sure the "help" and "list" commands aren't shown
+        $this->assertStringNotContainsString($listOutput, 'List commands', 'the list command should not display');
+        $this->assertStringNotContainsString($listOutput, 'Display help for a command', 'the help command should not display');
+    }
+
+    public function testPolyCommandCanRunInCli(): void
+    {
+        $kernel = Injector::inst()->get(Kernel::class);
+        $sake = new Sake($kernel);
+        $sake->setAutoExit(false);
+        $input = new ArrayInput(['list']);
+        $input->setInteractive(false);
+        $output = new BufferedOutput();
+
+        // Add test commands
+        Sake::config()->merge('commands', [
+            TestConfigPolyCommand::class,
+        ]);
+
+        // Disallow these to run in CLI.
+        // Note the scenario where all are allowed is in testList().
+        TestConfigPolyCommand::config()->set('can_run_in_cli', false);
+        TestBuildTask::config()->set('can_run_in_cli', false);
+        DevelopmentAdmin::config()->set('allow_all_cli', false);
+
+        // Must not be in dev mode to test permissions, because all PolyCommand can be run in dev mode.
+        $origEnvironment = $kernel->getEnvironment();
+        $kernel->setEnvironment('live');
+        try {
+            $sake->run($input, $output);
+        } finally {
+            $kernel->setEnvironment($origEnvironment);
+        }
+        $listOutput = $output->fetch();
+
+        $allCommands = [
+            TestConfigPolyCommand::class,
+            TestBuildTask::class,
+        ];
+        foreach ($allCommands as $commandClass) {
+            $command = new $commandClass();
+            $this->assertStringNotContainsString($command->getName(), $listOutput);
+            $this->assertStringNotContainsString($commandClass::getDescription(), $listOutput);
+        }
+    }
+
+    public static function provideHideTasks(): array
+    {
+        return [
+            'task count matches limit' => [
+                'taskLimit' => 'same',
+                'shouldShow' => true,
+            ],
+            'task count lower than limit' => [
+                'taskLimit' => 'more',
+                'shouldShow' => true,
+            ],
+            'task count greater than limit' => [
+                'taskLimit' => 'less',
+                'shouldShow' => false,
+            ],
+            'unlimited tasks allowed' => [
+                'taskLimit' => 'all',
+                'shouldShow' => true,
+            ],
+        ];
+    }
+
+    #[DataProvider('provideHideTasks')]
+    public function testHideTasks(string $taskLimit, bool $shouldShow): void
+    {
+        $sake = new Sake(Injector::inst()->get(Kernel::class));
+        $sake->setAutoExit(false);
+        $input = new ArrayInput(['list']);
+        $input->setInteractive(false);
+        $output = new BufferedOutput();
+
+        // Determine max tasks config value
+        $taskInfo = [];
+        foreach (ClassInfo::subclassesFor(BuildTask::class, false) as $class) {
+            $reflectionClass = new ReflectionClass($class);
+            if ($reflectionClass->isAbstract()) {
+                continue;
+            }
+            $singleton = $class::singleton();
+            if ($class::canRunInCli() && $singleton->isEnabled()) {
+                $taskInfo[$singleton->getName()] = $class::getDescription();
+            }
+        }
+        $maxTasks = match ($taskLimit) {
+            'same' => count($taskInfo),
+            'more' => count($taskInfo) + 1,
+            'less' => count($taskInfo) - 1,
+            'all' => 0,
+        };
+
+        Sake::config()->set('max_tasks_to_display', $maxTasks);
+        $sake->run($input, $output);
+        $listOutput = $output->fetch();
+
+        // Check the tasks are showing/hidden as appropriate
+        if ($shouldShow) {
+            foreach ($taskInfo as $name => $description) {
+                $this->assertStringContainsString($name, $listOutput);
+                $this->assertStringContainsString($description, $listOutput);
+            }
+            // Shouldn't display the task command
+            $this->assertStringNotContainsString('See a list of build tasks to run', $listOutput);
+        } else {
+            foreach ($taskInfo as $name => $description) {
+                $this->assertStringNotContainsString($name, $listOutput);
+                $this->assertStringNotContainsString($description, $listOutput);
+            }
+            // Should display the task command
+            $this->assertStringContainsString('See a list of build tasks to run', $listOutput);
+        }
+
+        // Check `sake tasks` ALWAYS shows the tasks
+        $input = new ArrayInput(['tasks']);
+        $sake->run($input, $output);
+        $listOutput = $output->fetch();
+        foreach ($taskInfo as $name => $description) {
+            $this->assertStringContainsString($name, $listOutput);
+            $this->assertStringContainsString($description, $listOutput);
+        }
+    }
+
+    public function testVersion(): void
+    {
+        $sake = new Sake(Injector::inst()->get(Kernel::class));
+        $sake->setAutoExit(false);
+        $versionProvider = new VersionProvider();
+        $this->assertSame($versionProvider->getVersion(), $sake->getVersion());
+    }
+
+    public function testLegacyDevCommands(): void
+    {
+        $sake = new Sake(Injector::inst()->get(Kernel::class));
+        $sake->setAutoExit(false);
+        $input = new ArrayInput(['dev/config']);
+        $input->setInteractive(false);
+        $output = new BufferedOutput();
+
+        $deprecationsWereEnabled = Deprecation::isEnabled();
+        Deprecation::enable();
+        $this->expectException(DeprecationTestException::class);
+        $expectedErrorString = 'Using the command with the name \'dev/config\' is deprecated. Use \'config:dump\' instead';
+        $this->expectExceptionMessage($expectedErrorString);
+
+        $exitCode = $sake->run($input, $output);
+        $this->assertSame(0, $exitCode, 'command should run successfully');
+        // $this->assertStringContainsString('abababa', $output->fetch());
+
+        $this->allowCatchingDeprecations($expectedErrorString);
+        try {
+            // call outputNotices() directly because the regular shutdown function that emits
+            // the notices within Deprecation won't be called until after this unit-test has finished
+            Deprecation::outputNotices();
+        } finally {
+            restore_error_handler();
+            $this->oldErrorHandler = null;
+            // Disable if they weren't enabled before.
+            if (!$deprecationsWereEnabled) {
+                Deprecation::disable();
+            }
+        }
+    }
+
+    private function allowCatchingDeprecations(string $expectedErrorString): void
+    {
+        // Use custom error handler for two reasons:
+        // - Filter out errors for deprecations unrelated to this test class
+        // - Allow the use of expectDeprecation(), which doesn't work with E_USER_DEPRECATION by default
+        //   https://github.com/laminas/laminas-di/pull/30#issuecomment-927585210
+        $this->oldErrorHandler = set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($expectedErrorString) {
+            if ($errno === E_USER_DEPRECATED) {
+                if (str_contains($errstr, $expectedErrorString)) {
+                    throw new DeprecationTestException($errstr);
+                } else {
+                    // Suppress any E_USER_DEPRECATED unrelated to this test class
+                    return true;
+                }
+            }
+            if (is_callable($this->oldErrorHandler)) {
+                return call_user_func($this->oldErrorHandler, $errno, $errstr, $errfile, $errline);
+            }
+            // Fallback to default PHP error handler
+            return false;
+        });
+    }
+}
diff --git a/tests/php/Cli/SakeTest/TestBuildTask.php b/tests/php/Cli/SakeTest/TestBuildTask.php
new file mode 100644
index 00000000000..105f03635dc
--- /dev/null
+++ b/tests/php/Cli/SakeTest/TestBuildTask.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\SakeTest;
+
+use SilverStripe\Dev\BuildTask;
+use SilverStripe\Dev\TestOnly;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
+
+class TestBuildTask extends BuildTask implements TestOnly
+{
+    protected static string $commandName = 'test-build-task';
+
+    protected string $title = 'my title';
+
+    protected static string $description = 'command for testing build tasks display as expected';
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        $output->writeln('This output is coming from a build task');
+        return 0;
+    }
+}
diff --git a/tests/php/Cli/SakeTest/TestCommandLoader.php b/tests/php/Cli/SakeTest/TestCommandLoader.php
new file mode 100644
index 00000000000..0cbbdf1c124
--- /dev/null
+++ b/tests/php/Cli/SakeTest/TestCommandLoader.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\SakeTest;
+
+use SilverStripe\Dev\TestOnly;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
+use Symfony\Component\Console\Exception\CommandNotFoundException;
+
+class TestCommandLoader implements CommandLoaderInterface, TestOnly
+{
+    private string $commandName = 'loader:test-command';
+
+    public function get(string $name): Command
+    {
+        if ($name !== $this->commandName) {
+            throw new CommandNotFoundException("Wrong command fetched. Expected '$this->commandName' - got '$name'");
+        }
+        return new TestLoaderCommand();
+    }
+
+    public function has(string $name): bool
+    {
+        return $name === $this->commandName;
+    }
+
+    public function getNames(): array
+    {
+        return [$this->commandName];
+    }
+}
diff --git a/tests/php/Cli/SakeTest/TestConfigCommand.php b/tests/php/Cli/SakeTest/TestConfigCommand.php
new file mode 100644
index 00000000000..040d6f058f0
--- /dev/null
+++ b/tests/php/Cli/SakeTest/TestConfigCommand.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\SakeTest;
+
+use SilverStripe\Dev\TestOnly;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+#[AsCommand('test:from-config:standard', 'command for testing adding standard commands via config')]
+class TestConfigCommand extends Command implements TestOnly
+{
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        return 'This is a standard command';
+        return 0;
+    }
+}
diff --git a/tests/php/Cli/SakeTest/TestConfigPolyCommand.php b/tests/php/Cli/SakeTest/TestConfigPolyCommand.php
new file mode 100644
index 00000000000..dd981288555
--- /dev/null
+++ b/tests/php/Cli/SakeTest/TestConfigPolyCommand.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\SakeTest;
+
+use SilverStripe\Dev\TestOnly;
+use SilverStripe\PolyExecution\PolyCommand;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
+
+class TestConfigPolyCommand extends PolyCommand implements TestOnly
+{
+    protected static string $commandName = 'test:from-config:poly';
+
+    protected static string $description = 'command for testing adding poly commands via config';
+
+    public function getTitle(): string
+    {
+        return 'This is a poly command';
+    }
+
+    public function run(InputInterface $input, PolyOutput $output): int
+    {
+        $output->writeln('This output is coming from a poly command');
+        return 0;
+    }
+}
diff --git a/tests/php/Cli/SakeTest/TestLoaderCommand.php b/tests/php/Cli/SakeTest/TestLoaderCommand.php
new file mode 100644
index 00000000000..ec8891a9798
--- /dev/null
+++ b/tests/php/Cli/SakeTest/TestLoaderCommand.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace SilverStripe\Cli\Tests\SakeTest;
+
+use SilverStripe\Dev\TestOnly;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+#[AsCommand('loader:test-command', 'command for testing adding custom command loaders')]
+class TestLoaderCommand extends Command implements TestOnly
+{
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        return 'This is a standard command';
+        return 0;
+    }
+}
diff --git a/tests/php/Control/DirectorTest.php b/tests/php/Control/DirectorTest.php
index 5ee9c7b5ab1..35a4cb196d8 100644
--- a/tests/php/Control/DirectorTest.php
+++ b/tests/php/Control/DirectorTest.php
@@ -12,6 +12,7 @@
 use SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter;
 use SilverStripe\Control\Middleware\TrustedProxyMiddleware;
 use SilverStripe\Control\Tests\DirectorTest\TestController;
+use SilverStripe\Control\Tests\DirectorTest\TestPolyCommand;
 use SilverStripe\Core\Config\Config;
 use SilverStripe\Core\Injector\Injector;
 use SilverStripe\Core\Environment;
@@ -997,4 +998,18 @@ public function testMockRequest()
             $this->assertEquals('/some-subdir/some-page/nested', $_SERVER['REQUEST_URI']);
         }, 'some-page/nested?query=1');
     }
+
+    public function testPolyCommandRoute(): void
+    {
+        Director::config()->set('rules', [
+            'test-route' => TestPolyCommand::class,
+        ]);
+        $response = Director::test('test-route');
+        $this->assertSame('Successful poly command request!', $response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+
+        // Arguments aren't available for PolyCommand yet so URLs with additional params should result in 404
+        $response = Director::test('test-route/more/params');
+        $this->assertSame(404, $response->getStatusCode());
+    }
 }
diff --git a/tests/php/Control/DirectorTest/TestPolyCommand.php b/tests/php/Control/DirectorTest/TestPolyCommand.php
new file mode 100644
index 00000000000..41508ac8f97
--- /dev/null
+++ b/tests/php/Control/DirectorTest/TestPolyCommand.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace SilverStripe\Control\Tests\DirectorTest;
+
+use SilverStripe\Dev\TestOnly;
+use SilverStripe\PolyExecution\PolyCommand;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
+
+class TestPolyCommand extends PolyCommand implements TestOnly
+{
+    protected static string $commandName = 'test:poly';
+
+    protected static string $description = 'simple command for testing Director routing to PolyCommand';
+
+    public function getTitle(): string
+    {
+        return 'This is the title!';
+    }
+
+    public function run(InputInterface $input, PolyOutput $output): int
+    {
+        $output->write('Successful poly command request!');
+        return 0;
+    }
+}
diff --git a/tests/php/Control/PolyCommandControllerTest.php b/tests/php/Control/PolyCommandControllerTest.php
new file mode 100644
index 00000000000..c2eaebc1ae3
--- /dev/null
+++ b/tests/php/Control/PolyCommandControllerTest.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace SilverStripe\Control\Tests;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use SilverStripe\Control\HTTPRequest;
+use SilverStripe\Control\HTTPResponse_Exception;
+use SilverStripe\Control\PolyCommandController;
+use SilverStripe\Control\Session;
+use SilverStripe\Control\Tests\PolyCommandControllerTest\TestPolyCommand;
+use SilverStripe\Dev\SapphireTest;
+
+class PolyCommandControllerTest extends SapphireTest
+{
+    protected $usesDatabase = false;
+
+    public static function provideHandleRequest(): array
+    {
+        return [
+            'no params' => [
+                'exitCode' => 0,
+                'params' => [],
+                'allowed' => true,
+                'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
+            ],
+            'with params' => [
+                'exitCode' => 1,
+                'params' => [
+                    'option1' => true,
+                    'option2' => 'abc',
+                    'option3' => [
+                        'val1',
+                        'val2',
+                    ],
+                ],
+                'allowed' => true,
+                'expectedOutput' => "Has option 1: true<br>\noption 2 value: abc<br>\noption 3 value: val1<br>\noption 3 value: val2<br>\n",
+            ],
+            'explicit exit code' => [
+                'exitCode' => 418,
+                'params' => [],
+                'allowed' => true,
+                'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
+            ],
+            'not allowed to run' => [
+                'exitCode' => 404,
+                'params' => [],
+                'allowed' => false,
+                'expectedOutput' => "Has option 1: false<br>\noption 2 value: <br>\n",
+            ],
+        ];
+    }
+
+    #[DataProvider('provideHandleRequest')]
+    public function testHandleRequest(int $exitCode, array $params, bool $allowed, string $expectedOutput): void
+    {
+        $polyCommand = new TestPolyCommand();
+        TestPolyCommand::setCanRunInBrowser($allowed);
+        if ($allowed) {
+            // Don't set the exit code if not allowed to run - we want to test that it's correctly forced to 404
+            $polyCommand->setExitCode($exitCode);
+        } else {
+            $this->expectException(HTTPResponse_Exception::class);
+            $this->expectExceptionCode(404);
+        }
+        $controller = new PolyCommandController($polyCommand);
+
+        $request = new HTTPRequest('GET', '', $params);
+        $request->setSession(new Session([]));
+        $response = $controller->handleRequest($request);
+
+        if ($exitCode === 0) {
+            $statusCode = 200;
+        } elseif ($exitCode === 1) {
+            $statusCode = 500;
+        } elseif ($exitCode === 2) {
+            $statusCode = 400;
+        } else {
+            $statusCode = $exitCode;
+        }
+
+        if ($allowed) {
+            $this->assertSame($expectedOutput, $response->getBody());
+        } else {
+            // The 404 response will NOT contain any output from the command, because the command didn't run.
+            $this->assertNotSame($expectedOutput, $response->getBody());
+        }
+        $this->assertSame($statusCode, $response->getStatusCode());
+    }
+}
diff --git a/tests/php/Control/PolyCommandControllerTest/TestPolyCommand.php b/tests/php/Control/PolyCommandControllerTest/TestPolyCommand.php
new file mode 100644
index 00000000000..fbcc5adc936
--- /dev/null
+++ b/tests/php/Control/PolyCommandControllerTest/TestPolyCommand.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace SilverStripe\Control\Tests\PolyCommandControllerTest;
+
+use SilverStripe\Dev\TestOnly;
+use SilverStripe\PolyExecution\PolyCommand;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+
+class TestPolyCommand extends PolyCommand implements TestOnly
+{
+    protected static string $commandName = 'test:poly';
+
+    protected static string $description = 'simple command for testing controller wrapper';
+
+    protected static bool $canRunInBrowser = true;
+
+    private int $exitCode = 0;
+
+    public function getTitle(): string
+    {
+        return 'This is the title!';
+    }
+
+    public function run(InputInterface $input, PolyOutput $output): int
+    {
+        $output->writeln('Has option 1: ' . ($input->getOption('option1') ? 'true' : 'false'));
+        $output->writeln('option 2 value: ' . $input->getOption('option2'));
+        foreach ($input->getOption('option3') ?? [] as $value) {
+            $output->writeln('option 3 value: ' . $value);
+        }
+        return $this->exitCode;
+    }
+
+    public function setExitCode(int $code): void
+    {
+        $this->exitCode = $code;
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new InputOption('option1', null, InputOption::VALUE_NONE),
+            new InputOption('option2', null, InputOption::VALUE_REQUIRED),
+            new InputOption('option3', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
+        ];
+    }
+
+    public static function canRunInBrowser(): bool
+    {
+        return static::$canRunInBrowser;
+    }
+
+    public static function setCanRunInBrowser(bool $canRun): void
+    {
+        static::$canRunInBrowser = $canRun;
+    }
+}
diff --git a/tests/php/Dev/BuildTaskTest.php b/tests/php/Dev/BuildTaskTest.php
index 8f8f5420949..afa6239d46e 100644
--- a/tests/php/Dev/BuildTaskTest.php
+++ b/tests/php/Dev/BuildTaskTest.php
@@ -3,41 +3,26 @@
 namespace SilverStripe\Dev\Tests;
 
 use SilverStripe\Dev\SapphireTest;
-use SilverStripe\Dev\BuildTask;
+use SilverStripe\Dev\Tests\BuildTaskTest\TestBuildTask;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\ORM\FieldType\DBDatetime;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
 
 class BuildTaskTest extends SapphireTest
 {
-    /**
-     * Test that the default `$enabled` property is used when the new `is_enabled` config is not used
-     * Test that the `is_enabled` config overrides `$enabled` property
-     *
-     * This test should be removed in CMS 6 as the default $enabled property is now deprecated
-     */
-    public function testIsEnabled(): void
+    public function testRunOutput(): void
     {
-        // enabledTask
-        $enabledTask = new class extends BuildTask
-        {
-            protected $enabled = true;
-            public function run($request)
-            {
-                // noop
-            }
-        };
-        $this->assertTrue($enabledTask->isEnabled());
-        $enabledTask->config()->set('is_enabled', false);
-        $this->assertFalse($enabledTask->isEnabled());
-        // disabledTask
-        $disabledTask = new class extends BuildTask
-        {
-            protected $enabled = false;
-            public function run($request)
-            {
-                // noop
-            }
-        };
-        $this->assertFalse($disabledTask->isEnabled());
-        $disabledTask->config()->set('is_enabled', true);
-        $this->assertTrue($disabledTask->isEnabled());
+        DBDatetime::set_mock_now('2024-01-01 12:00:00');
+        $task = new TestBuildTask();
+        $task->setTimeTo = '2024-01-01 12:00:15';
+        $buffer = new BufferedOutput();
+        $output = new PolyOutput(PolyOutput::FORMAT_ANSI, wrappedOutput: $buffer);
+        $input = new ArrayInput([]);
+        $input->setInteractive(false);
+
+        $task->run($input, $output);
+
+        $this->assertSame("Running task 'my title'\nThis output is coming from a build task\n\nTask 'my title' completed successfully in 15 seconds\n", $buffer->fetch());
     }
 }
diff --git a/tests/php/Dev/BuildTaskTest/TestBuildTask.php b/tests/php/Dev/BuildTaskTest/TestBuildTask.php
new file mode 100644
index 00000000000..16c26fa33e3
--- /dev/null
+++ b/tests/php/Dev/BuildTaskTest/TestBuildTask.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace SilverStripe\Dev\Tests\BuildTaskTest;
+
+use SilverStripe\Dev\BuildTask;
+use SilverStripe\Dev\TestOnly;
+use SilverStripe\PolyExecution\PolyOutput;
+use SilverStripe\ORM\FieldType\DBDatetime;
+use Symfony\Component\Console\Input\InputInterface;
+
+class TestBuildTask extends BuildTask implements TestOnly
+{
+    protected static string $commandName = 'test-build-task';
+
+    protected string $title = 'my title';
+
+    protected static string $description = 'command for testing build tasks display as expected';
+
+    public string $setTimeTo;
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        DBDatetime::set_mock_now($this->setTimeTo);
+        $output->writeln('This output is coming from a build task');
+        return 0;
+    }
+}
diff --git a/tests/php/Dev/DevAdminControllerTest.php b/tests/php/Dev/DevAdminControllerTest.php
index a644a47e4dd..0a6d9aa08ca 100644
--- a/tests/php/Dev/DevAdminControllerTest.php
+++ b/tests/php/Dev/DevAdminControllerTest.php
@@ -3,47 +3,47 @@
 namespace SilverStripe\Dev\Tests;
 
 use Exception;
-use ReflectionMethod;
+use LogicException;
 use SilverStripe\Control\Director;
+use SilverStripe\Control\RequestHandler;
 use SilverStripe\Core\Injector\Injector;
 use SilverStripe\Core\Kernel;
+use SilverStripe\Dev\Command\DevCommand;
 use SilverStripe\Dev\DevelopmentAdmin;
 use SilverStripe\Dev\FunctionalTest;
 use SilverStripe\Dev\Tests\DevAdminControllerTest\Controller1;
 use SilverStripe\Dev\Tests\DevAdminControllerTest\ControllerWithPermissions;
+use SilverStripe\Dev\Tests\DevAdminControllerTest\TestCommand;
+use SilverStripe\Dev\Tests\DevAdminControllerTest\TestHiddenController;
 use PHPUnit\Framework\Attributes\DataProvider;
 
-/**
- * Note: the running of this test is handled by the thing it's testing (DevelopmentAdmin controller).
- */
 class DevAdminControllerTest extends FunctionalTest
 {
-
     protected function setUp(): void
     {
         parent::setUp();
 
         DevelopmentAdmin::config()->merge(
-            'registered_controllers',
+            'commands',
+            [
+                'c1' => TestCommand::class,
+            ]
+        );
+
+        DevelopmentAdmin::config()->merge(
+            'controllers',
             [
                 'x1' => [
-                    'controller' => Controller1::class,
-                    'links' => [
-                        'x1' => 'x1 link description',
-                        'x1/y1' => 'x1/y1 link description'
-                    ]
-                ],
-                'x2' => [
-                    'controller' => 'DevAdminControllerTest_Controller2', // intentionally not a class that exists
-                    'links' => [
-                        'x2' => 'x2 link description'
-                    ]
+                    'class' => Controller1::class,
+                    'description' => 'controller1 description',
                 ],
                 'x3' => [
-                    'controller' => ControllerWithPermissions::class,
-                    'links' => [
-                        'x3' => 'x3 link description'
-                    ]
+                    'class' => ControllerWithPermissions::class,
+                    'description' => 'permission controller description',
+                ],
+                'x4' => [
+                    'class' => TestHiddenController::class,
+                    'skipLink' => true,
                 ],
             ]
         );
@@ -51,10 +51,12 @@ protected function setUp(): void
 
     public function testGoodRegisteredControllerOutput()
     {
-        // Check for the controller running from the registered url above
-        // (we use contains rather than equals because sometimes you get a warning)
+        // Check for the controller or command running from the registered url above
+        // Use string contains string because there's a lot of extra HTML markup around the output
         $this->assertStringContainsString(Controller1::OK_MSG, $this->getCapture('/dev/x1'));
-        $this->assertStringContainsString(Controller1::OK_MSG, $this->getCapture('/dev/x1/y1'));
+        $this->assertStringContainsString(Controller1::OK_MSG . ' y1', $this->getCapture('/dev/x1/y1'));
+        $this->assertStringContainsString(TestHiddenController::OK_MSG, $this->getCapture('/dev/x4'));
+        $this->assertStringContainsString('<h2>This is a test command</h2>' . TestCommand::OK_MSG, $this->getCapture('/dev/c1'));
     }
 
     public function testGoodRegisteredControllerStatus()
@@ -62,9 +64,8 @@ public function testGoodRegisteredControllerStatus()
         // Check response code is 200/OK
         $this->assertEquals(false, $this->getAndCheckForError('/dev/x1'));
         $this->assertEquals(false, $this->getAndCheckForError('/dev/x1/y1'));
-
-        // Check response code is 500/ some sort of error
-        $this->assertEquals(true, $this->getAndCheckForError('/dev/x2'));
+        $this->assertEquals(false, $this->getAndCheckForError('/dev/x4'));
+        $this->assertEquals(false, $this->getAndCheckForError('/dev/xc1'));
     }
 
     #[DataProvider('getLinksPermissionsProvider')]
@@ -77,29 +78,77 @@ public function testGetLinks(string $permission, array $present, array $absent):
         try {
             $this->logInWithPermission($permission);
             $controller = new DevelopmentAdmin();
-            $method = new ReflectionMethod($controller, 'getLinks');
-            $method->setAccessible(true);
-            $links = $method->invoke($controller);
+            $links = $controller->getLinks();
 
             foreach ($present as $expected) {
-                $this->assertArrayHasKey($expected, $links, sprintf('Expected link %s not found in %s', $expected, json_encode($links)));
+                $this->assertArrayHasKey('dev/' . $expected, $links, sprintf('Expected link %s not found in %s', 'dev/' . $expected, json_encode($links)));
             }
 
             foreach ($absent as $unexpected) {
-                $this->assertArrayNotHasKey($unexpected, $links, sprintf('Unexpected link %s found in %s', $unexpected, json_encode($links)));
+                $this->assertArrayNotHasKey('dev/' . $unexpected, $links, sprintf('Unexpected link %s found in %s', 'dev/' . $unexpected, json_encode($links)));
             }
         } finally {
             $kernel->setEnvironment($env);
         }
     }
 
+    public static function provideMissingClasses(): array
+    {
+        return [
+            'missing command' => [
+                'configKey' => 'commands',
+                'configToMerge' => [
+                    'c2' => 'DevAdminControllerTest_NonExistentCommand',
+                ],
+                'expectedMessage' => 'Class \'DevAdminControllerTest_NonExistentCommand\' doesn\'t exist',
+            ],
+            'missing controller' => [
+                'configKey' => 'controllers',
+                'configToMerge' => [
+                    'x2' => [
+                        'class' => 'DevAdminControllerTest_NonExistentController',
+                        'description' => 'controller2 description',
+                    ],
+                ],
+                'expectedMessage' => 'Class \'DevAdminControllerTest_NonExistentController\' doesn\'t exist',
+            ],
+            'wrong class command' => [
+                'configKey' => 'commands',
+                'configToMerge' => [
+                    'c2' => static::class,
+                ],
+                'expectedMessage' => 'Class \'' . static::class . '\' must be a subclass of ' . DevCommand::class,
+            ],
+            'wrong class controller' => [
+                'configKey' => 'controllers',
+                'configToMerge' => [
+                    'x2' => [
+                        'class' => static::class,
+                        'description' => 'controller2 description',
+                    ],
+                ],
+                'expectedMessage' => 'Class \'' . static::class . '\' must be a subclass of ' . RequestHandler::class,
+            ],
+        ];
+    }
+
+    #[DataProvider('provideMissingClasses')]
+    public function testMissingClasses(string $configKey, array $configToMerge, string $expectedMessage): void
+    {
+        DevelopmentAdmin::config()->merge($configKey, $configToMerge);
+        $controller = new DevelopmentAdmin();
+        $this->expectException(LogicException::class);
+        $this->expectExceptionMessage($expectedMessage);
+        $controller->getLinks();
+    }
+
     public static function getLinksPermissionsProvider() : array
     {
         return [
-            ['ADMIN', ['x1', 'x1/y1', 'x3'], ['x2']],
-            ['ALL_DEV_ADMIN', ['x1', 'x1/y1', 'x3'], ['x2']],
-            ['DEV_ADMIN_TEST_PERMISSION', ['x3'], ['x1', 'x1/y1', 'x2']],
-            ['NOTHING', [], ['x1', 'x1/y1', 'x2', 'x3']],
+            'admin access' => ['ADMIN', ['c1', 'x1', 'x3'], ['x4']],
+            'all dev access' => ['ALL_DEV_ADMIN', ['c1', 'x1', 'x3'], ['x4']],
+            'dev test access' => ['DEV_ADMIN_TEST_PERMISSION', ['x3'], ['c1', 'x1', 'x4']],
+            'no access' => ['NOTHING', [], ['c1', 'x1', 'x3', 'x4']],
         ];
     }
 
diff --git a/tests/php/Dev/DevAdminControllerTest/Controller1.php b/tests/php/Dev/DevAdminControllerTest/Controller1.php
index e73ee27ccfd..6e16c113154 100644
--- a/tests/php/Dev/DevAdminControllerTest/Controller1.php
+++ b/tests/php/Dev/DevAdminControllerTest/Controller1.php
@@ -27,6 +27,6 @@ public function index()
 
     public function y1Action()
     {
-        echo Controller1::OK_MSG;
+        echo Controller1::OK_MSG . ' y1';
     }
 }
diff --git a/tests/php/Dev/DevAdminControllerTest/TestCommand.php b/tests/php/Dev/DevAdminControllerTest/TestCommand.php
new file mode 100644
index 00000000000..94d8310fb38
--- /dev/null
+++ b/tests/php/Dev/DevAdminControllerTest/TestCommand.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace SilverStripe\Dev\Tests\DevAdminControllerTest;
+
+use SilverStripe\Dev\Command\DevCommand;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
+
+class TestCommand extends DevCommand
+{
+    const OK_MSG = 'DevAdminControllerTest_TestCommand TEST OK';
+
+    protected static string $commandName = 'my-test-command';
+
+    protected static string $description = 'my test command';
+
+    public function getTitle(): string
+    {
+        return 'Test command';
+    }
+
+    protected function execute(InputInterface $input, PolyOutput $output): int
+    {
+        $output->write(TestCommand::OK_MSG);
+        return 0;
+    }
+
+    protected function getHeading(): string
+    {
+        return 'This is a test command';
+    }
+}
diff --git a/tests/php/Dev/DevAdminControllerTest/TestHiddenController.php b/tests/php/Dev/DevAdminControllerTest/TestHiddenController.php
new file mode 100644
index 00000000000..50f8908b5be
--- /dev/null
+++ b/tests/php/Dev/DevAdminControllerTest/TestHiddenController.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace SilverStripe\Dev\Tests\DevAdminControllerTest;
+
+use SilverStripe\Control\Controller;
+
+class TestHiddenController extends Controller
+{
+    const OK_MSG = 'DevAdminControllerTest_TestHiddenController TEST OK';
+
+    public function index()
+    {
+        echo TestHiddenController::OK_MSG;
+    }
+}
diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php
index eeabc78a090..825313ea22a 100644
--- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php
+++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php
@@ -3,13 +3,15 @@
 namespace SilverStripe\Dev\Tests\TaskRunnerTest;
 
 use SilverStripe\Dev\BuildTask;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
 
 abstract class TaskRunnerTest_AbstractTask extends BuildTask
 {
     protected $enabled = true;
 
-    public function run($request)
+    protected function execute(InputInterface $input, PolyOutput $output): int
     {
-        // NOOP
+        return 0;
     }
 }
diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_ChildOfAbstractTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_ChildOfAbstractTask.php
index c45bdbc2248..cb42a1e5834 100644
--- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_ChildOfAbstractTask.php
+++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_ChildOfAbstractTask.php
@@ -3,13 +3,15 @@
 namespace SilverStripe\Dev\Tests\TaskRunnerTest;
 
 use SilverStripe\Dev\Tests\TaskRunnerTest\TaskRunnerTest_AbstractTask;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
 
 class TaskRunnerTest_ChildOfAbstractTask extends TaskRunnerTest_AbstractTask
 {
     protected $enabled = true;
 
-    public function run($request)
+    protected function doRun(InputInterface $input, PolyOutput $output): int
     {
-        // NOOP
+        return 0;
     }
 }
diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php
index e0748a8607b..4b01ad50ade 100644
--- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php
+++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php
@@ -3,13 +3,15 @@
 namespace SilverStripe\Dev\Tests\TaskRunnerTest;
 
 use SilverStripe\Dev\BuildTask;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
 
 class TaskRunnerTest_DisabledTask extends BuildTask
 {
-    protected $enabled = false;
+    private static bool $is_enabled = false;
 
-    public function run($request)
+    protected function execute(InputInterface $input, PolyOutput $output): int
     {
-        // NOOP
+        return 0;
     }
 }
diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php
index c76bd16f0c6..b37d0d7ce7a 100644
--- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php
+++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php
@@ -3,13 +3,15 @@
 namespace SilverStripe\Dev\Tests\TaskRunnerTest;
 
 use SilverStripe\Dev\BuildTask;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Input\InputInterface;
 
 class TaskRunnerTest_EnabledTask extends BuildTask
 {
     protected $enabled = true;
 
-    public function run($request)
+    protected function execute(InputInterface $input, PolyOutput $output): int
     {
-        // NOOP
+        return 0;
     }
 }
diff --git a/tests/php/Logging/HTTPOutputHandlerTest.php b/tests/php/Logging/ErrorOutputHandlerTest.php
similarity index 94%
rename from tests/php/Logging/HTTPOutputHandlerTest.php
rename to tests/php/Logging/ErrorOutputHandlerTest.php
index dc43867b8bc..9e1cfffba0f 100644
--- a/tests/php/Logging/HTTPOutputHandlerTest.php
+++ b/tests/php/Logging/ErrorOutputHandlerTest.php
@@ -13,9 +13,10 @@
 use SilverStripe\Logging\DebugViewFriendlyErrorFormatter;
 use SilverStripe\Logging\DetailedErrorFormatter;
 use SilverStripe\Logging\HTTPOutputHandler;
+use SilverStripe\Logging\ErrorOutputHandler;
 use PHPUnit\Framework\Attributes\DataProvider;
 
-class HTTPOutputHandlerTest extends SapphireTest
+class ErrorOutputHandlerTest extends SapphireTest
 {
     protected function setUp(): void
     {
@@ -28,7 +29,7 @@ protected function setUp(): void
 
     public function testGetFormatter()
     {
-        $handler = new HTTPOutputHandler();
+        $handler = new ErrorOutputHandler();
 
         $detailedFormatter = new DetailedErrorFormatter();
         $friendlyFormatter = new DebugViewFriendlyErrorFormatter();
@@ -49,9 +50,9 @@ public function testGetFormatter()
      */
     public function testDevConfig()
     {
-        /** @var HTTPOutputHandler $handler */
+        /** @var ErrorOutputHandler $handler */
         $handler = Injector::inst()->get(HandlerInterface::class);
-        $this->assertInstanceOf(HTTPOutputHandler::class, $handler);
+        $this->assertInstanceOf(ErrorOutputHandler::class, $handler);
 
         // Test only default formatter is set, but CLI specific formatter is left out
         $this->assertNull($handler->getCLIFormatter());
@@ -154,7 +155,7 @@ public function testShouldShowError(
         bool $shouldShow,
         bool $expected
     ) {
-        $reflectionShouldShow = new ReflectionMethod(HTTPOutputHandler::class, 'shouldShowError');
+        $reflectionShouldShow = new ReflectionMethod(ErrorOutputHandler::class, 'shouldShowError');
         $reflectionShouldShow->setAccessible(true);
         $reflectionDeprecation = new ReflectionClass(Deprecation::class);
 
@@ -175,16 +176,15 @@ public function testShouldShowError(
         $reflectionDirector = new ReflectionClass(Environment::class);
         $origIsCli = $reflectionDirector->getStaticPropertyValue('isCliOverride');
         $reflectionDirector->setStaticPropertyValue('isCliOverride', $isCli);
-
         try {
-            $handler = new HTTPOutputHandler();
+            $handler = new ErrorOutputHandler();
             $result = $reflectionShouldShow->invoke($handler, $errorCode);
             $this->assertSame($expected, $result);
 
             Deprecation::setShouldShowForCli($cliShouldShowOrig);
             Deprecation::setShouldShowForHttp($httpShouldShowOrig);
-            $reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringErrorOrig);
         } finally {
+            $reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringErrorOrig);
             $reflectionDirector->setStaticPropertyValue('isCliOverride', $origIsCli);
         }
     }
diff --git a/tests/php/PolyExecution/AnsiToHtmlConverterTest.php b/tests/php/PolyExecution/AnsiToHtmlConverterTest.php
new file mode 100644
index 00000000000..563b80148b1
--- /dev/null
+++ b/tests/php/PolyExecution/AnsiToHtmlConverterTest.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace SilverStripe\PolyExecution\Tests;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use SilverStripe\Dev\SapphireTest;
+use SilverStripe\PolyExecution\AnsiToHtmlConverter;
+use Symfony\Component\Console\Formatter\OutputFormatter;
+
+class AnsiToHtmlConverterTest extends SapphireTest
+{
+    protected $usesDatabase = false;
+
+    public static function provideConvert(): array
+    {
+        return [
+            'no text, no result' => [
+                'unformatted' => '',
+                'expected' => '',
+            ],
+            'no empty span' => [
+                'unformatted' => 'This text <info></info> is unformatted',
+                'expected' => 'This text  is unformatted',
+            ],
+            'named formats are converted' => [
+                'unformatted' => 'This text <info>has some</info> formatting',
+                'expected' => 'This text <span style="color: green">has some</span> formatting',
+            ],
+            'fg and bg are converted' => [
+                'unformatted' => 'This text <fg=red;bg=blue>has some</> formatting',
+                'expected' => 'This text <span style="background-color: blue; color: darkred">has some</span> formatting',
+            ],
+            'bold and underscore are converted' => [
+                'unformatted' => 'This text <options=bold;options=underscore>has some</> formatting',
+                'expected' => 'This text <span style="font-weight: bold; text-decoration: underline">has some</span> formatting',
+            ],
+            'multiple styles are converted' => [
+                'unformatted' => 'This text <options=bold;fg=green>has some</> <comment>formatting</comment>',
+                'expected' => 'This text <span style="font-weight: bold; color: green">has some</span> <span style="color: goldenrod">formatting</span>',
+            ],
+            'hyperlinks are converted' => [
+                'unformatted' => 'This text <href=https://www.example.com/>has a</> link',
+                'expected' => 'This text <a href="https://www.example.com/">has a</a> link',
+            ],
+        ];
+    }
+
+    #[DataProvider('provideConvert')]
+    public function testConvert(string $unformatted, string $expected): void
+    {
+        $converter = new AnsiToHtmlConverter();
+        $ansiFormatter = new OutputFormatter(true);
+        $formatted = $ansiFormatter->format($unformatted);
+
+        $this->assertSame($expected, $converter->convert($formatted));
+    }
+}
diff --git a/tests/php/PolyExecution/HttpRequestInputTest.php b/tests/php/PolyExecution/HttpRequestInputTest.php
new file mode 100644
index 00000000000..ad41daaef8d
--- /dev/null
+++ b/tests/php/PolyExecution/HttpRequestInputTest.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace SilverStripe\PolyExecution\Tests;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use SilverStripe\Control\HTTPRequest;
+use SilverStripe\Dev\SapphireTest;
+use SilverStripe\PolyExecution\HttpRequestInput;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class HttpRequestInputTest extends SapphireTest
+{
+    protected $usesDatabase = false;
+
+    public static function provideInputOptions(): array
+    {
+        return [
+            'no vars, no options' => [
+                'requestVars' => [],
+                'commandOptions' => [],
+                'expected' => [],
+            ],
+            'some vars, no options' => [
+                'requestVars' => [
+                    'var1' => '1',
+                    'var2' => 'abcd',
+                    'var3' => null,
+                    'var4' => ['a', 'b', 'c'],
+                ],
+                'commandOptions' => [],
+                'expected' => [],
+            ],
+            'no vars, some options' => [
+                'requestVars' => [],
+                'commandOptions' => [
+                    new InputOption('var1', null, InputOption::VALUE_NEGATABLE),
+                    new InputOption('var2', null, InputOption::VALUE_REQUIRED),
+                    new InputOption('var3', null, InputOption::VALUE_OPTIONAL),
+                    new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
+                ],
+                'expected' => [
+                    'var1' => null,
+                    'var2' => null,
+                    'var3' => null,
+                    'var4' => [],
+                ],
+            ],
+            'no vars, some options (with default values)' => [
+                'requestVars' => [],
+                'commandOptions' => [
+                    new InputOption('var1', null, InputOption::VALUE_NEGATABLE, default: true),
+                    new InputOption('var2', null, InputOption::VALUE_REQUIRED, default: 'def'),
+                    new InputOption('var3', null, InputOption::VALUE_OPTIONAL, default: false),
+                    new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, default: [1, 2, 'banana']),
+                ],
+                'expected' => [
+                    'var1' => true,
+                    'var2' => 'def',
+                    'var3' => false,
+                    'var4' => [1, 2, 'banana'],
+                ],
+            ],
+            'some vars and options' => [
+                'requestVars' => [
+                    'var1' => '1',
+                    'var2' => 'abcd',
+                    'var3' => 2,
+                    'var4' => ['a', 'b', 'c'],
+                ],
+                'commandOptions' => [
+                    new InputOption('var1', null, InputOption::VALUE_NEGATABLE),
+                    new InputOption('var2', null, InputOption::VALUE_REQUIRED),
+                    new InputOption('var3', null, InputOption::VALUE_OPTIONAL),
+                    new InputOption('var4', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
+                ],
+                'expected' => [
+                    'var1' => true,
+                    'var2' => 'abcd',
+                    'var3' => 2,
+                    'var4' => ['a', 'b', 'c'],
+                ],
+            ],
+        ];
+    }
+
+    #[DataProvider('provideInputOptions')]
+    public function testInputOptions(array $requestVars, array $commandOptions, array $expected): void
+    {
+        $request = new HTTPRequest('GET', 'arbitrary-url', $requestVars);
+        $input = new HttpRequestInput($request, $commandOptions);
+
+        foreach ($expected as $option => $value) {
+            $this->assertSame($value, $input->getOption($option), 'checking value for ' . $option);
+        }
+
+        // If there's no expected values, the success metric is that we didn't throw any exceptions.
+        if (empty($expected)) {
+            $this->expectNotToPerformAssertions();
+        }
+    }
+
+    public static function provideGetVerbosity(): array
+    {
+        return [
+            'default to normal' => [
+                'requestVars' => [],
+                'expected' => OutputInterface::VERBOSITY_NORMAL,
+            ],
+            'shortcuts are ignored' => [
+                'requestVars' => ['v' => 1],
+                'expected' => OutputInterface::VERBOSITY_NORMAL,
+            ],
+            '?verbose=1 is verbose' => [
+                'requestVars' => ['verbose' => 1],
+                'expected' => OutputInterface::VERBOSITY_VERBOSE,
+            ],
+            '?verbose=2 is very verbose' => [
+                'requestVars' => ['verbose' => 2],
+                'expected' => OutputInterface::VERBOSITY_VERY_VERBOSE,
+            ],
+            '?verbose=3 is debug' => [
+                // Check string works as well as int
+                'requestVars' => ['verbose' => '3'],
+                'expected' => OutputInterface::VERBOSITY_DEBUG,
+            ],
+            '?quiet=1 is quiet' => [
+                'requestVars' => ['quiet' => 1],
+                'expected' => OutputInterface::VERBOSITY_QUIET,
+            ],
+        ];
+    }
+
+    #[DataProvider('provideGetVerbosity')]
+    public function testGetVerbosity(array $requestVars, int $expected): void
+    {
+        $request = new HTTPRequest('GET', 'arbitrary-url', $requestVars);
+        $input = new HttpRequestInput($request);
+        $this->assertSame($expected, $input->getVerbosity());
+    }
+}
diff --git a/tests/php/PolyExecution/PolyOutputTest.php b/tests/php/PolyExecution/PolyOutputTest.php
new file mode 100644
index 00000000000..e60825b28c3
--- /dev/null
+++ b/tests/php/PolyExecution/PolyOutputTest.php
@@ -0,0 +1,207 @@
+<?php
+
+namespace SilverStripe\PolyExecution\Tests;
+
+use LogicException;
+use PHPUnit\Framework\Attributes\DataProvider;
+use SilverStripe\Dev\SapphireTest;
+use SilverStripe\PolyExecution\PolyOutput;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+class PolyOutputTest extends SapphireTest
+{
+    protected $usesDatabase = false;
+
+    public static function provideWriteForFormat(): array
+    {
+        return [
+            'html for html' => [
+                'outputFormat' => PolyOutput::FORMAT_HTML,
+                'forFormat' => PolyOutput::FORMAT_HTML,
+                'messages' => ['one message', 'two message'],
+                'expected' => "one message<br>\ntwo message<br>\n",
+            ],
+            'html for ansi' => [
+                'outputFormat' => PolyOutput::FORMAT_HTML,
+                'forFormat' => PolyOutput::FORMAT_ANSI,
+                'messages' => ['one message', 'two message'],
+                'expected' => '',
+            ],
+            'ansi for html' => [
+                'outputFormat' => PolyOutput::FORMAT_ANSI,
+                'forFormat' => PolyOutput::FORMAT_HTML,
+                'messages' => ['one message', 'two message'],
+                'expected' => '',
+            ],
+            'ansi for ansi' => [
+                'outputFormat' => PolyOutput::FORMAT_ANSI,
+                'forFormat' => PolyOutput::FORMAT_ANSI,
+                'messages' => ['one message', 'two message'],
+                'expected' => "one message\ntwo message\n",
+            ],
+        ];
+    }
+
+    #[DataProvider('provideWriteForFormat')]
+    public function testWriteForFormat(
+        string $outputFormat,
+        string $forFormat,
+        string|iterable $messages,
+        string $expected
+    ): void {
+        $buffer = new BufferedOutput();
+        $output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
+        $output->writeForFormat($forFormat, $messages, true);
+        $this->assertSame($expected, $buffer->fetch());
+    }
+
+    public static function provideList(): array
+    {
+        return [
+            'empty list ANSI' => [
+                'outputFormat' => PolyOutput::FORMAT_ANSI,
+                'list' => [
+                    'type' => PolyOutput::LIST_UNORDERED,
+                    'items' => []
+                ],
+                'expected' => '',
+            ],
+            'empty list HTML' => [
+                'outputFormat' => PolyOutput::FORMAT_HTML,
+                'list' => [
+                    'type' => PolyOutput::LIST_UNORDERED,
+                    'items' => []
+                ],
+                'expected' => '<ul></ul>',
+            ],
+            'single list UL ANSI' => [
+                'outputFormat' => PolyOutput::FORMAT_ANSI,
+                'list' => [
+                    'type' => PolyOutput::LIST_UNORDERED,
+                    'items' => ['item 1', 'item 2']
+                ],
+                'expected' => <<< EOL
+                 * item 1
+                 * item 2
+
+                EOL,
+            ],
+            'single list OL ANSI' => [
+                'outputFormat' => PolyOutput::FORMAT_ANSI,
+                'list' => [
+                    'type' => PolyOutput::LIST_ORDERED,
+                    'items' => ['item 1', 'item 2']
+                ],
+                'expected' => <<< EOL
+                 1. item 1
+                 2. item 2
+
+                EOL,
+            ],
+            'single list UL HTML' => [
+                'outputFormat' => PolyOutput::FORMAT_HTML,
+                'list' => [
+                    'type' => PolyOutput::LIST_UNORDERED,
+                    'items' => ['item 1', 'item 2']
+                ],
+                'expected' => '<ul><li>item 1</li><li>item 2</li></ul>',
+            ],
+            'single list OL HTML' => [
+                'outputFormat' => PolyOutput::FORMAT_HTML,
+                'list' => [
+                    'type' => PolyOutput::LIST_ORDERED,
+                    'items' => ['item 1', 'item 2']
+                ],
+                'expected' => '<ol><li>item 1</li><li>item 2</li></ol>',
+            ],
+            'nested list ANSI' => [
+                'outputFormat' => PolyOutput::FORMAT_ANSI,
+                'list' => [
+                    'type' => PolyOutput::LIST_UNORDERED,
+                    'items' => [
+                        'item 1',
+                        'item 2',
+                        [
+                            'type' => PolyOutput::LIST_ORDERED,
+                            'items' => [
+                                'item 2a',
+                                ['item 2b','item 2c'],
+                                'item 2d',
+                            ]
+                        ],
+                        'item 3',
+                    ]
+                ],
+                'expected' => <<< EOL
+                 * item 1
+                 * item 2
+                  1. item 2a
+                  2. item 2b
+                  3. item 2c
+                  4. item 2d
+                 * item 3
+
+                EOL,
+            ],
+            'nested list HTML' => [
+                'outputFormat' => PolyOutput::FORMAT_HTML,
+                'list' => [
+                    'type' => PolyOutput::LIST_UNORDERED,
+                    'items' => [
+                        'item 1',
+                        'item 2',
+                        'list' => [
+                            'type' => PolyOutput::LIST_ORDERED,
+                            'items' => [
+                                'item 2a',
+                                ['item 2b','item 2c'],
+                                'item 2d',
+                            ]
+                        ],
+                        'item 3',
+                    ]
+                ],
+                'expected' => '<ul><li>item 1</li><li>item 2</li><ol><li>item 2a</li><li>item 2b</li><li>item 2c</li><li>item 2d</li></ol><li>item 3</li></ul>',
+            ],
+        ];
+    }
+
+    #[DataProvider('provideList')]
+    public function testList(string $outputFormat, array $list, string $expected): void
+    {
+        $buffer = new BufferedOutput();
+        $output = new PolyOutput($outputFormat, wrappedOutput: $buffer);
+        $this->makeListRecursive($output, $list);
+        $this->assertSame($expected, $buffer->fetch());
+    }
+
+    public static function provideListMustBeStarted(): array
+    {
+        return [
+            [PolyOutput::FORMAT_ANSI],
+            [PolyOutput::FORMAT_HTML],
+        ];
+    }
+
+    #[DataProvider('provideListMustBeStarted')]
+    public function testListMustBeStarted(string $outputFormat): void
+    {
+        $output = new PolyOutput($outputFormat);
+        $this->expectException(LogicException::class);
+        $this->expectExceptionMessage('No lists started. Call startList() first.');
+        $output->writeListItem('');
+    }
+
+    private function makeListRecursive(PolyOutput $output, array $list): void
+    {
+        $output->startList($list['type']);
+        foreach ($list['items'] as $item) {
+            if (isset($item['type'])) {
+                $this->makeListRecursive($output, $item);
+                continue;
+            }
+            $output->writeListItem($item);
+        }
+        $output->stopList();
+    }
+}
diff --git a/tests/php/View/SSViewerTest.php b/tests/php/View/SSViewerTest.php
index 45af82fbd72..f39dce9cc95 100644
--- a/tests/php/View/SSViewerTest.php
+++ b/tests/php/View/SSViewerTest.php
@@ -2367,4 +2367,17 @@ public function forTemplate()
         };
         $this->render('$Me', $myArrayData);
     }
+
+    public function testLoopingThroughArrayInOverlay(): void
+    {
+        $viewableData = new ViewableData();
+        $theArray = [
+            ['Val' => 'one'],
+            ['Val' => 'two'],
+            ['Val' => 'red'],
+            ['Val' => 'blue'],
+        ];
+        $output = $viewableData->renderWith('SSViewerTestLoopArray', ['MyArray' => $theArray]);
+        $this->assertEqualIgnoringWhitespace('one two red blue', $output);
+    }
 }
diff --git a/tests/php/View/SSViewerTest/templates/SSViewerTestLoopArray.ss b/tests/php/View/SSViewerTest/templates/SSViewerTestLoopArray.ss
new file mode 100644
index 00000000000..f9a20f36eb5
--- /dev/null
+++ b/tests/php/View/SSViewerTest/templates/SSViewerTestLoopArray.ss
@@ -0,0 +1,3 @@
+<% loop $MyArray %>
+  $Val
+<% end_loop %>