From d79cda8d81a9f4bc2f834ee0feea06edf4fa967a Mon Sep 17 00:00:00 2001
From: Benjamin Franzke <ben@bnf.dev>
Date: Thu, 21 Mar 2024 11:34:23 +0100
Subject: [PATCH] [TASK] Extract site persistence into separate service
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The SiteConfiguration service is currently needed during installation
phase for writing site configurations. This limits symfony dependency
injection usage and basically requires that all services needed by
SiteConfiguration need to be defined and manually wired in
ServiceProvider php code.

The writing part is now split into a separate service that can be used
in EXT:install SetupService as before.

Resolves: #103450
Releases: main
Change-Id: I9cb579ade537c794ce7c1a844b3d7bec7c1b653e
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/83557
Tested-by: core-ci <typo3@b13.com>
Reviewed-by: Jörg Bösche <typo3@joergboesche.de>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 .../SiteConfigurationController.php           |   9 +-
 .../Event/SiteConfigurationChangedEvent.php   |  28 ++
 .../Configuration/SiteConfiguration.php       | 194 +-------------
 .../core/Classes/Configuration/SiteWriter.php | 247 ++++++++++++++++++
 .../Classes/Hooks/CreateSiteConfiguration.php |   6 +-
 typo3/sysext/core/Classes/ServiceProvider.php |   7 +-
 typo3/sysext/core/Configuration/Services.yaml |   5 -
 .../Aspect/PersistedAliasMapperTest.php       |   4 +-
 .../Aspect/PersistedPatternMapperTest.php     |   4 +-
 .../SiteHandling/SiteBasedTestTrait.php       |   8 +-
 .../Configuration/SiteConfigurationTest.php   | 109 --------
 .../Unit/Configuration/SiteWriterTest.php     | 164 ++++++++++++
 ...eConfigurationsOnPackageInitialization.php |   4 +-
 .../install/Classes/Service/SetupService.php  |   6 +-
 .../install/Classes/ServiceProvider.php       |   4 +-
 .../MigrateSiteSettingsConfigUpdate.php       |   5 +-
 .../MigrateSiteSettingsConfigUpdateTest.php   |   7 +-
 .../AddPageTypeZeroSourceTest.php             |   6 +-
 .../AddPlainSlugReplacementSourceTest.php     |   6 +-
 .../SlugRedirectChangeItemFactoryTest.php     |   7 +-
 .../Functional/Service/SlugServiceTest.php    |  18 +-
 .../TcaDataGenerator/AbstractGenerator.php    |   8 +-
 .../Classes/TcaDataGenerator/Generator.php    |   4 +-
 .../TcaDataGenerator/GeneratorFrontend.php    |   4 +-
 24 files changed, 508 insertions(+), 356 deletions(-)
 create mode 100644 typo3/sysext/core/Classes/Configuration/Event/SiteConfigurationChangedEvent.php
 create mode 100644 typo3/sysext/core/Classes/Configuration/SiteWriter.php
 create mode 100644 typo3/sysext/core/Tests/Unit/Configuration/SiteWriterTest.php

diff --git a/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php b/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php
index 9eea8a9ae0bb..83b40ffed1de 100644
--- a/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php
+++ b/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php
@@ -34,6 +34,7 @@ use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
 use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
 use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
@@ -72,6 +73,7 @@ class SiteConfigurationController
         private readonly FormDataCompiler $formDataCompiler,
         private readonly PageRenderer $pageRenderer,
         private readonly SiteConfiguration $siteConfiguration,
+        private readonly SiteWriter $siteWriter,
     ) {}
 
     /**
@@ -402,13 +404,12 @@ class SiteConfigurationController
             );
 
             // Persist the configuration
-            $siteConfigurationManager = GeneralUtility::makeInstance(SiteConfiguration::class);
             try {
                 if (!$isNewConfiguration && $currentIdentifier !== $siteIdentifier) {
-                    $siteConfigurationManager->rename($currentIdentifier, $siteIdentifier);
+                    $this->siteWriter->rename($currentIdentifier, $siteIdentifier);
                     $this->getBackendUser()->writelog(Type::SITE, SiteAction::RENAME, SystemLogErrorClassification::MESSAGE, 0, 'Site configuration \'%s\' was renamed to \'%s\'.', [$currentIdentifier, $siteIdentifier], 'site');
                 }
-                $siteConfigurationManager->write($siteIdentifier, $newSiteConfiguration, true);
+                $this->siteWriter->write($siteIdentifier, $newSiteConfiguration, true);
                 if ($isNewConfiguration) {
                     $this->getBackendUser()->writelog(Type::SITE, SiteAction::CREATE, SystemLogErrorClassification::MESSAGE, 0, 'Site configuration \'%s\' was created.', [$siteIdentifier], 'site');
                 } else {
@@ -656,7 +657,7 @@ class SiteConfigurationController
         }
         try {
             // Verify site does exist, method throws if not
-            GeneralUtility::makeInstance(SiteConfiguration::class)->delete($siteIdentifier);
+            $this->siteWriter->delete($siteIdentifier);
             $this->getBackendUser()->writelog(Type::SITE, SiteAction::DELETE, SystemLogErrorClassification::MESSAGE, 0, 'Site configuration \'%s\' was deleted.', [$siteIdentifier], 'site');
         } catch (SiteConfigurationWriteException $e) {
             $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $e->getMessage(), '', ContextualFeedbackSeverity::WARNING, true);
diff --git a/typo3/sysext/core/Classes/Configuration/Event/SiteConfigurationChangedEvent.php b/typo3/sysext/core/Classes/Configuration/Event/SiteConfigurationChangedEvent.php
new file mode 100644
index 000000000000..264052de11bd
--- /dev/null
+++ b/typo3/sysext/core/Classes/Configuration/Event/SiteConfigurationChangedEvent.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Configuration\Event;
+
+/**
+ * @internal
+ */
+final readonly class SiteConfigurationChangedEvent
+{
+    public function __construct(
+        public string $siteIdentifier,
+    ) {}
+}
diff --git a/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php b/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php
index fc3f084ad31d..91a5eeb13838 100644
--- a/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php
+++ b/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php
@@ -18,22 +18,19 @@ declare(strict_types=1);
 namespace TYPO3\CMS\Core\Configuration;
 
 use Psr\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
 use Symfony\Component\Finder\Finder;
 use Symfony\Component\Yaml\Yaml;
+use TYPO3\CMS\Core\Attribute\AsEventListener;
 use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent;
 use TYPO3\CMS\Core\Cache\Exception\InvalidDataException;
 use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
-use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationBeforeWriteEvent;
+use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationChangedEvent;
 use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationLoadedEvent;
-use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
-use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlPlaceholderException;
 use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
-use TYPO3\CMS\Core\Configuration\Loader\YamlPlaceholderGuard;
-use TYPO3\CMS\Core\Exception\SiteNotFoundException;
 use TYPO3\CMS\Core\SingletonInterface;
 use TYPO3\CMS\Core\Site\Entity\Site;
 use TYPO3\CMS\Core\Site\Entity\SiteSettings;
-use TYPO3\CMS\Core\Utility\ArrayUtility;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
@@ -82,8 +79,10 @@ class SiteConfiguration implements SingletonInterface
     protected $firstLevelCache;
 
     public function __construct(
+        #[Autowire('%env(TYPO3:configPath)%/sites')]
         protected string $configPath,
         protected EventDispatcherInterface $eventDispatcher,
+        #[Autowire(service: 'cache.core')]
         protected PhpFrontend $cache
     ) {}
 
@@ -100,33 +99,6 @@ class SiteConfiguration implements SingletonInterface
         return $this->resolveAllExistingSites($useCache);
     }
 
-    /**
-     * Creates a site configuration with one language "English" which is the de-facto default language for TYPO3 in general.
-     *
-     * @throws SiteConfigurationWriteException
-     */
-    public function createNewBasicSite(string $identifier, int $rootPageId, string $base): void
-    {
-        // Create a default site configuration called "main" as best practice
-        $this->write($identifier, [
-            'rootPageId' => $rootPageId,
-            'base' => $base,
-            'languages' => [
-                0 => [
-                    'title' => 'English',
-                    'enabled' => true,
-                    'languageId' => 0,
-                    'base' => '/',
-                    'locale' => 'en_US.UTF-8',
-                    'navigationTitle' => 'English',
-                    'flag' => 'us',
-                ],
-            ],
-            'errorHandling' => [],
-            'routes' => [],
-        ]);
-    }
-
     /**
      * Resolve all site objects which have been found in the filesystem.
      *
@@ -277,163 +249,13 @@ class SiteConfiguration implements SingletonInterface
         return [];
     }
 
-    public function writeSettings(string $siteIdentifier, array $settings): void
-    {
-        $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName;
-        $yamlFileContents = Yaml::dump($settings, 99, 2);
-        if (!GeneralUtility::writeFile($fileName, $yamlFileContents)) {
-            throw new SiteConfigurationWriteException('Unable to write site settings in sites/' . $siteIdentifier . '/' . $this->configFileName, 1590487411);
-        }
-    }
-
-    /**
-     * Add or update a site configuration
-     *
-     * @param bool $protectPlaceholders whether to disallow introducing new placeholders
-     * @todo enforce $protectPlaceholders with TYPO3 v13.0
-     * @throws SiteConfigurationWriteException
-     */
-    public function write(string $siteIdentifier, array $configuration, bool $protectPlaceholders = false): void
+    #[AsEventListener(event: SiteConfigurationChangedEvent::class)]
+    public function siteConfigurationChanged()
     {
-        $folder = $this->configPath . '/' . $siteIdentifier;
-        $fileName = $folder . '/' . $this->configFileName;
-        $newConfiguration = $configuration;
-        if (!file_exists($folder)) {
-            GeneralUtility::mkdir_deep($folder);
-            if ($protectPlaceholders && $newConfiguration !== []) {
-                $newConfiguration = $this->protectPlaceholders([], $newConfiguration);
-            }
-        } elseif (file_exists($fileName)) {
-            $loader = GeneralUtility::makeInstance(YamlFileLoader::class);
-            // load without any processing to have the unprocessed base to modify
-            $newConfiguration = $loader->load(GeneralUtility::fixWindowsFilePath($fileName), 0);
-            // load the processed configuration to diff changed values
-            $processed = $loader->load(GeneralUtility::fixWindowsFilePath($fileName));
-            // find properties that were modified via GUI
-            $newModified = array_replace_recursive(
-                self::findRemoved($processed, $configuration),
-                self::findModified($processed, $configuration)
-            );
-            if ($protectPlaceholders && $newModified !== []) {
-                $newModified = $this->protectPlaceholders($newConfiguration, $newModified);
-            }
-            // change _only_ the modified keys, leave the original non-changed areas alone
-            ArrayUtility::mergeRecursiveWithOverrule($newConfiguration, $newModified);
-        }
-        $event = $this->eventDispatcher->dispatch(new SiteConfigurationBeforeWriteEvent($siteIdentifier, $newConfiguration));
-        $newConfiguration = $this->sortConfiguration($event->getConfiguration());
-        $yamlFileContents = Yaml::dump($newConfiguration, 99, 2);
-        if (!GeneralUtility::writeFile($fileName, $yamlFileContents)) {
-            throw new SiteConfigurationWriteException('Unable to write site configuration in sites/' . $siteIdentifier . '/' . $this->configFileName, 1590487011);
-        }
         $this->firstLevelCache = null;
-        $this->cache->remove($this->cacheIdentifier);
-    }
-
-    /**
-     * Renames a site identifier (and moves the folder)
-     *
-     * @throws SiteConfigurationWriteException
-     */
-    public function rename(string $currentIdentifier, string $newIdentifier): void
-    {
-        if (!rename($this->configPath . '/' . $currentIdentifier, $this->configPath . '/' . $newIdentifier)) {
-            throw new SiteConfigurationWriteException('Unable to rename folder sites/' . $currentIdentifier, 1522491300);
-        }
-        $this->cache->remove($this->cacheIdentifier);
-        $this->firstLevelCache = null;
-    }
-
-    /**
-     * Removes the config.yaml file of a site configuration.
-     * Also clears the cache.
-     *
-     * @throws SiteNotFoundException|SiteConfigurationWriteException
-     */
-    public function delete(string $siteIdentifier): void
-    {
-        $sites = $this->getAllExistingSites();
-        if (!isset($sites[$siteIdentifier])) {
-            throw new SiteNotFoundException('Site configuration named ' . $siteIdentifier . ' not found.', 1522866183);
-        }
-        $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->configFileName;
-        if (!file_exists($fileName)) {
-            throw new SiteNotFoundException('Site configuration file ' . $this->configFileName . ' within the site ' . $siteIdentifier . ' not found.', 1522866184);
-        }
-        if (!unlink($fileName)) {
-            throw new SiteConfigurationWriteException('Unable to delete folder sites/' . $siteIdentifier, 1596462020);
-        }
-        $this->cache->remove($this->cacheIdentifier);
-        $this->firstLevelCache = null;
-    }
-
-    /**
-     * Detects placeholders that have been introduced and handles* them.
-     * (*) currently throws an exception, but could be purged or escaped as well
-     *
-     * @param array<string, mixed> $existingConfiguration
-     * @param array<string, mixed> $modifiedConfiguration
-     * @return array<string, mixed> sanitized configuration (currently not used, exception thrown before)
-     * @throws SiteConfigurationWriteException
-     */
-    protected function protectPlaceholders(array $existingConfiguration, array $modifiedConfiguration): array
-    {
-        try {
-            return GeneralUtility::makeInstance(YamlPlaceholderGuard::class, $existingConfiguration)
-                ->process($modifiedConfiguration);
-        } catch (YamlPlaceholderException $exception) {
-            throw new SiteConfigurationWriteException($exception->getMessage(), 1670361271, $exception);
-        }
-    }
-
-    protected function sortConfiguration(array $newConfiguration): array
-    {
-        ksort($newConfiguration);
-        if (isset($newConfiguration['imports'])) {
-            $imports = $newConfiguration['imports'];
-            unset($newConfiguration['imports']);
-            $newConfiguration['imports'] = $imports;
-        }
-        return $newConfiguration;
-    }
-
-    protected static function findModified(array $currentConfiguration, array $newConfiguration): array
-    {
-        $differences = [];
-        foreach ($newConfiguration as $key => $value) {
-            if (!isset($currentConfiguration[$key]) || $currentConfiguration[$key] !== $newConfiguration[$key]) {
-                if (!isset($newConfiguration[$key]) && isset($currentConfiguration[$key])) {
-                    $differences[$key] = '__UNSET';
-                } elseif (isset($currentConfiguration[$key])
-                    && is_array($newConfiguration[$key])
-                    && is_array($currentConfiguration[$key])
-                ) {
-                    $differences[$key] = self::findModified($currentConfiguration[$key], $newConfiguration[$key]);
-                } else {
-                    $differences[$key] = $value;
-                }
-            }
-        }
-        return $differences;
-    }
-
-    protected static function findRemoved(array $currentConfiguration, array $newConfiguration): array
-    {
-        $removed = [];
-        foreach ($currentConfiguration as $key => $value) {
-            if (!isset($newConfiguration[$key])) {
-                $removed[$key] = '__UNSET';
-            } elseif (isset($currentConfiguration[$key]) && is_array($currentConfiguration[$key]) && is_array($newConfiguration[$key])) {
-                $removedInRecursion = self::findRemoved($currentConfiguration[$key], $newConfiguration[$key]);
-                if (!empty($removedInRecursion)) {
-                    $removed[$key] = $removedInRecursion;
-                }
-            }
-        }
-
-        return $removed;
     }
 
+    #[AsEventListener('typo3-core/site-configuration')]
     public function warmupCaches(CacheWarmupEvent $event): void
     {
         if ($event->hasGroup('system')) {
diff --git a/typo3/sysext/core/Classes/Configuration/SiteWriter.php b/typo3/sysext/core/Classes/Configuration/SiteWriter.php
new file mode 100644
index 000000000000..98e951e34f6e
--- /dev/null
+++ b/typo3/sysext/core/Classes/Configuration/SiteWriter.php
@@ -0,0 +1,247 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Configuration;
+
+use Psr\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\Yaml\Yaml;
+use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
+use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationBeforeWriteEvent;
+use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationChangedEvent;
+use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
+use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlPlaceholderException;
+use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
+use TYPO3\CMS\Core\Configuration\Loader\YamlPlaceholderGuard;
+use TYPO3\CMS\Core\Exception\SiteNotFoundException;
+use TYPO3\CMS\Core\Utility\ArrayUtility;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Writes Site objects into site configuration files.
+ *
+ * @internal
+ */
+class SiteWriter
+{
+    /**
+     * Config yaml file name.
+     *
+     * @internal
+     */
+    protected string $configFileName = 'config.yaml';
+
+    /**
+     * YAML file name with all settings.
+     *
+     * @internal
+     * @todo remove, move usages to SiteSettingsFactory
+     */
+    protected string $settingsFileName = 'settings.yaml';
+
+    /**
+     * Identifier to store all configuration data in the core cache.
+     *
+     * @internal
+     */
+    protected string $cacheIdentifier = 'sites-configuration';
+
+    public function __construct(
+        protected string $configPath,
+        protected EventDispatcherInterface $eventDispatcher,
+        protected PhpFrontend $cache
+    ) {}
+
+    /**
+     * Creates a site configuration with one language "English" which is the de-facto default language for TYPO3 in general.
+     *
+     * @throws SiteConfigurationWriteException
+     */
+    public function createNewBasicSite(string $identifier, int $rootPageId, string $base): void
+    {
+        // Create a default site configuration called "main" as best practice
+        $this->write($identifier, [
+            'rootPageId' => $rootPageId,
+            'base' => $base,
+            'languages' => [
+                0 => [
+                    'title' => 'English',
+                    'enabled' => true,
+                    'languageId' => 0,
+                    'base' => '/',
+                    'locale' => 'en_US.UTF-8',
+                    'navigationTitle' => 'English',
+                    'flag' => 'us',
+                ],
+            ],
+            'errorHandling' => [],
+            'routes' => [],
+        ]);
+    }
+
+    public function writeSettings(string $siteIdentifier, array $settings): void
+    {
+        $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName;
+        $yamlFileContents = Yaml::dump($settings, 99, 2);
+        if (!GeneralUtility::writeFile($fileName, $yamlFileContents)) {
+            throw new SiteConfigurationWriteException('Unable to write site settings in sites/' . $siteIdentifier . '/' . $this->configFileName, 1590487411);
+        }
+    }
+
+    /**
+     * Add or update a site configuration
+     *
+     * @param bool $protectPlaceholders whether to disallow introducing new placeholders
+     * @todo enforce $protectPlaceholders with TYPO3 v13.0
+     * @throws SiteConfigurationWriteException
+     */
+    public function write(string $siteIdentifier, array $configuration, bool $protectPlaceholders = false): void
+    {
+        $folder = $this->configPath . '/' . $siteIdentifier;
+        $fileName = $folder . '/' . $this->configFileName;
+        $newConfiguration = $configuration;
+        if (!file_exists($folder)) {
+            GeneralUtility::mkdir_deep($folder);
+            if ($protectPlaceholders && $newConfiguration !== []) {
+                $newConfiguration = $this->protectPlaceholders([], $newConfiguration);
+            }
+        } elseif (file_exists($fileName)) {
+            $loader = GeneralUtility::makeInstance(YamlFileLoader::class);
+            // load without any processing to have the unprocessed base to modify
+            $newConfiguration = $loader->load(GeneralUtility::fixWindowsFilePath($fileName), 0);
+            // load the processed configuration to diff changed values
+            $processed = $loader->load(GeneralUtility::fixWindowsFilePath($fileName));
+            // find properties that were modified via GUI
+            $newModified = array_replace_recursive(
+                self::findRemoved($processed, $configuration),
+                self::findModified($processed, $configuration)
+            );
+            if ($protectPlaceholders && $newModified !== []) {
+                $newModified = $this->protectPlaceholders($newConfiguration, $newModified);
+            }
+            // change _only_ the modified keys, leave the original non-changed areas alone
+            ArrayUtility::mergeRecursiveWithOverrule($newConfiguration, $newModified);
+        }
+        $event = $this->eventDispatcher->dispatch(new SiteConfigurationBeforeWriteEvent($siteIdentifier, $newConfiguration));
+        $newConfiguration = $this->sortConfiguration($event->getConfiguration());
+        $yamlFileContents = Yaml::dump($newConfiguration, 99, 2);
+        if (!GeneralUtility::writeFile($fileName, $yamlFileContents)) {
+            throw new SiteConfigurationWriteException('Unable to write site configuration in sites/' . $siteIdentifier . '/' . $this->configFileName, 1590487011);
+        }
+        $this->cache->remove($this->cacheIdentifier);
+        $this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($siteIdentifier));
+    }
+
+    /**
+     * Renames a site identifier (and moves the folder)
+     *
+     * @throws SiteConfigurationWriteException
+     */
+    public function rename(string $currentIdentifier, string $newIdentifier): void
+    {
+        if (!rename($this->configPath . '/' . $currentIdentifier, $this->configPath . '/' . $newIdentifier)) {
+            throw new SiteConfigurationWriteException('Unable to rename folder sites/' . $currentIdentifier, 1522491300);
+        }
+        $this->cache->remove($this->cacheIdentifier);
+        $this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($newIdentifier));
+    }
+
+    /**
+     * Removes the config.yaml file of a site configuration.
+     * Also clears the cache.
+     *
+     * @throws SiteNotFoundException|SiteConfigurationWriteException
+     */
+    public function delete(string $siteIdentifier): void
+    {
+        $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->configFileName;
+        if (!file_exists($fileName)) {
+            throw new SiteNotFoundException('Site configuration file ' . $this->configFileName . ' within the site ' . $siteIdentifier . ' not found.', 1522866184);
+        }
+        if (!unlink($fileName)) {
+            throw new SiteConfigurationWriteException('Unable to delete folder sites/' . $siteIdentifier, 1596462020);
+        }
+        $this->cache->remove($this->cacheIdentifier);
+        $this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($siteIdentifier));
+    }
+
+    /**
+     * Detects placeholders that have been introduced and handles* them.
+     * (*) currently throws an exception, but could be purged or escaped as well
+     *
+     * @param array<string, mixed> $existingConfiguration
+     * @param array<string, mixed> $modifiedConfiguration
+     * @return array<string, mixed> sanitized configuration (currently not used, exception thrown before)
+     * @throws SiteConfigurationWriteException
+     */
+    protected function protectPlaceholders(array $existingConfiguration, array $modifiedConfiguration): array
+    {
+        try {
+            return GeneralUtility::makeInstance(YamlPlaceholderGuard::class, $existingConfiguration)
+                ->process($modifiedConfiguration);
+        } catch (YamlPlaceholderException $exception) {
+            throw new SiteConfigurationWriteException($exception->getMessage(), 1670361271, $exception);
+        }
+    }
+
+    protected function sortConfiguration(array $newConfiguration): array
+    {
+        ksort($newConfiguration);
+        if (isset($newConfiguration['imports'])) {
+            $imports = $newConfiguration['imports'];
+            unset($newConfiguration['imports']);
+            $newConfiguration['imports'] = $imports;
+        }
+        return $newConfiguration;
+    }
+
+    protected static function findModified(array $currentConfiguration, array $newConfiguration): array
+    {
+        $differences = [];
+        foreach ($newConfiguration as $key => $value) {
+            if (!isset($currentConfiguration[$key]) || $currentConfiguration[$key] !== $newConfiguration[$key]) {
+                if (!isset($newConfiguration[$key]) && isset($currentConfiguration[$key])) {
+                    $differences[$key] = '__UNSET';
+                } elseif (isset($currentConfiguration[$key])
+                    && is_array($newConfiguration[$key])
+                    && is_array($currentConfiguration[$key])
+                ) {
+                    $differences[$key] = self::findModified($currentConfiguration[$key], $newConfiguration[$key]);
+                } else {
+                    $differences[$key] = $value;
+                }
+            }
+        }
+        return $differences;
+    }
+
+    protected static function findRemoved(array $currentConfiguration, array $newConfiguration): array
+    {
+        $removed = [];
+        foreach ($currentConfiguration as $key => $value) {
+            if (!isset($newConfiguration[$key])) {
+                $removed[$key] = '__UNSET';
+            } elseif (isset($currentConfiguration[$key]) && is_array($currentConfiguration[$key]) && is_array($newConfiguration[$key])) {
+                $removedInRecursion = self::findRemoved($currentConfiguration[$key], $newConfiguration[$key]);
+                if (!empty($removedInRecursion)) {
+                    $removed[$key] = $removedInRecursion;
+                }
+            }
+        }
+
+        return $removed;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Hooks/CreateSiteConfiguration.php b/typo3/sysext/core/Classes/Hooks/CreateSiteConfiguration.php
index 882eb61b965b..aaa137d96105 100644
--- a/typo3/sysext/core/Classes/Hooks/CreateSiteConfiguration.php
+++ b/typo3/sysext/core/Classes/Hooks/CreateSiteConfiguration.php
@@ -19,7 +19,7 @@ namespace TYPO3\CMS\Core\Hooks;
 
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\DataHandling\DataHandler;
 use TYPO3\CMS\Core\Domain\Repository\PageRepository;
@@ -87,11 +87,11 @@ class CreateSiteConfiguration
         $siteIdentifier = $entryPoint . '-' . md5((string)$pageId);
 
         if (!$this->siteExistsByRootPageId($pageId)) {
-            $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
+            $siteWriter = GeneralUtility::makeInstance(SiteWriter::class);
             $normalizedParams = $this->getNormalizedParams();
             $basePrefix = Environment::isCli() ? $normalizedParams->getSitePath() : $normalizedParams->getSiteUrl();
             try {
-                $siteConfiguration->createNewBasicSite(
+                $siteWriter->createNewBasicSite(
                     $siteIdentifier,
                     $pageId,
                     $basePrefix . $entryPoint
diff --git a/typo3/sysext/core/Classes/ServiceProvider.php b/typo3/sysext/core/Classes/ServiceProvider.php
index 44cf4125dca3..e24c81bcf790 100644
--- a/typo3/sysext/core/Classes/ServiceProvider.php
+++ b/typo3/sysext/core/Classes/ServiceProvider.php
@@ -59,7 +59,7 @@ class ServiceProvider extends AbstractServiceProvider
             Database\DriverMiddlewareService::class => self::getDriverMiddlewaresService(...),
             Charset\CharsetConverter::class => self::getCharsetConverter(...),
             Configuration\Loader\YamlFileLoader::class => self::getYamlFileLoader(...),
-            Configuration\SiteConfiguration::class => self::getSiteConfiguration(...),
+            Configuration\SiteWriter::class => self::getSiteWriter(...),
             Command\ListCommand::class => self::getListCommand(...),
             HelpCommand::class => self::getHelpCommand(...),
             Command\CacheFlushCommand::class => self::getCacheFlushCommand(...),
@@ -184,9 +184,9 @@ class ServiceProvider extends AbstractServiceProvider
         return self::new($container, Configuration\Loader\YamlFileLoader::class);
     }
 
-    public static function getSiteConfiguration(ContainerInterface $container): Configuration\SiteConfiguration
+    public static function getSiteWriter(ContainerInterface $container): Configuration\SiteWriter
     {
-        return self::new($container, Configuration\SiteConfiguration::class, [
+        return self::new($container, Configuration\SiteWriter::class, [
             Environment::getConfigPath() . '/sites',
             $container->get(EventDispatcherInterface::class),
             $container->get('cache.core'),
@@ -269,7 +269,6 @@ class ServiceProvider extends AbstractServiceProvider
         );
 
         $cacheWarmers = [
-            Configuration\SiteConfiguration::class,
             Http\MiddlewareStackResolver::class,
             Imaging\IconRegistry::class,
             Package\PackageManager::class,
diff --git a/typo3/sysext/core/Configuration/Services.yaml b/typo3/sysext/core/Configuration/Services.yaml
index 138be5a33462..55091672a1bc 100644
--- a/typo3/sysext/core/Configuration/Services.yaml
+++ b/typo3/sysext/core/Configuration/Services.yaml
@@ -56,11 +56,6 @@ services:
   TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools:
     public: true
 
-  TYPO3\CMS\Core\Configuration\SiteConfiguration:
-    arguments:
-      $cache: '@cache.core'
-      $configPath: '%env(TYPO3:configPath)%/sites'
-
   TYPO3\CMS\Core\Package\UnitTestPackageManager:
     autoconfigure: false
 
diff --git a/typo3/sysext/core/Tests/Functional/Routing/Aspect/PersistedAliasMapperTest.php b/typo3/sysext/core/Tests/Functional/Routing/Aspect/PersistedAliasMapperTest.php
index bc8f79d14366..568cec75a7da 100644
--- a/typo3/sysext/core/Tests/Functional/Routing/Aspect/PersistedAliasMapperTest.php
+++ b/typo3/sysext/core/Tests/Functional/Routing/Aspect/PersistedAliasMapperTest.php
@@ -20,7 +20,7 @@ namespace TYPO3\CMS\Core\Tests\Functional\Routing\Aspect;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Test;
 use Psr\EventDispatcher\EventDispatcherInterface;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Context\DateTimeAspect;
 use TYPO3\CMS\Core\Context\UserAspect;
@@ -309,7 +309,7 @@ final class PersistedAliasMapperTest extends FunctionalTestCase
             $cache = $this->get('cache.core');
             $eventDispatcher = $this->get(EventDispatcherInterface::class);
             GeneralUtility::rmdir($path . '/' . $site->getIdentifier(), true);
-            GeneralUtility::makeInstance(SiteConfiguration::class, $path, $eventDispatcher, $cache)->write($site->getIdentifier(), $site->getConfiguration());
+            GeneralUtility::makeInstance(SiteWriter::class, $path, $eventDispatcher, $cache)->write($site->getIdentifier(), $site->getConfiguration());
         } catch (\Exception $exception) {
             self::markTestSkipped($exception->getMessage());
         }
diff --git a/typo3/sysext/core/Tests/Functional/Routing/Aspect/PersistedPatternMapperTest.php b/typo3/sysext/core/Tests/Functional/Routing/Aspect/PersistedPatternMapperTest.php
index 66e9169c843d..568f1bb68e32 100644
--- a/typo3/sysext/core/Tests/Functional/Routing/Aspect/PersistedPatternMapperTest.php
+++ b/typo3/sysext/core/Tests/Functional/Routing/Aspect/PersistedPatternMapperTest.php
@@ -20,7 +20,7 @@ namespace TYPO3\CMS\Core\Tests\Functional\Routing\Aspect;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Test;
 use Psr\EventDispatcher\EventDispatcherInterface;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Context\DateTimeAspect;
 use TYPO3\CMS\Core\Context\UserAspect;
@@ -317,7 +317,7 @@ final class PersistedPatternMapperTest extends FunctionalTestCase
             $cache = $this->get('cache.core');
             $eventDispatcher = $this->get(EventDispatcherInterface::class);
             GeneralUtility::rmdir($path . '/' . $site->getIdentifier(), true);
-            GeneralUtility::makeInstance(SiteConfiguration::class, $path, $eventDispatcher, $cache)->write($site->getIdentifier(), $site->getConfiguration());
+            GeneralUtility::makeInstance(SiteWriter::class, $path, $eventDispatcher, $cache)->write($site->getIdentifier(), $site->getConfiguration());
         } catch (\Exception $exception) {
             self::markTestSkipped($exception->getMessage());
         }
diff --git a/typo3/sysext/core/Tests/Functional/SiteHandling/SiteBasedTestTrait.php b/typo3/sysext/core/Tests/Functional/SiteHandling/SiteBasedTestTrait.php
index 0b8c55a1fb0f..81a6ab09e5a3 100644
--- a/typo3/sysext/core/Tests/Functional/SiteHandling/SiteBasedTestTrait.php
+++ b/typo3/sysext/core/Tests/Functional/SiteHandling/SiteBasedTestTrait.php
@@ -18,6 +18,7 @@ declare(strict_types=1);
 namespace TYPO3\CMS\Core\Tests\Functional\SiteHandling;
 
 use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Tests\Functional\Fixtures\Frontend\PhpError;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Internal\AbstractInstruction;
@@ -59,11 +60,11 @@ trait SiteBasedTestTrait
         if (!empty($errorHandling)) {
             $configuration['errorHandling'] = $errorHandling;
         }
-        $siteConfiguration = $this->get(SiteConfiguration::class);
+        $siteWriter = $this->get(SiteWriter::class);
         try {
             // ensure no previous site configuration influences the test
             GeneralUtility::rmdir($this->instancePath . '/typo3conf/sites/' . $identifier, true);
-            $siteConfiguration->write($identifier, $configuration);
+            $siteWriter->write($identifier, $configuration);
         } catch (\Exception $exception) {
             $this->markTestSkipped($exception->getMessage());
         }
@@ -74,10 +75,11 @@ trait SiteBasedTestTrait
         array $overrides
     ): void {
         $siteConfiguration = $this->get(SiteConfiguration::class);
+        $siteWriter = $this->get(SiteWriter::class);
         $configuration = $siteConfiguration->load($identifier);
         $configuration = array_merge($configuration, $overrides);
         try {
-            $siteConfiguration->write($identifier, $configuration);
+            $siteWriter->write($identifier, $configuration);
         } catch (\Exception $exception) {
             $this->markTestSkipped($exception->getMessage());
         }
diff --git a/typo3/sysext/core/Tests/Unit/Configuration/SiteConfigurationTest.php b/typo3/sysext/core/Tests/Unit/Configuration/SiteConfigurationTest.php
index eb00bcc3cd0b..c02f46f10b81 100644
--- a/typo3/sysext/core/Tests/Unit/Configuration/SiteConfigurationTest.php
+++ b/typo3/sysext/core/Tests/Unit/Configuration/SiteConfigurationTest.php
@@ -17,12 +17,9 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Core\Tests\Unit\Configuration;
 
-use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Test;
 use Symfony\Component\Yaml\Yaml;
 use TYPO3\CMS\Core\Cache\Frontend\NullFrontend;
-use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
-use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
 use TYPO3\CMS\Core\Configuration\SiteConfiguration;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\EventDispatcher\NoopEventDispatcher;
@@ -80,110 +77,4 @@ final class SiteConfigurationTest extends UnitTestCase
         self::assertSame(42, $currentSite->getRootPageId());
         self::assertEquals(new Uri('https://example.com'), $currentSite->getBase());
     }
-
-    #[Test]
-    public function writeOnlyWritesModifiedKeys(): void
-    {
-        $identifier = 'testsite';
-        GeneralUtility::mkdir_deep($this->fixturePath . '/' . $identifier);
-        $configFixture = __DIR__ . '/Fixtures/SiteConfigs/config1.yaml';
-        $expected = __DIR__ . '/Fixtures/SiteConfigs/config1_expected.yaml';
-        $siteConfig = $this->fixturePath . '/' . $identifier . '/config.yaml';
-        copy($configFixture, $siteConfig);
-
-        // load with resolved imports as the module does
-        $configuration = GeneralUtility::makeInstance(YamlFileLoader::class)
-            ->load(
-                GeneralUtility::fixWindowsFilePath($siteConfig),
-                YamlFileLoader::PROCESS_IMPORTS
-            );
-        // modify something on base level
-        $configuration['base'] = 'https://example.net/';
-        // modify something nested
-        $configuration['languages'][0]['title'] = 'English';
-        // delete values
-        unset($configuration['someOtherValue'], $configuration['languages'][1]);
-
-        $this->siteConfiguration->write($identifier, $configuration, true);
-
-        // expect modified base but intact imports
-        self::assertFileEquals($expected, $siteConfig);
-    }
-
-    #[Test]
-    public function writingOfNestedStructuresPreservesOrder(): void
-    {
-        $identifier = 'testsite';
-        GeneralUtility::mkdir_deep($this->fixturePath . '/' . $identifier);
-        $configFixture = __DIR__ . '/Fixtures/SiteConfigs/config2.yaml';
-        $expected = __DIR__ . '/Fixtures/SiteConfigs/config2_expected.yaml';
-        $siteConfig = $this->fixturePath . '/' . $identifier . '/config.yaml';
-        copy($configFixture, $siteConfig);
-
-        // load with resolved imports as the module does
-        $configuration = GeneralUtility::makeInstance(YamlFileLoader::class)
-            ->load(
-                GeneralUtility::fixWindowsFilePath($siteConfig),
-                YamlFileLoader::PROCESS_IMPORTS
-            );
-        // add new language
-        $languageConfig = [
-            'title' => 'English',
-            'enabled' => true,
-            'languageId' => '0',
-            'base' => '/en',
-            'locale' => 'en_US.utf8',
-            'flag' => 'en',
-            'navigationTitle' => 'English',
-        ];
-        array_unshift($configuration['languages'], $languageConfig);
-        $this->siteConfiguration->write($identifier, $configuration, true);
-
-        // expect modified base but intact imports
-        self::assertFileEquals($expected, $siteConfig);
-    }
-
-    public static function writingPlaceholdersIsHandledDataProvider(): \Generator
-    {
-        yield 'unchanged' => [
-            ['customProperty' => 'Using %env("existing")% variable'],
-            false,
-        ];
-        yield 'removed placeholder variable' => [
-            ['customProperty' => 'Not using any variable'],
-            false,
-        ];
-        yield 'changed raw text only' => [
-            ['customProperty' => 'Using %env("existing")% variable from system environment'],
-            false,
-        ];
-        yield 'added new placeholder variable' => [
-            ['customProperty' => 'Using %env("existing")% and %env("secret")% variable'],
-            true,
-        ];
-    }
-
-    #[DataProvider('writingPlaceholdersIsHandledDataProvider')]
-    #[Test]
-    public function writingPlaceholdersIsHandled(array $changes, bool $expectedException): void
-    {
-        if ($expectedException) {
-            $this->expectException(SiteConfigurationWriteException::class);
-            $this->expectExceptionCode(1670361271);
-        }
-
-        $identifier = 'testsite';
-        GeneralUtility::mkdir_deep($this->fixturePath . '/' . $identifier);
-        $configFixture = __DIR__ . '/Fixtures/SiteConfigs/config2.yaml';
-        $siteConfig = $this->fixturePath . '/' . $identifier . '/config.yaml';
-        copy($configFixture, $siteConfig);
-        // load with resolved imports as the module does
-        $configuration = GeneralUtility::makeInstance(YamlFileLoader::class)
-            ->load(
-                GeneralUtility::fixWindowsFilePath($siteConfig),
-                YamlFileLoader::PROCESS_IMPORTS
-            );
-        $configuration = array_merge($configuration, $changes);
-        $this->siteConfiguration->write($identifier, $configuration, true);
-    }
 }
diff --git a/typo3/sysext/core/Tests/Unit/Configuration/SiteWriterTest.php b/typo3/sysext/core/Tests/Unit/Configuration/SiteWriterTest.php
new file mode 100644
index 000000000000..df8cae6f2f20
--- /dev/null
+++ b/typo3/sysext/core/Tests/Unit/Configuration/SiteWriterTest.php
@@ -0,0 +1,164 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Tests\Unit\Configuration;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use TYPO3\CMS\Core\Cache\Frontend\NullFrontend;
+use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
+use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
+use TYPO3\CMS\Core\Core\Environment;
+use TYPO3\CMS\Core\EventDispatcher\NoopEventDispatcher;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+final class SiteWriterTest extends UnitTestCase
+{
+    protected bool $resetSingletonInstances = true;
+
+    protected ?SiteWriter $siteWriter;
+
+    /**
+     * store temporarily used files here
+     * will be removed after each test
+     */
+    protected ?string $fixturePath;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $basePath = Environment::getVarPath() . '/tests/unit';
+        $this->fixturePath = $basePath . '/fixture/config/sites';
+        if (!file_exists($this->fixturePath)) {
+            GeneralUtility::mkdir_deep($this->fixturePath);
+        }
+        $this->testFilesToDelete[] = $basePath;
+        $this->siteWriter = new SiteWriter(
+            $this->fixturePath,
+            new NoopEventDispatcher(),
+            new NullFrontend('test')
+        );
+    }
+
+    #[Test]
+    public function writeOnlyWritesModifiedKeys(): void
+    {
+        $identifier = 'testsite';
+        GeneralUtility::mkdir_deep($this->fixturePath . '/' . $identifier);
+        $configFixture = __DIR__ . '/Fixtures/SiteConfigs/config1.yaml';
+        $expected = __DIR__ . '/Fixtures/SiteConfigs/config1_expected.yaml';
+        $siteConfig = $this->fixturePath . '/' . $identifier . '/config.yaml';
+        copy($configFixture, $siteConfig);
+
+        // load with resolved imports as the module does
+        $configuration = GeneralUtility::makeInstance(YamlFileLoader::class)
+            ->load(
+                GeneralUtility::fixWindowsFilePath($siteConfig),
+                YamlFileLoader::PROCESS_IMPORTS
+            );
+        // modify something on base level
+        $configuration['base'] = 'https://example.net/';
+        // modify something nested
+        $configuration['languages'][0]['title'] = 'English';
+        // delete values
+        unset($configuration['someOtherValue'], $configuration['languages'][1]);
+
+        $this->siteWriter->write($identifier, $configuration, true);
+
+        // expect modified base but intact imports
+        self::assertFileEquals($expected, $siteConfig);
+    }
+
+    #[Test]
+    public function writingOfNestedStructuresPreservesOrder(): void
+    {
+        $identifier = 'testsite';
+        GeneralUtility::mkdir_deep($this->fixturePath . '/' . $identifier);
+        $configFixture = __DIR__ . '/Fixtures/SiteConfigs/config2.yaml';
+        $expected = __DIR__ . '/Fixtures/SiteConfigs/config2_expected.yaml';
+        $siteConfig = $this->fixturePath . '/' . $identifier . '/config.yaml';
+        copy($configFixture, $siteConfig);
+
+        // load with resolved imports as the module does
+        $configuration = GeneralUtility::makeInstance(YamlFileLoader::class)
+            ->load(
+                GeneralUtility::fixWindowsFilePath($siteConfig),
+                YamlFileLoader::PROCESS_IMPORTS
+            );
+        // add new language
+        $languageConfig = [
+            'title' => 'English',
+            'enabled' => true,
+            'languageId' => '0',
+            'base' => '/en',
+            'locale' => 'en_US.utf8',
+            'flag' => 'en',
+            'navigationTitle' => 'English',
+        ];
+        array_unshift($configuration['languages'], $languageConfig);
+        $this->siteWriter->write($identifier, $configuration, true);
+
+        // expect modified base but intact imports
+        self::assertFileEquals($expected, $siteConfig);
+    }
+
+    public static function writingPlaceholdersIsHandledDataProvider(): \Generator
+    {
+        yield 'unchanged' => [
+            ['customProperty' => 'Using %env("existing")% variable'],
+            false,
+        ];
+        yield 'removed placeholder variable' => [
+            ['customProperty' => 'Not using any variable'],
+            false,
+        ];
+        yield 'changed raw text only' => [
+            ['customProperty' => 'Using %env("existing")% variable from system environment'],
+            false,
+        ];
+        yield 'added new placeholder variable' => [
+            ['customProperty' => 'Using %env("existing")% and %env("secret")% variable'],
+            true,
+        ];
+    }
+
+    #[DataProvider('writingPlaceholdersIsHandledDataProvider')]
+    #[Test]
+    public function writingPlaceholdersIsHandled(array $changes, bool $expectedException): void
+    {
+        if ($expectedException) {
+            $this->expectException(SiteConfigurationWriteException::class);
+            $this->expectExceptionCode(1670361271);
+        }
+
+        $identifier = 'testsite';
+        GeneralUtility::mkdir_deep($this->fixturePath . '/' . $identifier);
+        $configFixture = __DIR__ . '/Fixtures/SiteConfigs/config2.yaml';
+        $siteConfig = $this->fixturePath . '/' . $identifier . '/config.yaml';
+        copy($configFixture, $siteConfig);
+        // load with resolved imports as the module does
+        $configuration = GeneralUtility::makeInstance(YamlFileLoader::class)
+            ->load(
+                GeneralUtility::fixWindowsFilePath($siteConfig),
+                YamlFileLoader::PROCESS_IMPORTS
+            );
+        $configuration = array_merge($configuration, $changes);
+        $this->siteWriter->write($identifier, $configuration, true);
+    }
+}
diff --git a/typo3/sysext/impexp/Classes/Initialization/ImportSiteConfigurationsOnPackageInitialization.php b/typo3/sysext/impexp/Classes/Initialization/ImportSiteConfigurationsOnPackageInitialization.php
index 73854190c009..50142bbcec27 100644
--- a/typo3/sysext/impexp/Classes/Initialization/ImportSiteConfigurationsOnPackageInitialization.php
+++ b/typo3/sysext/impexp/Classes/Initialization/ImportSiteConfigurationsOnPackageInitialization.php
@@ -23,6 +23,7 @@ use Symfony\Component\Finder\Finder;
 use TYPO3\CMS\Core\Attribute\AsEventListener;
 use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
 use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Package\Event\PackageInitializationEvent;
 use TYPO3\CMS\Core\Registry;
@@ -39,6 +40,7 @@ final class ImportSiteConfigurationsOnPackageInitialization implements LoggerAwa
     public function __construct(
         private readonly Registry $registry,
         private readonly SiteConfiguration $siteConfiguration,
+        private readonly SiteWriter $siteWriter,
     ) {}
 
     #[AsEventListener(after: ImportContentOnPackageInitialization::class)]
@@ -98,7 +100,7 @@ final class ImportSiteConfigurationsOnPackageInitialization implements LoggerAwa
             $configuration = $this->siteConfiguration->load($siteIdentifier);
             $configuration['rootPageId'] = $importedPageId;
             try {
-                $this->siteConfiguration->write($siteIdentifier, $configuration);
+                $this->siteWriter->write($siteIdentifier, $configuration);
             } catch (SiteConfigurationWriteException $e) {
                 $this->logger->warning(
                     sprintf(
diff --git a/typo3/sysext/install/Classes/Service/SetupService.php b/typo3/sysext/install/Classes/Service/SetupService.php
index 72d64aacf70b..40577f466d46 100644
--- a/typo3/sysext/install/Classes/Service/SetupService.php
+++ b/typo3/sysext/install/Classes/Service/SetupService.php
@@ -20,7 +20,7 @@ namespace TYPO3\CMS\Install\Service;
 use TYPO3\CMS\Core\Configuration\ConfigurationManager;
 use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
 use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2idPasswordHash;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash;
@@ -42,7 +42,7 @@ readonly class SetupService
 {
     public function __construct(
         private ConfigurationManager $configurationManager,
-        private SiteConfiguration $siteConfiguration,
+        private SiteWriter $siteWriter,
         private YamlFileLoader $yamlFileLoader,
     ) {}
 
@@ -58,7 +58,7 @@ readonly class SetupService
     public function createSiteConfiguration(string $identifier, int $rootPageId, string $siteUrl): void
     {
         // Create a default site configuration called "main" as best practice
-        $this->siteConfiguration->createNewBasicSite($identifier, $rootPageId, $siteUrl);
+        $this->siteWriter->createNewBasicSite($identifier, $rootPageId, $siteUrl);
     }
 
     /**
diff --git a/typo3/sysext/install/Classes/ServiceProvider.php b/typo3/sysext/install/Classes/ServiceProvider.php
index 13e7f2783f1a..45acdc612da6 100644
--- a/typo3/sysext/install/Classes/ServiceProvider.php
+++ b/typo3/sysext/install/Classes/ServiceProvider.php
@@ -21,7 +21,7 @@ use Psr\Container\ContainerInterface;
 use Psr\EventDispatcher\EventDispatcherInterface;
 use TYPO3\CMS\Core\Configuration\ConfigurationManager;
 use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Console\CommandRegistry;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Crypto\HashService;
@@ -236,7 +236,7 @@ class ServiceProvider extends AbstractServiceProvider
     {
         return new Service\SetupService(
             $container->get(ConfigurationManager::class),
-            $container->get(SiteConfiguration::class),
+            $container->get(SiteWriter::class),
             $container->get(YamlFileLoader::class)
         );
     }
diff --git a/typo3/sysext/install/Classes/Updates/MigrateSiteSettingsConfigUpdate.php b/typo3/sysext/install/Classes/Updates/MigrateSiteSettingsConfigUpdate.php
index 861386c787e8..f0162e605b66 100644
--- a/typo3/sysext/install/Classes/Updates/MigrateSiteSettingsConfigUpdate.php
+++ b/typo3/sysext/install/Classes/Updates/MigrateSiteSettingsConfigUpdate.php
@@ -19,6 +19,7 @@ namespace TYPO3\CMS\Install\Updates;
 
 use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
 use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Install\Attribute\UpgradeWizard;
 
@@ -34,11 +35,13 @@ class MigrateSiteSettingsConfigUpdate implements UpgradeWizardInterface
     protected const SETTINGS_FILENAME = 'settings.yaml';
 
     protected ?SiteConfiguration $siteConfiguration = null;
+    protected ?SiteWriter $siteWriter = null;
     protected array $sitePathsToMigrate = [];
 
     public function __construct()
     {
         $this->siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
+        $this->siteWriter = GeneralUtility::makeInstance(SiteWriter::class);
         $this->sitePathsToMigrate = $this->getSitePathsToMigrate();
     }
 
@@ -58,7 +61,7 @@ class MigrateSiteSettingsConfigUpdate implements UpgradeWizardInterface
     {
         try {
             foreach ($this->sitePathsToMigrate as $siteIdentifier => $settings) {
-                $this->siteConfiguration->writeSettings($siteIdentifier, $settings);
+                $this->siteWriter->writeSettings($siteIdentifier, $settings);
             }
         } catch (SiteConfigurationWriteException $e) {
             return false;
diff --git a/typo3/sysext/install/Tests/Functional/Updates/MigrateSiteSettingsConfigUpdateTest.php b/typo3/sysext/install/Tests/Functional/Updates/MigrateSiteSettingsConfigUpdateTest.php
index 76e4ddba4b28..96fb9cec1cbf 100644
--- a/typo3/sysext/install/Tests/Functional/Updates/MigrateSiteSettingsConfigUpdateTest.php
+++ b/typo3/sysext/install/Tests/Functional/Updates/MigrateSiteSettingsConfigUpdateTest.php
@@ -18,9 +18,8 @@ declare(strict_types=1);
 namespace TYPO3\CMS\Install\Tests\Functional\Updates;
 
 use PHPUnit\Framework\Attributes\Test;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Core\Environment;
-use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Install\Updates\MigrateSiteSettingsConfigUpdate;
 use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
 
@@ -31,7 +30,7 @@ final class MigrateSiteSettingsConfigUpdateTest extends FunctionalTestCase
     {
         $siteconfigurationIdentifier = 'settings';
 
-        GeneralUtility::makeInstance(SiteConfiguration::class)->write(
+        $this->get(SiteWriter::class)->write(
             $siteconfigurationIdentifier,
             [
                 'rootPageId' => 1,
@@ -66,7 +65,7 @@ final class MigrateSiteSettingsConfigUpdateTest extends FunctionalTestCase
     {
         $siteconfigurationIdentifier = 'withoutSettings';
 
-        GeneralUtility::makeInstance(SiteConfiguration::class)->write(
+        $this->get(SiteWriter::class)->write(
             $siteconfigurationIdentifier,
             [
                 'rootPageId' => 2,
diff --git a/typo3/sysext/redirects/Tests/Functional/EventListener/AddPageTypeZeroSourceTest.php b/typo3/sysext/redirects/Tests/Functional/EventListener/AddPageTypeZeroSourceTest.php
index c6add9614f44..80092641180e 100644
--- a/typo3/sysext/redirects/Tests/Functional/EventListener/AddPageTypeZeroSourceTest.php
+++ b/typo3/sysext/redirects/Tests/Functional/EventListener/AddPageTypeZeroSourceTest.php
@@ -20,7 +20,7 @@ namespace TYPO3\CMS\Redirects\Tests\Functional\EventListener;
 use PHPUnit\Framework\Attributes\Test;
 use Psr\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\DependencyInjection\Container;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
 use TYPO3\CMS\Core\Routing\InvalidRouteArgumentsException;
@@ -250,7 +250,7 @@ final class AddPageTypeZeroSourceTest extends FunctionalTestCase
 
     protected function buildSite(array $configuration): void
     {
-        $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
-        $siteConfiguration->write('testing', $configuration);
+        $siteWriter = $this->get(SiteWriter::class);
+        $siteWriter->write('testing', $configuration);
     }
 }
diff --git a/typo3/sysext/redirects/Tests/Functional/EventListener/AddPlainSlugReplacementSourceTest.php b/typo3/sysext/redirects/Tests/Functional/EventListener/AddPlainSlugReplacementSourceTest.php
index 090b4ae1b18a..a56bcffc591d 100644
--- a/typo3/sysext/redirects/Tests/Functional/EventListener/AddPlainSlugReplacementSourceTest.php
+++ b/typo3/sysext/redirects/Tests/Functional/EventListener/AddPlainSlugReplacementSourceTest.php
@@ -20,7 +20,7 @@ namespace TYPO3\CMS\Redirects\Tests\Functional\EventListener;
 use PHPUnit\Framework\Attributes\Test;
 use Psr\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\DependencyInjection\Container;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent;
@@ -80,7 +80,7 @@ final class AddPlainSlugReplacementSourceTest extends FunctionalTestCase
             'base' => '/',
             'settings' => $settings,
         ];
-        $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
-        $siteConfiguration->write('testing', $configuration);
+        $siteWriter = $this->get(SiteWriter::class);
+        $siteWriter->write('testing', $configuration);
     }
 }
diff --git a/typo3/sysext/redirects/Tests/Functional/RedirectUpdate/SlugRedirectChangeItemFactoryTest.php b/typo3/sysext/redirects/Tests/Functional/RedirectUpdate/SlugRedirectChangeItemFactoryTest.php
index e66183d1e3dc..084d5674a4b6 100644
--- a/typo3/sysext/redirects/Tests/Functional/RedirectUpdate/SlugRedirectChangeItemFactoryTest.php
+++ b/typo3/sysext/redirects/Tests/Functional/RedirectUpdate/SlugRedirectChangeItemFactoryTest.php
@@ -19,9 +19,8 @@ namespace TYPO3\CMS\Redirects\Tests\Functional\RedirectUpdate;
 
 use PHPUnit\Framework\Attributes\Test;
 use Symfony\Component\DependencyInjection\Container;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
-use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Redirects\Event\SlugRedirectChangeItemCreatedEvent;
 use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItem;
 use TYPO3\CMS\Redirects\RedirectUpdate\SlugRedirectChangeItemFactory;
@@ -138,7 +137,7 @@ final class SlugRedirectChangeItemFactoryTest extends FunctionalTestCase
             'base' => '/',
             'settings' => $settings,
         ];
-        $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
-        $siteConfiguration->write('testing', $configuration);
+        $siteWriter = $this->get(SiteWriter::class);
+        $siteWriter->write('testing', $configuration);
     }
 }
diff --git a/typo3/sysext/redirects/Tests/Functional/Service/SlugServiceTest.php b/typo3/sysext/redirects/Tests/Functional/Service/SlugServiceTest.php
index 160cfb46c0a3..38b8947cc511 100644
--- a/typo3/sysext/redirects/Tests/Functional/Service/SlugServiceTest.php
+++ b/typo3/sysext/redirects/Tests/Functional/Service/SlugServiceTest.php
@@ -21,7 +21,7 @@ use PHPUnit\Framework\Attributes\Test;
 use Psr\EventDispatcher\EventDispatcherInterface;
 use Psr\Log\NullLogger;
 use Symfony\Component\DependencyInjection\Container;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
 use TYPO3\CMS\Core\Domain\Repository\PageRepository;
@@ -563,8 +563,8 @@ final class SlugServiceTest extends FunctionalTestCase
             'rootPageId' => 1,
             'base' => '/',
         ];
-        $siteConfiguration = $this->get(SiteConfiguration::class);
-        $siteConfiguration->write('testing', $configuration);
+        $siteWriter = $this->get(SiteWriter::class);
+        $siteWriter->write('testing', $configuration);
     }
 
     protected function buildBaseSiteInSubfolder(): void
@@ -573,8 +573,8 @@ final class SlugServiceTest extends FunctionalTestCase
             'rootPageId' => 1,
             'base' => '/sub-folder',
         ];
-        $siteConfiguration = $this->get(SiteConfiguration::class);
-        $siteConfiguration->write('testing', $configuration);
+        $siteWriter = $this->get(SiteWriter::class);
+        $siteWriter->write('testing', $configuration);
     }
 
     protected function buildBaseSiteWithLanguages(): void
@@ -584,8 +584,8 @@ final class SlugServiceTest extends FunctionalTestCase
             'base' => '/',
             'languages' => $this->languages,
         ];
-        $siteConfiguration = $this->get(SiteConfiguration::class);
-        $siteConfiguration->write('testing', $configuration);
+        $siteWriter = $this->get(SiteWriter::class);
+        $siteWriter->write('testing', $configuration);
     }
 
     protected function buildBaseSiteWithLanguagesInSubFolder(): void
@@ -603,8 +603,8 @@ final class SlugServiceTest extends FunctionalTestCase
             'base' => '/sub-folder',
             'languages' => $languages,
         ];
-        $siteConfiguration = $this->get(SiteConfiguration::class);
-        $siteConfiguration->write('testing', $configuration);
+        $siteWriter = $this->get(SiteWriter::class);
+        $siteWriter->write('testing', $configuration);
     }
 
     protected function createSubject(): void
diff --git a/typo3/sysext/styleguide/Classes/TcaDataGenerator/AbstractGenerator.php b/typo3/sysext/styleguide/Classes/TcaDataGenerator/AbstractGenerator.php
index d31fce1ead7d..d4f118a87c74 100644
--- a/typo3/sysext/styleguide/Classes/TcaDataGenerator/AbstractGenerator.php
+++ b/typo3/sysext/styleguide/Classes/TcaDataGenerator/AbstractGenerator.php
@@ -18,7 +18,7 @@ declare(strict_types=1);
 namespace TYPO3\CMS\Styleguide\TcaDataGenerator;
 
 use TYPO3\CMS\Backend\Utility\BackendUtility;
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Database\Connection;
 use TYPO3\CMS\Core\Database\ConnectionPool;
@@ -46,9 +46,9 @@ abstract class AbstractGenerator
         $recordFinder = GeneralUtility::makeInstance(RecordFinder::class);
         // When the DataHandler created the page tree, a default site configuration has been added. Fetch,  rename, update.
         $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByRootPageId($topPageUid);
-        $siteConfiguration = GeneralUtility::makeInstance(SiteConfiguration::class);
+        $siteWriter = GeneralUtility::makeInstance(SiteWriter::class);
         $siteIdentifier = 'styleguide-demo-' . $topPageUid;
-        $siteConfiguration->rename($site->getIdentifier(), $siteIdentifier);
+        $siteWriter->rename($site->getIdentifier(), $siteIdentifier);
         $highestLanguageId = $recordFinder->findHighestLanguageId();
         $configuration = [
             'base' => $base . 'styleguide-demo-' . $topPageUid,
@@ -138,7 +138,7 @@ abstract class AbstractGenerator
                 ],
             ],
         ];
-        $siteConfiguration->write($siteIdentifier, $configuration);
+        $siteWriter->write($siteIdentifier, $configuration);
     }
 
     /**
diff --git a/typo3/sysext/styleguide/Classes/TcaDataGenerator/Generator.php b/typo3/sysext/styleguide/Classes/TcaDataGenerator/Generator.php
index fb9c514bff25..e7c7cb46efeb 100644
--- a/typo3/sysext/styleguide/Classes/TcaDataGenerator/Generator.php
+++ b/typo3/sysext/styleguide/Classes/TcaDataGenerator/Generator.php
@@ -17,7 +17,7 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Styleguide\TcaDataGenerator;
 
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
 use TYPO3\CMS\Core\Crypto\Random;
 use TYPO3\CMS\Core\Database\ConnectionPool;
@@ -200,7 +200,7 @@ final class Generator extends AbstractGenerator
         // Delete site configuration
         if ($topUids[0] ?? false) {
             $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByRootPageId($topUids[0]);
-            GeneralUtility::makeInstance(SiteConfiguration::class)->delete($site->getIdentifier());
+            GeneralUtility::makeInstance(SiteWriter::class)->delete($site->getIdentifier());
         }
     }
 
diff --git a/typo3/sysext/styleguide/Classes/TcaDataGenerator/GeneratorFrontend.php b/typo3/sysext/styleguide/Classes/TcaDataGenerator/GeneratorFrontend.php
index 38fd0947114b..b6196d4d8bf6 100644
--- a/typo3/sysext/styleguide/Classes/TcaDataGenerator/GeneratorFrontend.php
+++ b/typo3/sysext/styleguide/Classes/TcaDataGenerator/GeneratorFrontend.php
@@ -17,7 +17,7 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Styleguide\TcaDataGenerator;
 
-use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
 use TYPO3\CMS\Core\Exception\SiteNotFoundException;
 use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -204,7 +204,7 @@ final class GeneratorFrontend extends AbstractGenerator
             if (!empty($rootUid)) {
                 $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByRootPageId((int)$rootUid[0]);
                 $identifier = $site->getIdentifier();
-                GeneralUtility::makeInstance(SiteConfiguration::class)->delete($identifier);
+                GeneralUtility::makeInstance(SiteWriter::class)->delete($identifier);
             }
         } catch (SiteNotFoundException $e) {
             // Do not throw a thing if site config does not exist
-- 
GitLab