diff --git a/composer.json b/composer.json
index 35bdcb166cbf5836a94e70f3572c65ed3c055cd4..22c0dfede2cd1b308b8bc1955126974aa2550c5e 100755
--- a/composer.json
+++ b/composer.json
@@ -39,8 +39,8 @@
     },
     "patches": {
       "drupal/core": {
-        "Issue #3397897: Add Drupal Recipes patch for Drupal core": 
-        "web/modules/contrib/webpatches/patches/core-drupal--10-2-x--2023-12-14--3397897-89ea95f4.patch",
+        "Issue #3410330: Switch the local patch to have Recipes functionality applied to Drupal core from the Distributions and Recipes project": 
+        "https://git.drupalcode.org/project/distributions_recipes/-/raw/patch/recipe-10.2.x.patch",
         "Issue #3272720: Fix PHP8.1+ Deprecated function: hash(): Passing null to parameter #2 ($data) of type string is deprecated in generateFieldTableName":
         "web/modules/contrib/webpatches/patches/core-drupal--10-2-x--2022-07-21--3272720-6.patch",
         "Issue #3326684: Fix PHP8.1+ Deprecated function: mb_strtolower(): Passing null to parameter #1 ($string) of type string is deprecated":
diff --git a/patches/core-drupal--10-2-x--2023-12-14--3397897-89ea95f4.patch b/patches/core-drupal--10-2-x--2023-12-14--3397897-89ea95f4.patch
deleted file mode 100644
index fa97d8a5f613997b9118aee25915c8a2c5cdd060..0000000000000000000000000000000000000000
--- a/patches/core-drupal--10-2-x--2023-12-14--3397897-89ea95f4.patch
+++ /dev/null
@@ -1,3983 +0,0 @@
-diff --git a/core/core.services.yml b/core/core.services.yml
-index e9d75306..581f1dff 100644
---- a/core/core.services.yml
-+++ b/core/core.services.yml
-@@ -57,6 +57,10 @@ parameters:
-     supportsCredentials: false
-   tempstore.expire: 604800
- services:
-+  plugin.manager.config_action:
-+    class: Drupal\Core\Config\Action\ConfigActionManager
-+    parent: default_plugin_manager
-+    arguments: ['@config.manager']
-   # Simple cache contexts, directly derived from the request context.
-   cache_context.ip:
-     class: Drupal\Core\Cache\Context\IpCacheContext
-diff --git a/core/lib/Drupal/Core/Config/Action/Annotation/ConfigAction.php b/core/lib/Drupal/Core/Config/Action/Annotation/ConfigAction.php
-new file mode 100644
-index 00000000..810ac205
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/Annotation/ConfigAction.php
-@@ -0,0 +1,47 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action\Annotation;
-+
-+use Drupal\Component\Annotation\Plugin;
-+use Drupal\Core\Annotation\Translation;
-+use Drupal\Core\StringTranslation\TranslatableMarkup;
-+
-+/**
-+ * Defines a ConfigAction annotation object.
-+ *
-+ * @ingroup config_action_api
-+ *
-+ * @Annotation
-+ */
-+class ConfigAction extends Plugin {
-+
-+  /**
-+   * The plugin ID.
-+   *
-+   * @var string
-+   */
-+  public string $id;
-+
-+  /**
-+   * The administrative label of the config action.
-+   *
-+   * @var \Drupal\Core\Annotation\Translation|\Drupal\Core\StringTranslation\TranslatableMarkup|string
-+   *
-+   * @ingroup plugin_translatable
-+   */
-+  public Translation|TranslatableMarkup|string $admin_label = '';
-+
-+  /**
-+   * Allows action shorthand IDs for the listed config entity types.
-+   *
-+   * If '*' is present in the array then it can apply to all entity types. An
-+   * empty array means that shorthand action IDs are not available for this
-+   * plugin.
-+   *
-+   * @see \Drupal\Core\Config\Action\ConfigActionManager::convertActionToPluginId()
-+   *
-+   * @var string[]
-+   */
-+  public array $entity_types = [];
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php
-new file mode 100644
-index 00000000..0d2f481e
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/Attribute/ActionMethod.php
-@@ -0,0 +1,39 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action\Attribute;
-+
-+// cspell:ignore inflector
-+use Drupal\Core\Config\Action\Exists;
-+use Drupal\Core\StringTranslation\TranslatableMarkup;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+#[\Attribute(\Attribute::TARGET_METHOD)]
-+final class ActionMethod {
-+
-+  /**
-+   * @param \Drupal\Core\Config\Action\Exists $exists
-+   *   Determines behavior of action depending on entity existence.
-+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|string $adminLabel
-+   *   The admin label for the user interface.
-+   * @param bool|string $pluralize
-+   *   Determines whether to create a pluralized version of the method to enable
-+   *   the action to be called multiple times before saving the entity. The
-+   *   default behaviour is to create an action with a plural form as determined
-+   *   by \Symfony\Component\String\Inflector\EnglishInflector::pluralize().
-+   *   For example, 'grantPermission' has a pluralized version of
-+   *   'grantPermissions'. If a string is provided this will be the full action
-+   *   ID. For example, if the method is called 'addArray' this can be set to
-+   *   'addMultipleArrays'. Set to FALSE if a pluralized version does not make
-+   *   logical sense.
-+   */
-+  public function __construct(
-+    public readonly Exists $exists = Exists::ERROR_IF_NOT_EXISTS,
-+    public readonly TranslatableMarkup|string $adminLabel = '',
-+    public readonly bool|string $pluralize = TRUE
-+  ) {
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionException.php b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php
-new file mode 100644
-index 00000000..02e5220e
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionException.php
-@@ -0,0 +1,10 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class ConfigActionException extends \RuntimeException {
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php
-new file mode 100644
-index 00000000..646a0141
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionManager.php
-@@ -0,0 +1,123 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action;
-+
-+use Drupal\Component\Plugin\PluginBase;
-+use Drupal\Core\Cache\CacheBackendInterface;
-+use Drupal\Core\Config\ConfigManagerInterface;
-+use Drupal\Core\Extension\ModuleHandlerInterface;
-+use Drupal\Core\Plugin\DefaultPluginManager;
-+
-+/**
-+ * @defgroup config_action_api Config Action API
-+ * @{
-+ * Information about the classes and interfaces that make up the Config Action
-+ * API.
-+ *
-+ * Configuration actions are plugins that manipulate simple configuration or
-+ * configuration entities. The configuration action plugin manager can apply
-+ * configuration actions. For example, the API is leveraged by recipes to create
-+ * roles if they do not exist already and grant permissions to those roles.
-+ *
-+ * To define a configuration action in a module you need to:
-+ * - Define a Config Action plugin by creating a new class that implements the
-+ *   \Drupal\Core\Config\Action\ConfigActionPluginInterface, in namespace
-+ *   Plugin\ConfigAction under your module namespace. For more information about
-+ *   creating plugins, see the @link plugin_api Plugin API topic. @endlink
-+ * - Config action plugins use the annotations defined by
-+ *  \Drupal\Core\Config\Action\Annotation\ConfigAction. See the
-+ *   @link annotation Annotations topic @endlink for more information about
-+ *   annotations.
-+ *
-+ * Further information and examples:
-+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod derives
-+ *   configuration actions from config entity methods which have the
-+ *   \Drupal\Core\Config\Action\Attribute\ActionMethod attribute.
-+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate allows you to
-+ *   create configuration entities if they do not exist.
-+ * - \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate allows
-+ *   you to update simple configuration using a config action.
-+ * @}
-+ */
-+class ConfigActionManager extends DefaultPluginManager {
-+
-+  /**
-+   * Constructs a new \Drupal\Core\Config\Action\ConfigActionManager object.
-+   *
-+   * @param \Traversable $namespaces
-+   *   An object that implements \Traversable which contains the root paths
-+   *   keyed by the corresponding namespace to look for plugin implementations.
-+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
-+   *   Cache backend instance to use.
-+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
-+   *   The module handler to invoke the alter hook with.
-+   * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
-+   *   The config manager.
-+   */
-+  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, protected readonly ConfigManagerInterface $configManager) {
-+    assert($namespaces instanceof \ArrayAccess, '$namespaces can be accessed like an array');
-+    // Enable this namespace to be searched for plugins.
-+    $namespaces[__NAMESPACE__] = 'core/lib/Drupal/Core/Config/Action';
-+
-+    parent::__construct('Plugin/ConfigAction', $namespaces, $module_handler, 'Drupal\Core\Config\Action\ConfigActionPluginInterface', 'Drupal\Core\Config\Action\Annotation\ConfigAction');
-+
-+    $this->alterInfo('config_action');
-+    $this->setCacheBackend($cache_backend, 'config_action');
-+  }
-+
-+  /**
-+   * Applies a config action.
-+   *
-+   * @param string $action_id
-+   *   The ID of the action to apply. This can be a complete configuration
-+   *   action plugin ID or a shorthand action ID that is available for the
-+   *   entity type of the provided configuration name.
-+   * @param string $configName
-+   *   The configuration name.
-+   * @param mixed $data
-+   *   The data for the action.
-+   *
-+   * @throws \Drupal\Component\Plugin\Exception\PluginException
-+   *   Thrown when the config action cannot be found.
-+   * @throws \Drupal\Core\Config\Action\ConfigActionException
-+   *   Thrown when the config action fails to apply.
-+   */
-+  public function applyAction(string $action_id, string $configName, mixed $data): void {
-+    if (!$this->hasDefinition($action_id)) {
-+      // Get the full plugin ID from the shorthand map, if it is available.
-+      $entity_type = $this->configManager->getEntityTypeIdByName($configName);
-+      if ($entity_type) {
-+        $action_id = $this->getShorthandActionIdsForEntityType($entity_type)[$action_id] ?? $action_id;
-+      }
-+    }
-+    /** @var \Drupal\Core\Config\Action\ConfigActionPluginInterface $action */
-+    $action = $this->createInstance($action_id);
-+    $action->apply($configName, $data);
-+  }
-+
-+  /**
-+   * Gets a map of shorthand action IDs to plugin IDs for an entity type.
-+   *
-+   * @param string $entityType
-+   *   The entity type ID to get the map for.
-+   *
-+   * @return string[]
-+   *   An array of plugin IDs keyed by shorthand action ID for the provided
-+   *   entity type.
-+   */
-+  protected function getShorthandActionIdsForEntityType(string $entityType): array {
-+    $map = [];
-+    foreach ($this->getDefinitions() as $plugin_id => $definition) {
-+      if (in_array($entityType, $definition['entity_types'], TRUE) || in_array('*', $definition['entity_types'], TRUE)) {
-+        $regex = '/' . PluginBase::DERIVATIVE_SEPARATOR . '([^' . PluginBase::DERIVATIVE_SEPARATOR . ']*)$/';
-+        $action_id = preg_match($regex, $plugin_id, $matches) ? $matches[1] : $plugin_id;
-+        if (isset($map[$action_id])) {
-+          throw new DuplicateConfigActionIdException(sprintf('The plugins \'%s\' and \'%s\' both resolve to the same shorthand action ID for the \'%s\' entity type', $plugin_id, $map[$action_id], $entityType));
-+        }
-+        $map[$action_id] = $plugin_id;
-+      }
-+    }
-+    return $map;
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php
-new file mode 100644
-index 00000000..143ce3a3
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/ConfigActionPluginInterface.php
-@@ -0,0 +1,19 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action;
-+
-+interface ConfigActionPluginInterface {
-+
-+  /**
-+   * Applies the config action.
-+   *
-+   * @param string $configName
-+   *   The name of the config to apply the action to.
-+   * @param mixed $value
-+   *   The value for the action to use.
-+   *
-+   * @throws ConfigActionException
-+   */
-+  public function apply(string $configName, mixed $value): void;
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php
-new file mode 100644
-index 00000000..7f6bdd3f
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/DuplicateConfigActionIdException.php
-@@ -0,0 +1,9 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action;
-+
-+/**
-+ * Exception thrown if there are conflicting shorthand action IDs.
-+ */
-+class DuplicateConfigActionIdException extends \RuntimeException {
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/EntityMethodException.php b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php
-new file mode 100644
-index 00000000..3a7b5644
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/EntityMethodException.php
-@@ -0,0 +1,10 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class EntityMethodException extends \RuntimeException {
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/Exists.php b/core/lib/Drupal/Core/Config/Action/Exists.php
-new file mode 100644
-index 00000000..ec34b574
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/Exists.php
-@@ -0,0 +1,42 @@
-+<?php
-+// phpcs:ignoreFile
-+
-+namespace Drupal\Core\Config\Action;
-+
-+use Drupal\Core\Config\Entity\ConfigEntityInterface;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+enum Exists {
-+  case ERROR_IF_EXISTS;
-+  case ERROR_IF_NOT_EXISTS;
-+  case RETURN_EARLY_IF_EXISTS;
-+  case RETURN_EARLY_IF_NOT_EXISTS;
-+
-+  /**
-+   * Determines if an action should return early depending on $entity.
-+   *
-+   * @param string $configName
-+   *   The config name supplied to the action.
-+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity
-+   *   The entity, if it exists.
-+   *
-+   * @return bool
-+   *   TRUE if the action should return early, FALSE if not.
-+   *
-+   * @throws \Drupal\Core\Config\Action\ConfigActionException
-+   *   Thrown depending on $entity and the value of $this.
-+   */
-+  public function returnEarly(string $configName, ?ConfigEntityInterface $entity): bool {
-+    return match (TRUE) {
-+      $this === self::RETURN_EARLY_IF_EXISTS && $entity !== NULL,
-+      $this === self::RETURN_EARLY_IF_NOT_EXISTS && $entity === NULL => TRUE,
-+      $this === self::ERROR_IF_EXISTS && $entity !== NULL => throw new ConfigActionException(sprintf('Entity %s exists', $configName)),
-+      $this === self::ERROR_IF_NOT_EXISTS && $entity === NULL => throw new ConfigActionException(sprintf('Entity %s does not exist', $configName)),
-+      default => FALSE
-+    };
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php
-new file mode 100644
-index 00000000..8f42f7ad
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityCreateDeriver.php
-@@ -0,0 +1,32 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
-+
-+use Drupal\Component\Plugin\Derivative\DeriverBase;
-+use Drupal\Core\Config\Action\Exists;
-+use Drupal\Core\StringTranslation\StringTranslationTrait;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+class EntityCreateDeriver extends DeriverBase {
-+  use StringTranslationTrait;
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function getDerivativeDefinitions($base_plugin_definition) {
-+    // These derivatives apply to all entity types.
-+    $base_plugin_definition['entity_types'] = ['*'];
-+
-+    $this->derivatives['ensure_exists'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::RETURN_EARLY_IF_EXISTS]];
-+    $this->derivatives['ensure_exists']['admin_label'] = $this->t('Ensure entity exists');
-+
-+    $this->derivatives['create'] = $base_plugin_definition + ['constructor_args' => ['exists' => Exists::ERROR_IF_EXISTS]];
-+    $this->derivatives['create']['admin_label'] = $this->t('Entity create');
-+
-+    return $this->derivatives;
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php
-new file mode 100644
-index 00000000..fe99550c
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/EntityMethodDeriver.php
-@@ -0,0 +1,141 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
-+
-+// cspell:ignore inflector
-+use Drupal\Component\Plugin\Derivative\DeriverBase;
-+use Drupal\Component\Plugin\PluginBase;
-+use Drupal\Core\Config\Action\Attribute\ActionMethod;
-+use Drupal\Core\Config\Action\EntityMethodException;
-+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
-+use Drupal\Core\Entity\EntityTypeManagerInterface;
-+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
-+use Drupal\Core\StringTranslation\StringTranslationTrait;
-+use Symfony\Component\DependencyInjection\ContainerInterface;
-+use Symfony\Component\String\Inflector\EnglishInflector;
-+use Symfony\Component\String\Inflector\InflectorInterface;
-+
-+/**
-+ * Derives config action methods from attributed config entity methods.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class EntityMethodDeriver extends DeriverBase implements ContainerDeriverInterface {
-+
-+  use StringTranslationTrait;
-+
-+  /**
-+   * Inflector to pluralize words.
-+   */
-+  protected readonly InflectorInterface $inflector;
-+
-+  /**
-+   * Constructs new EntityMethodDeriver.
-+   *
-+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
-+   *   The entity type manager.
-+   */
-+  public function __construct(protected readonly EntityTypeManagerInterface $entityTypeManager) {
-+    $this->inflector = new EnglishInflector();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public static function create(ContainerInterface $container, $base_plugin_id) {
-+    return new static(
-+      $container->get('entity_type.manager')
-+    );
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function getDerivativeDefinitions($base_plugin_definition) {
-+    // Scan all the config entity classes for attributes.
-+    foreach ($this->entityTypeManager->getDefinitions() as $entity_type) {
-+      if ($entity_type instanceof ConfigEntityTypeInterface) {
-+        $reflectionClass = new \ReflectionClass($entity_type->getClass());
-+        while ($reflectionClass) {
-+          foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
-+            // Only process a method if it is declared on the current class.
-+            // Methods on the parent class will be processed later. This allows
-+            // for a parent to have an attribute and an overriding class does
-+            // not need one. For example,
-+            // \Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay::setComponent()
-+            // and \Drupal\Core\Entity\EntityDisplayBase::setComponent().
-+            if ($method->getDeclaringClass()->getName() === $reflectionClass->getName()) {
-+              foreach ($method->getAttributes(ActionMethod::class) as $attribute) {
-+                $this->processMethod($method, $attribute->newInstance(), $entity_type, $base_plugin_definition);
-+              }
-+            }
-+          }
-+          $reflectionClass = $reflectionClass->getParentClass();
-+        }
-+      }
-+    }
-+    return $this->derivatives;
-+  }
-+
-+  /**
-+   * Processes a method to create derivatives.
-+   *
-+   * @param \ReflectionMethod $method
-+   *   The entity method.
-+   * @param \Drupal\Core\Config\Action\Attribute\ActionMethod $action_attribute
-+   *   The entity method attribute.
-+   * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type
-+   *   The entity type.
-+   * @param array $derivative
-+   *   The base plugin definition that will used to create the derivative.
-+   */
-+  private function processMethod(\ReflectionMethod $method, ActionMethod $action_attribute, ConfigEntityTypeInterface $entity_type, array $derivative): void {
-+    $derivative['admin_label'] = $action_attribute->adminLabel ?: $this->t('@entity_type @method', ['@entity_type' => $entity_type->getLabel(), '@method' => $method->name]);
-+    $derivative['constructor_args'] = [
-+      'method' => $method->name,
-+      'exists' => $action_attribute->exists,
-+      'numberOfParams' => $method->getNumberOfParameters(),
-+      'numberOfRequiredParams' => $method->getNumberOfRequiredParameters(),
-+      'pluralized' => FALSE,
-+    ];
-+    $derivative['entity_types'] = [$entity_type->id()];
-+    // Build a config action identifier from the entity type's config
-+    // prefix  and the method name. For example, the Role entity adds a
-+    // 'user.role:grantPermission' action.
-+    $this->addDerivative($method->name, $entity_type, $derivative, $method->name);
-+
-+    $pluralized_name = match(TRUE) {
-+      is_string($action_attribute->pluralize) => $action_attribute->pluralize,
-+      $action_attribute->pluralize === FALSE => '',
-+      default => $this->inflector->pluralize($method->name)[0]
-+    };
-+    // Add a pluralized version of the plugin.
-+    if (strlen($pluralized_name) > 0) {
-+      $derivative['constructor_args']['pluralized'] = TRUE;
-+      $derivative['admin_label'] = $this->t('@admin_label (multiple calls)', ['@admin_label' => $derivative['admin_label']]);
-+      $this->addDerivative($pluralized_name, $entity_type, $derivative, $method->name);
-+    }
-+  }
-+
-+  /**
-+   * Adds a derivative.
-+   *
-+   * @param string $action_id
-+   *   The action ID.
-+   * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type
-+   *   The entity type.
-+   * @param array $derivative
-+   *   The derivative definition.
-+   * @param string $methodName
-+   *   The method name.
-+   */
-+  private function addDerivative(string $action_id, ConfigEntityTypeInterface $entity_type, array $derivative, string $methodName): void {
-+    $id = $entity_type->getConfigPrefix() . PluginBase::DERIVATIVE_SEPARATOR . $action_id;
-+    if (isset($this->derivatives[$id])) {
-+      throw new EntityMethodException(sprintf('Duplicate action can not be created for ID \'%s\' for %s::%s(). The existing action is for the ::%s() method', $id, $entity_type->getClass(), $methodName, $this->derivatives[$id]['constructor_args']['method']));
-+    }
-+    $this->derivatives[$id] = $derivative;
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
-new file mode 100644
-index 00000000..f86f64fb
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
-@@ -0,0 +1,74 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
-+
-+use Drupal\Core\Config\Action\ConfigActionException;
-+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
-+use Drupal\Core\Config\Action\Exists;
-+use Drupal\Core\Config\ConfigManagerInterface;
-+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-+use Symfony\Component\DependencyInjection\ContainerInterface;
-+
-+/**
-+ * @ConfigAction(
-+ *   id = "entity_create",
-+ *   deriver = "\Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityCreateDeriver",
-+ * )
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class EntityCreate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
-+
-+  /**
-+   * Constructs a EntityCreate object.
-+   *
-+   * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
-+   *   The config manager.
-+   * @param \Drupal\Core\Config\Action\Exists $exists
-+   *   Determines behavior of action depending on entity existence.
-+   */
-+  public function __construct(
-+    protected readonly ConfigManagerInterface $configManager,
-+    protected readonly Exists $exists
-+  ) {
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
-+    assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings');
-+    return new static($container->get('config.manager'), ...$plugin_definition['constructor_args']);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function apply(string $configName, mixed $value): void {
-+    if (!is_array($value)) {
-+      throw new ConfigActionException(sprintf("The value provided to create %s must be an array", $configName));
-+    }
-+
-+    /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */
-+    $entity = $this->configManager->loadConfigEntityByName($configName);
-+    if ($this->exists->returnEarly($configName, $entity)) {
-+      return;
-+    }
-+
-+    $entity_type_manager = $this->configManager->getEntityTypeManager();
-+    $entity_type_id = $this->configManager->getEntityTypeIdByName($configName);
-+    if ($entity_type_id === NULL) {
-+      throw new ConfigActionException(sprintf("Cannot determine a config entity type from %s", $configName));
-+    }
-+    /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */
-+    $entity_type = $entity_type_manager->getDefinition($entity_type_id);
-+
-+    $id = substr($configName, strlen($entity_type->getConfigPrefix()) + 1);
-+    $entity_type_manager
-+      ->getStorage($entity_type->id())
-+      ->create($value + ['id' => $id])
-+      ->save();
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php
-new file mode 100644
-index 00000000..a04bd637
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityMethod.php
-@@ -0,0 +1,146 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
-+
-+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
-+use Drupal\Core\Config\Action\EntityMethodException;
-+use Drupal\Core\Config\Action\Exists;
-+use Drupal\Core\Config\ConfigManagerInterface;
-+use Drupal\Core\Config\Entity\ConfigEntityInterface;
-+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-+use Symfony\Component\DependencyInjection\ContainerInterface;
-+
-+/**
-+ * Makes config entity methods with the ActionMethod attribute into actions.
-+ *
-+ * For example, adding the ActionMethod attribute to
-+ * \Drupal\user\Entity\Role::grantPermission() allows permissions to be added to
-+ * roles via config actions.
-+ *
-+ * When calling \Drupal\Core\Config\Action\ConfigActionManager::applyAction()
-+ * the $data parameter is mapped to the method's arguments using the following
-+ * rules:
-+ * - If $data is not an array, the method must only have one argument or one
-+ *   required argument.
-+ * - If $data is an array and the method only accepts a single argument, the
-+ *   array will be passed to the first argument.
-+ * - If $data is an array and the method accepts more than one argument, $data
-+ *   will be unpacked into the method arguments.
-+ *
-+ * @ConfigAction(
-+ *   id = "entity_method",
-+ *   deriver = "\Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver",
-+ * )
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ *
-+ * @see \Drupal\Core\Config\Action\Attribute\ActionMethod
-+ */
-+final class EntityMethod implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
-+
-+  /**
-+   * Constructs a EntityMethod object.
-+   *
-+   * @param string $pluginId
-+   *   The config action plugin ID.
-+   * @param \Drupal\Core\Config\ConfigManagerInterface $configManager
-+   *   The config manager.
-+   * @param string $method
-+   *   The method to call on the config entity.
-+   * @param \Drupal\Core\Config\Action\Exists $exists
-+   *   Determines behavior of action depending on entity existence.
-+   * @param int $numberOfParams
-+   *   The number of parameters the method has.
-+   * @param int $numberOfRequiredParams
-+   *   The number of required parameters the method has.
-+   * @param bool $pluralized
-+   *   Determines whether an array maps to multiple calls.
-+   */
-+  public function __construct(
-+    protected readonly string $pluginId,
-+    protected readonly ConfigManagerInterface $configManager,
-+    protected readonly string $method,
-+    protected readonly Exists $exists,
-+    protected readonly int $numberOfParams,
-+    protected readonly int $numberOfRequiredParams,
-+    protected readonly bool $pluralized
-+  ) {
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
-+    assert(is_array($plugin_definition) && is_array($plugin_definition['constructor_args']), '$plugin_definition contains the expected settings');
-+    return new static(
-+      $plugin_id,
-+      $container->get('config.manager'),
-+      ...$plugin_definition['constructor_args']
-+    );
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function apply(string $configName, mixed $value): void {
-+    /** @var \Drupal\Core\Config\Entity\ConfigEntityInterface|null $entity */
-+    $entity = $this->configManager->loadConfigEntityByName($configName);
-+    if ($this->exists->returnEarly($configName, $entity)) {
-+      return;
-+    }
-+
-+    $entity = $this->pluralized ? $this->applyPluralized($entity, $value) : $this->applySingle($entity, $value);
-+    $entity->save();
-+  }
-+
-+  /**
-+   * Apply the action to entity treating the $values array as multiple calls.
-+   *
-+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
-+   *   The entity to apply the action to.
-+   * @param mixed $values
-+   *   The values for the action to use.
-+   *
-+   * @return \Drupal\Core\Config\Entity\ConfigEntityInterface
-+   *   The unsaved entity with the action applied.
-+   */
-+  private function applyPluralized(ConfigEntityInterface $entity, mixed $values): ConfigEntityInterface {
-+    if (!is_array($values)) {
-+      throw new EntityMethodException(sprintf('The pluralized entity method config action \'%s\' requires an array value in order to call %s::%s() multiple times', $this->pluginId, $entity->getEntityType()->getClass(), $this->method));
-+    }
-+    foreach ($values as $value) {
-+      $entity = $this->applySingle($entity, $value);
-+    }
-+    return $entity;
-+  }
-+
-+  /**
-+   * Apply the action to entity treating the $values array a single call.
-+   *
-+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
-+   *   The entity to apply the action to.
-+   * @param mixed $value
-+   *   The value for the action to use.
-+   *
-+   * @return \Drupal\Core\Config\Entity\ConfigEntityInterface
-+   *   The unsaved entity with the action applied.
-+   */
-+  private function applySingle(ConfigEntityInterface $entity, mixed $value): ConfigEntityInterface {
-+    // If $value is not an array then we only support calling the method if the
-+    // number of parameters or required parameters is 1. If there is only 1
-+    // parameter and $value is an array then assume that the parameter expects
-+    // an array.
-+    if (!is_array($value) || $this->numberOfParams === 1) {
-+      if ($this->numberOfRequiredParams !== 1 && $this->numberOfParams !== 1) {
-+        throw new EntityMethodException(sprintf('Entity method config action \'%s\' requires an array value. The number of parameters or required parameters for %s::%s() is not 1', $this->pluginId, $entity->getEntityType()->getClass(), $this->method));
-+      }
-+      $entity->{$this->method}($value);
-+    }
-+    else {
-+      $entity->{$this->method}(...$value);
-+    }
-+    return $entity;
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php
-new file mode 100644
-index 00000000..5f46c65a
---- /dev/null
-+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/SimpleConfigUpdate.php
-@@ -0,0 +1,60 @@
-+<?php
-+
-+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
-+
-+use Drupal\Core\Config\Action\ConfigActionException;
-+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
-+use Drupal\Core\Config\ConfigFactoryInterface;
-+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-+use Symfony\Component\DependencyInjection\ContainerInterface;
-+
-+/**
-+ * @ConfigAction(
-+ *   id = "simple_config_update",
-+ *   admin_label = @Translation("Simple configuration update")
-+ * )
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class SimpleConfigUpdate implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
-+
-+  /**
-+   * Constructs a SimpleConfigUpdate object.
-+   *
-+   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
-+   *   The config factory.
-+   */
-+  public function __construct(
-+    protected readonly ConfigFactoryInterface $configFactory,
-+  ) {
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
-+    return new static($container->get('config.factory'));
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function apply(string $configName, mixed $value): void {
-+    $config = $this->configFactory->getEditable($configName);
-+    // @todo Should we error if this is a config entity?
-+    if ($config->isNew()) {
-+      throw new ConfigActionException(sprintf('Config %s does not exist so can not be updated', $configName));
-+    }
-+
-+    // Expect $value to be an array whose keys are the config keys to update.
-+    if (!is_array($value)) {
-+      throw new ConfigActionException(sprintf('Config %s can not be updated because $value is not an array', $configName));
-+    }
-+    foreach ($value as $key => $value) {
-+      $config->set($key, $value);
-+    }
-+    $config->save();
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
-index 1724adda..65079ccf 100644
---- a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
-+++ b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php
-@@ -2,10 +2,12 @@
- 
- namespace Drupal\Core\Entity;
- 
-+use Drupal\Core\Config\Action\Attribute\ActionMethod;
- use Drupal\Core\Config\Entity\ConfigEntityBase;
- use Drupal\Core\Config\Entity\ConfigEntityInterface;
- use Drupal\Core\Field\FieldDefinitionInterface;
- use Drupal\Core\Entity\Display\EntityDisplayInterface;
-+use Drupal\Core\StringTranslation\TranslatableMarkup;
- 
- /**
-  * Provides a common base class for entity view and form displays.
-@@ -337,6 +339,7 @@ public function getComponent($name) {
-   /**
-    * {@inheritdoc}
-    */
-+  #[ActionMethod(adminLabel: new TranslatableMarkup('Add component to display'))]
-   public function setComponent($name, array $options = []) {
-     // If no weight specified, make sure the field sinks at the bottom.
-     if (!isset($options['weight'])) {
-diff --git a/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php
-new file mode 100644
-index 00000000..a8f3f025
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/ConfigConfigurator.php
-@@ -0,0 +1,76 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Core\Config\FileStorage;
-+use Drupal\Core\Config\StorageInterface;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class ConfigConfigurator {
-+
-+  public readonly ?string $recipeConfigDirectory;
-+
-+  /**
-+   * @param array $config
-+   *   Config options for a recipe.
-+   * @param string $recipe_directory
-+   *   The path to the recipe.
-+   * @param \Drupal\Core\Config\StorageInterface $active_configuration
-+   *   The active configuration storage.
-+   */
-+  public function __construct(public readonly array $config, string $recipe_directory, StorageInterface $active_configuration) {
-+    // @todo validate structure of $config['import'] and $config['actions'].
-+
-+    $this->recipeConfigDirectory = is_dir($recipe_directory . '/config') ? $recipe_directory . '/config' : NULL;
-+    $recipe_storage = $this->getConfigStorage();
-+    foreach ($recipe_storage->listAll() as $config_name) {
-+      if ($active_data = $active_configuration->read($config_name)) {
-+        // @todo investigate if there is any generic code in core for this.
-+        unset($active_data['uuid'], $active_data['_core']);
-+        if (empty($active_data['dependencies'])) {
-+          unset($active_data['dependencies']);
-+        }
-+        if ($active_data !== $recipe_storage->read($config_name)) {
-+          throw new RecipePreExistingConfigException($config_name, sprintf("The configuration '%s' exists already and does not match the recipe's configuration", $config_name));
-+        }
-+      }
-+    }
-+  }
-+
-+  /**
-+   * Gets a config storage object for reading config from the recipe.
-+   *
-+   * @return \Drupal\Core\Config\StorageInterface
-+   *   The  config storage object for reading config from the recipe.
-+   */
-+  public function getConfigStorage(): StorageInterface {
-+    $storages = [];
-+
-+    if ($this->recipeConfigDirectory) {
-+      // Config provided by the recipe should take priority over config from
-+      // extensions.
-+      $storages[] = new FileStorage($this->recipeConfigDirectory);
-+    }
-+    if (!empty($this->config['import'])) {
-+      /** @var \Drupal\Core\Extension\ModuleExtensionList $module_list */
-+      $module_list = \Drupal::service('extension.list.module');
-+      /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_list */
-+      $theme_list = \Drupal::service('extension.list.theme');
-+      foreach ($this->config['import'] as $extension => $config) {
-+        $path = match (TRUE) {
-+          $module_list->exists($extension) => $module_list->getPath($extension),
-+          $theme_list->exists($extension) => $theme_list->getPath($extension),
-+          default => throw new \RuntimeException("$extension is not a theme or module")
-+        };
-+        $config = (array) ($config === '*' ? NULL : $config);
-+        $storages[] = new RecipeExtensionConfigStorage($path, $config);
-+      }
-+    }
-+
-+    return RecipeConfigStorageWrapper::createStorageFromArray($storages);
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/ContentConfigurator.php b/core/lib/Drupal/Core/Recipe/ContentConfigurator.php
-new file mode 100644
-index 00000000..753ecc16
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/ContentConfigurator.php
-@@ -0,0 +1,19 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class ContentConfigurator {
-+
-+  /**
-+   * @param array $content
-+   *   Content options for a recipe.
-+   */
-+  public function __construct(public readonly array $content) {
-+    // @todo https://www.drupal.org/project/distributions_recipes/issues/3292287
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/InstallConfigurator.php b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php
-new file mode 100644
-index 00000000..a1207cc2
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/InstallConfigurator.php
-@@ -0,0 +1,121 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Component\Assertion\Inspector;
-+use Drupal\Core\Extension\Dependency;
-+use Drupal\Core\Extension\ModuleExtensionList;
-+use Drupal\Core\Extension\ThemeExtensionList;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class InstallConfigurator {
-+
-+  /**
-+   * The list of modules to install.
-+   *
-+   * This list is sorted an includes any module dependencies of the provided
-+   * extensions.
-+   *
-+   * @var string[]
-+   */
-+  public readonly array $modules;
-+
-+  /**
-+   * The list of themes to install.
-+   *
-+   * This list is sorted an includes any theme dependencies of the provided
-+   * extensions.
-+   *
-+   * @var string[]
-+   */
-+  public readonly array $themes;
-+
-+  /**
-+   * @param string[] $extensions
-+   *   A list of extensions for a recipe to install.
-+   * @param \Drupal\Core\Extension\ModuleExtensionList $module_list
-+   *   The module list service.
-+   * @param \Drupal\Core\Extension\ThemeExtensionList $theme_list
-+   *   The theme list service.
-+   */
-+  public function __construct(array $extensions, ModuleExtensionList $module_list, ThemeExtensionList $theme_list) {
-+    assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.');
-+    // @todo https://www.drupal.org/project/distributions_recipes/issues/3292281
-+    $extensions = array_map(fn($extension) => Dependency::createFromString($extension)->getName(), $extensions);
-+    $extensions = array_combine($extensions, $extensions);
-+    $module_data = $module_list->reset()->getList();
-+    $theme_data = $theme_list->reset()->getList();
-+
-+    $modules = array_intersect_key($extensions, $module_data);
-+    $themes = array_intersect_key($extensions, $theme_data);
-+
-+    $missing_extensions = array_diff($extensions, $modules, $themes);
-+
-+    // Add theme module dependencies.
-+    foreach ($themes as $theme => $value) {
-+      $modules = array_merge($modules, array_keys($theme_data[$theme]->module_dependencies));
-+    }
-+
-+    // Add modules that other modules depend on.
-+    // @todo should recipes do this? I think so. It allows modules to add
-+    //   dependencies and recipes continue to work
-+    foreach ($modules as $module) {
-+      if ($module_data[$module]->requires) {
-+        $modules = array_merge($modules, array_keys($module_data[$module]->requires));
-+      }
-+    }
-+
-+    // Remove all modules that have been installed already.
-+    $modules = array_diff(array_unique($modules), array_keys($module_list->getAllInstalledInfo()));
-+    $modules = array_combine($modules, $modules);
-+
-+    // Create a sortable list of modules.
-+    foreach ($modules as $name => $value) {
-+      if (isset($module_data[$name])) {
-+        $modules[$name] = $module_data[$name]->sort;
-+      }
-+      else {
-+        $missing_extensions[$name] = $name;
-+      }
-+    }
-+
-+    // Add any missing base themes to the list of themes to install.
-+    foreach ($themes as $theme => $value) {
-+      // $theme_data[$theme]->requires contains both theme and module
-+      // dependencies keyed by the extension machine names.
-+      // $theme_data[$theme]->module_dependencies contains only the module
-+      // dependencies keyed by the module extension machine name. Therefore,
-+      // we can find the theme dependencies by finding array keys for
-+      // 'requires' that are not in $module_dependencies.
-+      $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $theme_data[$theme]->module_dependencies);
-+      $themes = array_merge($themes, array_keys($theme_dependencies));
-+    }
-+
-+    // Remove all themes that have been installed already.
-+    $themes = array_diff(array_unique($themes), array_keys($theme_list->getAllInstalledInfo()));
-+    $themes = array_combine($themes, $themes);
-+
-+    // Create a sortable list of themes.
-+    foreach ($themes as $name => $value) {
-+      if (isset($theme_data[$name])) {
-+        $themes[$name] = $theme_data[$name]->sort;
-+      }
-+      else {
-+        $missing_extensions[$name] = $name;
-+      }
-+    }
-+
-+    if (!empty($missing_extensions)) {
-+      throw new RecipeMissingExtensionsException(array_values($missing_extensions));
-+    }
-+
-+    arsort($modules);
-+    arsort($themes);
-+    $this->modules = array_keys($modules);
-+    $this->themes = array_keys($themes);
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/Recipe.php b/core/lib/Drupal/Core/Recipe/Recipe.php
-new file mode 100644
-index 00000000..ed35e188
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/Recipe.php
-@@ -0,0 +1,70 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Core\Serialization\Yaml;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class Recipe {
-+
-+  const COMPOSER_PROJECT_TYPE = 'drupal-recipe';
-+
-+  public function __construct(
-+    public readonly string $name,
-+    public readonly string $description,
-+    public readonly string $type,
-+    public readonly RecipeConfigurator $recipes,
-+    public readonly InstallConfigurator $install,
-+    public readonly ConfigConfigurator $config,
-+    public readonly ContentConfigurator $content
-+  ) {
-+  }
-+
-+  /**
-+   * Creates a recipe object from the provided path.
-+   *
-+   * @param string $path
-+   *   The path to a recipe.
-+   *
-+   * @return static
-+   *   The Recipe object.
-+   */
-+  public static function createFromDirectory(string $path): static {
-+    if (!is_readable($path . '/recipe.yml')) {
-+      throw new RecipeFileException("There is no $path/recipe.yml file");
-+    }
-+
-+    $recipe_contents = file_get_contents($path . '/recipe.yml');
-+    if (!$recipe_contents) {
-+      throw new RecipeFileException("$path/recipe.yml cannot be read");
-+    }
-+    $recipe_data = Yaml::decode($recipe_contents);
-+    // @todo Do we need to improve validation?
-+    if (!is_array($recipe_data)) {
-+      throw new RecipeFileException("$path/recipe.yml is invalid");
-+    }
-+    $recipe_data += [
-+      'description' => '',
-+      'type' => '',
-+      'recipes' => [],
-+      'install' => [],
-+      'config' => [],
-+      'content' => [],
-+    ];
-+
-+    if (!isset($recipe_data['name'])) {
-+      throw new RecipeFileException("The $path/recipe.yml has no name value.");
-+    }
-+
-+    $recipe_discovery = new RecipeDiscovery([dirname($path)]);
-+    $recipes = new RecipeConfigurator($recipe_data['recipes'], $recipe_discovery);
-+    $install = new InstallConfigurator($recipe_data['install'], \Drupal::service('extension.list.module'), \Drupal::service('extension.list.theme'));
-+    $config = new ConfigConfigurator($recipe_data['config'], $path, \Drupal::service('config.storage'));
-+    $content = new ContentConfigurator($recipe_data['content']);
-+    return new static($recipe_data['name'], $recipe_data['description'], $recipe_data['type'], $recipes, $install, $config, $content);
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeCommand.php b/core/lib/Drupal/Core/Recipe/RecipeCommand.php
-new file mode 100644
-index 00000000..d58cf812
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeCommand.php
-@@ -0,0 +1,107 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Core\DrupalKernel;
-+use Drupal\Core\Site\Settings;
-+use Symfony\Component\Console\Command\Command;
-+use Symfony\Component\Console\Input\InputArgument;
-+use Symfony\Component\Console\Input\InputInterface;
-+use Symfony\Component\Console\Output\OutputInterface;
-+use Symfony\Component\Console\Style\SymfonyStyle;
-+use Symfony\Component\HttpFoundation\Request;
-+
-+/**
-+ * Applies recipe.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeCommand extends Command {
-+
-+  /**
-+   * The class loader.
-+   *
-+   * @var object
-+   */
-+  protected $classLoader;
-+
-+  /**
-+   * Constructs a new ServerCommand command.
-+   *
-+   * @param object $class_loader
-+   *   The class loader.
-+   */
-+  public function __construct($class_loader) {
-+    parent::__construct('recipe');
-+    $this->classLoader = $class_loader;
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  protected function configure(): void {
-+    $this
-+      ->setDescription('Applies a recipe to a site.')
-+      ->addArgument('path', InputArgument::REQUIRED, 'The path to the recipe\'s folder to apply');
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  protected function execute(InputInterface $input, OutputInterface $output): int {
-+    $io = new SymfonyStyle($input, $output);
-+
-+    if (PHP_VERSION_ID < 80100) {
-+      $io->error('Recipes require PHP 8.1');
-+      return 1;
-+    }
-+
-+    $recipe_path = $input->getArgument('path');
-+    if (!is_string($recipe_path) || !is_dir($recipe_path)) {
-+      $io->error(sprintf('The supplied path %s is not a directory', $recipe_path));
-+    }
-+
-+    // Recipes have to be applied to installed sites.
-+    $this->boot();
-+
-+    $recipe = Recipe::createFromDirectory($recipe_path);
-+    RecipeRunner::processRecipe($recipe);
-+    $io->success(sprintf('%s applied successfully', $recipe->name));
-+    return 0;
-+  }
-+
-+  /**
-+   * Boots up a Drupal environment.
-+   *
-+   * @return \Drupal\Core\DrupalKernelInterface
-+   *   The Drupal kernel.
-+   *
-+   * @throws \Exception
-+   *   Exception thrown if kernel does not boot.
-+   */
-+  protected function boot() {
-+    $kernel = new DrupalKernel('prod', $this->classLoader, FALSE);
-+    $kernel::bootEnvironment();
-+    $kernel->setSitePath($this->getSitePath());
-+    Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $this->classLoader);
-+    $kernel->boot();
-+    $kernel->preHandle(Request::createFromGlobals());
-+
-+    return $kernel;
-+  }
-+
-+  /**
-+   * Gets the site path.
-+   *
-+   * Defaults to 'sites/default'. For testing purposes this can be overridden
-+   * using the DRUPAL_DEV_SITE_PATH environment variable.
-+   *
-+   * @return string
-+   *   The site path to use.
-+   */
-+  protected function getSitePath() {
-+    return getenv('DRUPAL_DEV_SITE_PATH') ?: 'sites/default';
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php
-new file mode 100644
-index 00000000..70d6308d
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigInstaller.php
-@@ -0,0 +1,63 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Core\Config\ConfigInstaller;
-+use Drupal\Core\Config\Entity\ConfigDependencyManager;
-+use Drupal\Core\Config\StorageInterface;
-+
-+/**
-+ * Extends the ConfigInstaller service for recipes.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeConfigInstaller extends ConfigInstaller {
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function installRecipeConfig(ConfigConfigurator $recipe_config): void {
-+    $storage = $recipe_config->getConfigStorage();
-+
-+    // Build the list of possible configuration to create.
-+    $list = $storage->listAll();
-+
-+    $enabled_extensions = $this->getEnabledExtensions();
-+    $existing_config = $this->getActiveStorages()->listAll();
-+
-+    // Filter the list of configuration to only include configuration that
-+    // should be created.
-+    $list = array_filter($list, function ($config_name) use ($existing_config) {
-+      // Only list configuration that:
-+      // - does not already exist
-+      return !in_array($config_name, $existing_config);
-+    });
-+
-+    // If there is nothing to do.
-+    if (empty($list)) {
-+      return;
-+    }
-+
-+    $all_config = array_merge($existing_config, $list);
-+    $all_config = array_combine($all_config, $all_config);
-+    $config_to_create = $storage->readMultiple($list);
-+
-+    // Sort $config_to_create in the order of the least dependent first.
-+    $dependency_manager = new ConfigDependencyManager();
-+    $dependency_manager->setData($config_to_create);
-+    $config_to_create = array_merge(array_flip($dependency_manager->sortAll()), $config_to_create);
-+
-+    foreach ($config_to_create as $config_name => $data) {
-+      if (!$this->validateDependencies($config_name, $data, $enabled_extensions, $all_config)) {
-+        throw new RecipeUnmetDependenciesException($config_name, sprintf("The configuration '%s' has unmet dependencies", $config_name));
-+      }
-+    }
-+
-+    // Create the optional configuration if there is any left after filtering.
-+    if (!empty($config_to_create)) {
-+      $this->createConfiguration(StorageInterface::DEFAULT_COLLECTION, $config_to_create);
-+    }
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php
-new file mode 100644
-index 00000000..d2808d16
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigStorageWrapper.php
-@@ -0,0 +1,156 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Core\Config\NullStorage;
-+use Drupal\Core\Config\StorageInterface;
-+
-+/**
-+ * Merges two storages together.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeConfigStorageWrapper implements StorageInterface {
-+
-+  /**
-+   * @param \Drupal\Core\Config\StorageInterface $storageA
-+   *   First config storage to wrap.
-+   * @param \Drupal\Core\Config\StorageInterface $storageB
-+   *   Second config storage to wrap.
-+   * @param string $collection
-+   *   (optional) The collection to store configuration in. Defaults to the
-+   *   default collection.
-+   */
-+  public function __construct(protected readonly StorageInterface $storageA, protected readonly StorageInterface $storageB, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) {
-+  }
-+
-+  /**
-+   * Creates a single config storage for an array of storages.
-+   *
-+   * If the same configuration is contained in multiple storages then the
-+   * version returned is from the first storage supplied in the $storages array.
-+   *
-+   * @param \Drupal\Core\Config\StorageInterface[] $storages
-+   *   An array of storages to merge into a single storage.
-+   *
-+   * @return \Drupal\Core\Config\StorageInterface
-+   *   A config storage that represents a merge of all the provided storages.
-+   */
-+  public static function createStorageFromArray(array $storages): StorageInterface {
-+    // If storages is empty use the NullStorage to represent an empty storage.
-+    if (empty($storages)) {
-+      return new NullStorage();
-+    }
-+
-+    // When there is only one storage there is no point wrapping it.
-+    if (count($storages) === 1) {
-+      return reset($storages);
-+    }
-+
-+    // Reduce all the storages to a single RecipeConfigStorageWrapper object.
-+    // The storages are prioritized in the order they are added to $storages.
-+    return array_reduce($storages, fn(StorageInterface $carry, StorageInterface $storage) => new static($carry, $storage), new static(
-+      array_shift($storages),
-+      array_shift($storages)
-+    ));
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function exists($name): bool {
-+    return $this->storageA->exists($name) || $this->storageB->exists($name);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function read($name): array|bool {
-+    return $this->storageA->read($name) ?: $this->storageB->read($name);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function readMultiple(array $names): array {
-+    // If both storageA and storageB contain the same configuration, the value
-+    // for storageA takes precedence.
-+    return array_merge($this->storageB->readMultiple($names), $this->storageA->readMultiple($names));
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function write($name, array $data): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function delete($name): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function rename($name, $new_name): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function encode($data): string {
-+    return $this->storageA->encode($data);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function decode($raw): array {
-+    return $this->storageA->decode($raw);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function listAll($prefix = ''): array {
-+    return array_unique(array_merge($this->storageA->listAll($prefix), $this->storageB->listAll($prefix)));
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function deleteAll($prefix = ''): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function createCollection($collection): static {
-+    return new static(
-+      $this->storageA->createCollection($collection),
-+      $this->storageB->createCollection($collection),
-+      $collection
-+    );
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function getAllCollectionNames(): array {
-+    return array_unique(array_merge($this->storageA->getAllCollectionNames(), $this->storageB->getAllCollectionNames()));
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function getCollectionName(): string {
-+    return $this->collection;
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php
-new file mode 100644
-index 00000000..f541675e
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeConfigurator.php
-@@ -0,0 +1,24 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeConfigurator {
-+
-+  public readonly array $recipes;
-+
-+  /**
-+   * @param string[] $recipes
-+   *   A list of recipes for a recipe to apply. The recipes will be applied in
-+   *   the order listed.
-+   * @param \Drupal\Core\Recipe\RecipeDiscovery $recipeDiscovery
-+   *   Recipe discovery.
-+   */
-+  public function __construct(array $recipes, RecipeDiscovery $recipeDiscovery) {
-+    $this->recipes = array_map([$recipeDiscovery, 'getRecipe'], $recipes);
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php
-new file mode 100644
-index 00000000..a83025bb
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeDiscovery.php
-@@ -0,0 +1,46 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Component\Assertion\Inspector;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeDiscovery {
-+
-+  /**
-+   * Constructs a recipe discovery object.
-+   *
-+   * @param array $paths
-+   *   An array of paths where to search for recipes. The path will be searched
-+   *   folders containing a recipe.yml. There will be no traversal further into
-+   *   the directory structure.
-+   */
-+  public function __construct(protected readonly array $paths) {
-+    assert(Inspector::assertAllStrings($paths), 'Paths must be strings.');
-+  }
-+
-+  /**
-+   * Constructs a RecipeDiscovery object.
-+   *
-+   * @param string $name
-+   *   The machine name of the recipe to find.
-+   *
-+   * @return \Drupal\Core\Recipe\Recipe
-+   *   The recipe object.
-+   *
-+   * @throws \Drupal\Core\Recipe\UnknownRecipeException
-+   *   Thrown when the recipe cannot be found.
-+   */
-+  public function getRecipe(string $name): Recipe {
-+    foreach ($this->paths as $path) {
-+      if (file_exists($path . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR . 'recipe.yml')) {
-+        return Recipe::createFromDirectory($path . DIRECTORY_SEPARATOR . $name);
-+      }
-+    }
-+    throw new UnknownRecipeException($name, $this->paths, sprintf("Can not find the %s recipe, search paths: %s", $name, implode(', ', $this->paths)));
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php
-new file mode 100644
-index 00000000..4dfaf80d
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeExtensionConfigStorage.php
-@@ -0,0 +1,144 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Core\Config\FileStorage;
-+use Drupal\Core\Config\StorageInterface;
-+
-+/**
-+ * Allows the recipe to select configuration from the module.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeExtensionConfigStorage implements StorageInterface {
-+
-+  protected readonly StorageInterface $storage;
-+
-+  /**
-+   * @param string $extensionPath
-+   *   The path extension to read configuration from
-+   * @param array $configNames
-+   *   The list of config to read from the extension. An empty array means all
-+   *   configuration.
-+   * @param string $collection
-+   *   (optional) The collection to store configuration in. Defaults to the
-+   *   default collection.
-+   */
-+  public function __construct(protected readonly string $extensionPath, protected readonly array $configNames, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) {
-+    $this->storage = new RecipeConfigStorageWrapper(
-+      new FileStorage($this->extensionPath . '/config/install', $this->collection),
-+      new FileStorage($this->extensionPath . '/config/optional', $this->collection),
-+      $collection
-+    );
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function exists($name): bool {
-+    if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) {
-+      return FALSE;
-+    }
-+    return $this->storage->exists($name);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function read($name): array|bool {
-+    if (!empty($this->configNames) && !in_array($name, $this->configNames, TRUE)) {
-+      return FALSE;
-+    }
-+    return $this->storage->read($name);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function readMultiple(array $names): array {
-+    if (!empty($this->configNames)) {
-+      $names = array_intersect($this->configNames, $names);
-+    }
-+    return $this->storage->readMultiple($names);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function write($name, array $data): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function delete($name): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function rename($name, $new_name): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function encode($data): string {
-+    return $this->storage->encode($data);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function decode($raw): array {
-+    return $this->storage->decode($raw);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function listAll($prefix = ''): array {
-+    $names = $this->storage->listAll($prefix);
-+    if (!empty($this->configNames)) {
-+      $names = array_intersect($this->configNames, $names);
-+    }
-+    return $names;
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function deleteAll($prefix = ''): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function createCollection($collection): static {
-+    return new static(
-+      $this->extensionPath,
-+      $this->configNames,
-+      $collection
-+    );
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function getAllCollectionNames(): array {
-+    return $this->storage->getAllCollectionNames();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function getCollectionName(): string {
-+    return $this->collection;
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeFileException.php b/core/lib/Drupal/Core/Recipe/RecipeFileException.php
-new file mode 100644
-index 00000000..e3f6cffc
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeFileException.php
-@@ -0,0 +1,11 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+/**
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeFileException extends \RuntimeException {
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php
-new file mode 100644
-index 00000000..e927466a
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeMissingExtensionsException.php
-@@ -0,0 +1,32 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Component\Assertion\Inspector;
-+
-+/**
-+ * Exception thrown when recipes contain or depend on missing extensions.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeMissingExtensionsException extends \RuntimeException {
-+
-+  /**
-+   * Constructs a RecipeMissingExtensionsException.
-+   *
-+   * @param array $extensions
-+   *   The list of missing extensions.
-+   * @param string $message
-+   *   [optional] The Exception message to throw.
-+   * @param int $code
-+   *   [optional] The Exception code.
-+   * @param null|\Throwable $previous
-+   *   [optional] The previous throwable used for the exception chaining.
-+   */
-+  public function __construct(public readonly array $extensions, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
-+    assert(Inspector::assertAllStrings($extensions), 'Extension names must be strings.');
-+    parent::__construct($message, $code, $previous);
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php
-new file mode 100644
-index 00000000..02cdca54
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeOverrideConfigStorage.php
-@@ -0,0 +1,131 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Core\Config\StorageInterface;
-+
-+/**
-+ * Wraps a config storage to allow recipe provided configuration to override it.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeOverrideConfigStorage implements StorageInterface {
-+
-+  /**
-+   * @param \Drupal\Core\Config\StorageInterface $recipeStorage
-+   *   The recipe's configuration storage.
-+   * @param \Drupal\Core\Config\StorageInterface $wrappedStorage
-+   *   The storage to override.
-+   * @param string $collection
-+   *   (optional) The collection to store configuration in. Defaults to the
-+   *   default collection.
-+   */
-+  public function __construct(protected readonly StorageInterface $recipeStorage, protected readonly StorageInterface $wrappedStorage, protected readonly string $collection = StorageInterface::DEFAULT_COLLECTION) {
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function exists($name): bool {
-+    return $this->wrappedStorage->exists($name);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function read($name): array|bool {
-+    if ($this->wrappedStorage->exists($name) && $this->recipeStorage->exists($name)) {
-+      return $this->recipeStorage->read($name);
-+    }
-+    return $this->wrappedStorage->read($name);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function readMultiple(array $names): array {
-+    $data = $this->wrappedStorage->readMultiple($names);
-+    foreach ($data as $name => $value) {
-+      if ($this->recipeStorage->exists($name)) {
-+        $data[$name] = $this->recipeStorage->read($name);
-+      }
-+    }
-+    return $data;
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function write($name, array $data): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function delete($name): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function rename($name, $new_name): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function encode($data): string {
-+    return $this->wrappedStorage->encode($data);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function decode($raw): array {
-+    return $this->wrappedStorage->decode($raw);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function listAll($prefix = ''): array {
-+    return $this->wrappedStorage->listAll($prefix);
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function deleteAll($prefix = ''): bool {
-+    throw new \BadMethodCallException();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function createCollection($collection): static {
-+    return new static(
-+      $this->recipeStorage->createCollection($collection),
-+      $this->wrappedStorage->createCollection($collection),
-+      $collection
-+    );
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function getAllCollectionNames(): array {
-+    return $this->wrappedStorage->getAllCollectionNames();
-+  }
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function getCollectionName(): string {
-+    return $this->collection;
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php
-new file mode 100644
-index 00000000..ce0bd543
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipePreExistingConfigException.php
-@@ -0,0 +1,26 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+/**
-+ * Exception thrown when a recipe has configuration that exists already.
-+ */
-+class RecipePreExistingConfigException extends \RuntimeException {
-+
-+  /**
-+   * Constructs a RecipePreExistingConfigException.
-+   *
-+   * @param string $configName
-+   *   The configuration name that has missing dependencies.
-+   * @param string $message
-+   *   [optional] The Exception message to throw.
-+   * @param int $code
-+   *   [optional] The Exception code.
-+   * @param null|\Throwable $previous
-+   *   [optional] The previous throwable used for the exception chaining.
-+   */
-+  public function __construct(public readonly string $configName, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
-+    parent::__construct($message, $code, $previous);
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeRunner.php b/core/lib/Drupal/Core/Recipe/RecipeRunner.php
-new file mode 100644
-index 00000000..5670c1f8
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeRunner.php
-@@ -0,0 +1,124 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+use Drupal\Core\Config\FileStorage;
-+use Drupal\Core\Config\InstallStorage;
-+use Drupal\Core\Config\StorageInterface;
-+
-+/**
-+ * Applies a recipe.
-+ *
-+ * This class currently static and use \Drupal::service() in order to put off
-+ * having to solve issues caused by container rebuilds during module install and
-+ * configuration import.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeRunner {
-+
-+  /**
-+   * @param \Drupal\Core\Recipe\Recipe $recipe
-+   *   The recipe to apply.
-+   */
-+  public static function processRecipe(Recipe $recipe): void {
-+    static::processRecipes($recipe->recipes);
-+    static::processInstall($recipe->install, $recipe->config->getConfigStorage());
-+    static::processConfiguration($recipe->config);
-+    static::processContent($recipe->content);
-+  }
-+
-+  /**
-+   * Applies any recipes listed by the recipe.
-+   *
-+   * @param \Drupal\Core\Recipe\RecipeConfigurator $recipes
-+   *   The list of recipes to apply.
-+   */
-+  protected static function processRecipes(RecipeConfigurator $recipes): void {
-+    foreach ($recipes->recipes as $recipe) {
-+      static::processRecipe($recipe);
-+    }
-+  }
-+
-+  /**
-+   * Installs the extensions.
-+   *
-+   * @param \Drupal\Core\Recipe\InstallConfigurator $install
-+   *   The list of extensions to install.
-+   * @param \Drupal\Core\Config\StorageInterface $recipeConfigStorage
-+   *   The recipe's configuration storage. Used to override extension provided
-+   *   configuration.
-+   */
-+  protected static function processInstall(InstallConfigurator $install, StorageInterface $recipeConfigStorage): void {
-+    foreach ($install->modules as $name) {
-+      // Disable configuration entity install but use the config directory from
-+      // the module.
-+      \Drupal::service('config.installer')->setSyncing(TRUE);
-+      $default_install_path = \Drupal::service('extension.list.module')->get($name)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
-+      // Allow the recipe to override simple configuration from the module.
-+      $storage = new RecipeOverrideConfigStorage(
-+        $recipeConfigStorage,
-+        new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION)
-+      );
-+      \Drupal::service('config.installer')->setSourceStorage($storage);
-+
-+      \Drupal::service('module_installer')->install([$name]);
-+      \Drupal::service('config.installer')->setSyncing(FALSE);
-+    }
-+
-+    // Themes can depend on modules so have to be installed after modules.
-+    foreach ($install->themes as $name) {
-+      // Disable configuration entity install.
-+      \Drupal::service('config.installer')->setSyncing(TRUE);
-+      $default_install_path = \Drupal::service('extension.list.theme')->get($name)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
-+      // Allow the recipe to override simple configuration from the theme.
-+      $storage = new RecipeOverrideConfigStorage(
-+        $recipeConfigStorage,
-+        new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION)
-+      );
-+      \Drupal::service('config.installer')->setSourceStorage($storage);
-+
-+      \Drupal::service('theme_installer')->install([$name]);
-+      \Drupal::service('config.installer')->setSyncing(FALSE);
-+    }
-+  }
-+
-+  /**
-+   * Creates configuration and applies configuration actions.
-+   *
-+   * @param \Drupal\Core\Recipe\ConfigConfigurator $config
-+   *   The config configurator from the recipe.
-+   */
-+  protected static function processConfiguration(ConfigConfigurator $config): void {
-+    // @todo sort out this monstrosity.
-+    $config_installer = new RecipeConfigInstaller(
-+      \Drupal::service('config.factory'),
-+      \Drupal::service('config.storage'),
-+      \Drupal::service('config.typed'),
-+      \Drupal::service('config.manager'),
-+      \Drupal::service('event_dispatcher'),
-+      NULL,
-+      \Drupal::service('extension.path.resolver'));
-+
-+    // Create configuration that is either supplied by the recipe or listed in
-+    // the config.import section that does not exist.
-+    $config_installer->installRecipeConfig($config);
-+
-+    if (!empty($config->config['actions'])) {
-+      // Process the actions.
-+      /** @var \Drupal\Core\Config\Action\ConfigActionManager $config_action_manager */
-+      $config_action_manager = \Drupal::service('plugin.manager.config_action');
-+      foreach ($config->config['actions'] as $config_name => $actions) {
-+        foreach ($actions as $action_id => $data) {
-+          $config_action_manager->applyAction($action_id, $config_name, $data);
-+        }
-+      }
-+    }
-+  }
-+
-+  protected static function processContent(ContentConfigurator $content): void {
-+    // @todo https://www.drupal.org/project/distributions_recipes/issues/3292287
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/RecipeUnmetDependenciesException.php b/core/lib/Drupal/Core/Recipe/RecipeUnmetDependenciesException.php
-new file mode 100644
-index 00000000..71fcdc6b
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/RecipeUnmetDependenciesException.php
-@@ -0,0 +1,29 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+/**
-+ * Exception thrown when a recipe has configuration with unmet dependencies.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class RecipeUnmetDependenciesException extends \RuntimeException {
-+
-+  /**
-+   * Constructs a RecipeUnmetDependenciesException.
-+   *
-+   * @param string $configName
-+   *   The configuration name that has missing dependencies.
-+   * @param string $message
-+   *   [optional] The Exception message to throw.
-+   * @param int $code
-+   *   [optional] The Exception code.
-+   * @param null|\Throwable $previous
-+   *   [optional] The previous throwable used for the exception chaining.
-+   */
-+  public function __construct(public readonly string $configName, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
-+    parent::__construct($message, $code, $previous);
-+  }
-+
-+}
-diff --git a/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php
-new file mode 100644
-index 00000000..9351ba4d
---- /dev/null
-+++ b/core/lib/Drupal/Core/Recipe/UnknownRecipeException.php
-@@ -0,0 +1,29 @@
-+<?php
-+
-+namespace Drupal\Core\Recipe;
-+
-+/**
-+ * Exception thrown when recipe is can not be found.
-+ *
-+ * @internal
-+ *   This API is experimental.
-+ */
-+final class UnknownRecipeException extends \RuntimeException {
-+
-+  /**
-+   * @param string $recipe
-+   *   The recipe's name.
-+   * @param array $searchPaths
-+   *   The paths searched for the recipe.
-+   * @param string $message
-+   *   (optional) The exception message.
-+   * @param int $code
-+   *   (optional) The exception code.
-+   * @param \Throwable|null $previous
-+   *   (optional) The previous exception.
-+   */
-+  public function __construct(public readonly string $recipe, public readonly array $searchPaths, string $message = "", int $code = 0, ?\Throwable $previous = NULL) {
-+    parent::__construct($message, $code, $previous);
-+  }
-+
-+}
-diff --git a/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml
-new file mode 100644
-index 00000000..2f1dd51d
---- /dev/null
-+++ b/core/modules/config/tests/config_action_duplicate_test/config_action_duplicate_test.info.yml
-@@ -0,0 +1,4 @@
-+name: 'Config action duplicate test'
-+type: module
-+package: Testing
-+version: VERSION
-diff --git a/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php
-new file mode 100644
-index 00000000..d19474c6
---- /dev/null
-+++ b/core/modules/config/tests/config_action_duplicate_test/src/Plugin/ConfigAction/DuplicateConfigAction.php
-@@ -0,0 +1,26 @@
-+<?php
-+
-+namespace Drupal\config_duplicate_action_test\Plugin\ConfigAction;
-+
-+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
-+
-+/**
-+ * @ConfigAction(
-+ *   id = "config_action_duplicate_test:config_test.dynamic:setProtectedProperty",
-+ *   admin_label = @Translation("A duplicate config action"),
-+ *   entity_types = {
-+ *     "config_test"
-+ *   }
-+ * )
-+ */
-+final class DuplicateConfigAction implements ConfigActionPluginInterface {
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function apply(string $configName, mixed $value): void {
-+    // This method should never be called.
-+    throw new \BadMethodCallException();
-+  }
-+
-+}
-diff --git a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
-index 2a707fac..b6b59ec5 100644
---- a/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
-+++ b/core/modules/config/tests/config_test/config/schema/config_test.schema.yml
-@@ -24,6 +24,9 @@ config_test_dynamic:
-     protected_property:
-       type: string
-       label: 'Protected property'
-+    array_property:
-+      type: ignore
-+      label: 'Array property'
- 
- config_test.dynamic.*:
-   type: config_test_dynamic
-diff --git a/core/modules/config/tests/config_test/config_test.module b/core/modules/config/tests/config_test/config_test.module
-index ac06237e..24647088 100644
---- a/core/modules/config/tests/config_test/config_test.module
-+++ b/core/modules/config/tests/config_test/config_test.module
-@@ -40,4 +40,8 @@ function config_test_entity_type_alter(array &$entity_types) {
-   if (\Drupal::service('state')->get('config_test.lookup_keys', FALSE)) {
-     $entity_types['config_test']->set('lookup_keys', ['uuid', 'style']);
-   }
-+
-+  if (\Drupal::service('state')->get('config_test.class_override', FALSE)) {
-+    $entity_types['config_test']->setClass(\Drupal::service('state')->get('config_test.class_override'));
-+  }
- }
-diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php
-new file mode 100644
-index 00000000..b51e41af
---- /dev/null
-+++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedMethodName.php
-@@ -0,0 +1,17 @@
-+<?php
-+
-+namespace Drupal\config_test\ConfigActionErrorEntity;
-+
-+use Drupal\config_test\Entity\ConfigTest;
-+use Drupal\Core\Config\Action\Attribute\ActionMethod;
-+
-+/**
-+ * Test entity class.
-+ */
-+class DuplicatePluralizedMethodName extends ConfigTest {
-+
-+  #[ActionMethod(pluralize: 'testMethod')]
-+  public function testMethod() {
-+  }
-+
-+}
-diff --git a/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php
-new file mode 100644
-index 00000000..98c5d075
---- /dev/null
-+++ b/core/modules/config/tests/config_test/src/ConfigActionErrorEntity/DuplicatePluralizedOtherMethodName.php
-@@ -0,0 +1,21 @@
-+<?php
-+
-+namespace Drupal\config_test\ConfigActionErrorEntity;
-+
-+use Drupal\config_test\Entity\ConfigTest;
-+use Drupal\Core\Config\Action\Attribute\ActionMethod;
-+
-+/**
-+ * Test entity class.
-+ */
-+class DuplicatePluralizedOtherMethodName extends ConfigTest {
-+
-+  #[ActionMethod(pluralize: 'testMethod2')]
-+  public function testMethod() {
-+  }
-+
-+  #[ActionMethod()]
-+  public function testMethod2() {
-+  }
-+
-+}
-diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
-index 6b9e8007..b461828a 100644
---- a/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
-+++ b/core/modules/config/tests/config_test/src/Entity/ConfigQueryTest.php
-@@ -46,4 +46,13 @@ class ConfigQueryTest extends ConfigTest {
-    */
-   public $array = [];
- 
-+  /**
-+   * {@inheritdoc}
-+   */
-+  public function concatProtectedProperty(string $value1, string $value2): static {
-+    // This method intentionally does not have the config action attribute to
-+    // ensure it is still discovered.
-+    return parent::concatProtectedProperty($value1, $value2);
-+  }
-+
- }
-diff --git a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
-index f8ceea42..5f446c4d 100644
---- a/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
-+++ b/core/modules/config/tests/config_test/src/Entity/ConfigTest.php
-@@ -2,10 +2,12 @@
- 
- namespace Drupal\config_test\Entity;
- 
-+use Drupal\Core\Config\Action\Attribute\ActionMethod;
- use Drupal\Core\Config\Entity\ConfigEntityBase;
- use Drupal\config_test\ConfigTestInterface;
- use Drupal\Core\Config\Entity\ConfigEntityInterface;
- use Drupal\Core\Entity\EntityStorageInterface;
-+use Drupal\Core\StringTranslation\TranslatableMarkup;
- 
- /**
-  * Defines the ConfigTest configuration entity.
-@@ -36,6 +38,7 @@
-  *     "size",
-  *     "size_value",
-  *     "protected_property",
-+ *     "array_property",
-  *   },
-  *   links = {
-  *     "edit-form" = "/admin/structure/config_test/manage/{config_test}",
-@@ -83,6 +86,13 @@ class ConfigTest extends ConfigEntityBase implements ConfigTestInterface {
-    */
-   protected $protected_property;
- 
-+  /**
-+   * An array property of the configuration entity.
-+   *
-+   * @var array
-+   */
-+  protected array $array_property = [];
-+
-   /**
-    * {@inheritdoc}
-    */
-@@ -188,4 +198,130 @@ public function isInstallable() {
-     return $this->id != 'isinstallable' || \Drupal::state()->get('config_test.isinstallable');
-   }
- 
-+  /**
-+   * Sets the protected property value.
-+   *
-+   * @param $value
-+   *   The value to set.
-+   *
-+   * @return $this
-+   *   The config entity.
-+   */
-+  #[ActionMethod(pluralize: FALSE)]
-+  public function setProtectedProperty(string $value): static {
-+    $this->protected_property = $value;
-+    return $this;
-+  }
-+
-+  /**
-+   * Gets the protected property value.
-+   *
-+   * @return string
-+   *   The protected property value.
-+   */
-+  public function getProtectedProperty(): string {
-+    return $this->protected_property;
-+  }
-+
-+  /**
-+   * Concatenates the two params and sets the protected property value.
-+   *
-+   * @param $value1
-+   *   The first value to concatenate.
-+   * @param $value2
-+   *   The second value to concatenate.
-+   *
-+   * @return $this
-+   *   The config entity.
-+   */
-+  #[ActionMethod()]
-+  public function concatProtectedProperty(string $value1, string $value2): static {
-+    $this->protected_property = $value1 . $value2;
-+    return $this;
-+  }
-+
-+  /**
-+   * Concatenates up to two params and sets the protected property value.
-+   *
-+   * @param $value1
-+   *   The first value to concatenate.
-+   * @param $value2
-+   *   (optional) The second value to concatenate. Defaults to ''.
-+   *
-+   * @return $this
-+   *   The config entity.
-+   */
-+  #[ActionMethod(pluralize: FALSE)]
-+  public function concatProtectedPropertyOptional(string $value1, string $value2 = ''): static {
-+    $this->protected_property = $value1 . $value2;
-+    return $this;
-+  }
-+
-+  /**
-+   * Appends to protected property.
-+   *
-+   * @param $value
-+   *   The value to append.
-+   *
-+   * @return $this
-+   *   The config entity.
-+   */
-+  #[ActionMethod()]
-+  public function append(string $value): static {
-+    $this->protected_property .= $value;
-+    return $this;
-+  }
-+
-+  /**
-+   * Sets the protected property to a default value.
-+   *
-+   * @return $this
-+   *   The config entity.
-+   */
-+  #[ActionMethod(pluralize: FALSE, adminLabel: new TranslatableMarkup('Set default name'))]
-+  public function defaultProtectedProperty(): static {
-+    $this->protected_property = 'Set by method';
-+    return $this;
-+  }
-+
-+  /**
-+   * Adds a value to the array property.
-+   *
-+   * @param mixed $value
-+   *   The value to add.
-+   *
-+   * @return $this
-+   *   The config entity.
-+   */
-+  #[ActionMethod(pluralize: 'addToArrayMultipleTimes')]
-+  public function addToArray(mixed $value): static {
-+    $this->array_property[] = $value;
-+    return $this;
-+  }
-+
-+  /**
-+   * Gets the array property value.
-+   *
-+   * @return array
-+   *   The array property value.
-+   */
-+  public function getArrayProperty(): array {
-+    return $this->array_property;
-+  }
-+
-+  /**
-+   * Sets the array property.
-+   *
-+   * @param $value
-+   *   The value to set.
-+   *
-+   * @return $this
-+   *   The config entity.
-+   */
-+  #[ActionMethod(pluralize: FALSE)]
-+  public function setArray(array $value): static {
-+    $this->array_property = $value;
-+    return $this;
-+  }
-+
- }
-diff --git a/core/modules/filter/src/Entity/FilterFormat.php b/core/modules/filter/src/Entity/FilterFormat.php
-index cf03af72..aa324306 100644
---- a/core/modules/filter/src/Entity/FilterFormat.php
-+++ b/core/modules/filter/src/Entity/FilterFormat.php
-@@ -3,9 +3,11 @@
- namespace Drupal\filter\Entity;
- 
- use Drupal\Component\Plugin\PluginInspectionInterface;
-+use Drupal\Core\Config\Action\Attribute\ActionMethod;
- use Drupal\Core\Config\Entity\ConfigEntityBase;
- use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
- use Drupal\Core\Entity\EntityStorageInterface;
-+use Drupal\Core\StringTranslation\TranslatableMarkup;
- use Drupal\filter\FilterFormatInterface;
- use Drupal\filter\FilterPluginCollection;
- use Drupal\filter\Plugin\FilterInterface;
-@@ -159,6 +161,7 @@ public function getPluginCollections() {
-   /**
-    * {@inheritdoc}
-    */
-+  #[ActionMethod(adminLabel: new TranslatableMarkup('Sets configuration for a filter plugin'))]
-   public function setFilterConfig($instance_id, array $configuration) {
-     $this->filters[$instance_id] = $configuration;
-     if (isset($this->filterCollection)) {
-diff --git a/core/modules/user/src/Entity/Role.php b/core/modules/user/src/Entity/Role.php
-index 18194f33..68ddd501 100644
---- a/core/modules/user/src/Entity/Role.php
-+++ b/core/modules/user/src/Entity/Role.php
-@@ -2,8 +2,10 @@
- 
- namespace Drupal\user\Entity;
- 
-+use Drupal\Core\Config\Action\Attribute\ActionMethod;
- use Drupal\Core\Config\Entity\ConfigEntityBase;
- use Drupal\Core\Entity\EntityStorageInterface;
-+use Drupal\Core\StringTranslation\TranslatableMarkup;
- use Drupal\user\RoleInterface;
- 
- /**
-@@ -126,6 +128,7 @@ public function hasPermission($permission) {
-   /**
-    * {@inheritdoc}
-    */
-+  #[ActionMethod(adminLabel: new TranslatableMarkup('Add permission to role'))]
-   public function grantPermission($permission) {
-     if ($this->isAdmin()) {
-       return $this;
-diff --git a/core/recipes/example/composer.json b/core/recipes/example/composer.json
-new file mode 100644
-index 00000000..1d231ba7
---- /dev/null
-+++ b/core/recipes/example/composer.json
-@@ -0,0 +1,9 @@
-+{
-+  "name": "drupal_recipe/example",
-+  "description": "An example Drupal recipe description",
-+  "type": "drupal-recipe",
-+  "require": {
-+    "drupal/core": "^10.0.x-dev"
-+  },
-+  "license": "GPL-2.0-or-later"
-+}
-diff --git a/core/recipes/example/recipe.yml b/core/recipes/example/recipe.yml
-new file mode 100644
-index 00000000..2103651c
---- /dev/null
-+++ b/core/recipes/example/recipe.yml
-@@ -0,0 +1,46 @@
-+# The type key is similar to the package key in module.info.yml. It
-+# can be used by the UI to group Drupal recipes. Additionally,
-+# the type 'Site' means that the Drupal recipe will be listed in
-+# the installer.
-+type: 'Content type'
-+
-+install:
-+  # An array of modules or themes to install, if they are not already.
-+  # The system will detect if it is a theme or a module. During the
-+  # install only simple configuration from the new modules is created.
-+  # This allows the Drupal recipe control over the configuration.
-+  - node
-+  - text
-+
-+config:
-+  # A Drupal recipe can have a config directory. All configuration
-+  # is this directory will be imported after the modules have been
-+  # installed.
-+
-+  # Additionally, the Drupal recipe can install configuration entities
-+  # provided by the modules it configures. This allows them to not have
-+  # to maintain or copy this configuration. Note the examples below are
-+  # fictitious.
-+  import:
-+    node:
-+      - node.type.article
-+    # Import all configuration that is provided by the text module and any
-+    # optional configuration that depends on the text module that is provided by
-+    # modules already installed.
-+    text: *
-+
-+  # Configuration actions may be defined. The structure here should be
-+  # entity_type.ID.action. Below the user role entity type with an ID of
-+  # editor is having the permissions added. The permissions key will be
-+  # mapped to the \Drupal\user\Entity\Role::grantPermission() method.
-+  actions:
-+    user.role.editor:
-+      ensure_exists:
-+        label: 'Editor'
-+      grantPermissions:
-+        - 'delete any article content'
-+        - 'edit any article content'
-+
-+content:
-+# A Drupal recipe can have a content directory. All content in this
-+# directory will be created after the configuration is installed.
-diff --git a/core/scripts/drupal b/core/scripts/drupal
-index 891d5b81..0c9eb300 100644
---- a/core/scripts/drupal
-+++ b/core/scripts/drupal
-@@ -10,6 +10,7 @@ use Drupal\Core\Command\GenerateTheme;
- use Drupal\Core\Command\QuickStartCommand;
- use Drupal\Core\Command\InstallCommand;
- use Drupal\Core\Command\ServerCommand;
-+use Drupal\Core\Recipe\RecipeCommand;
- use Symfony\Component\Console\Application;
- 
- if (PHP_SAPI !== 'cli') {
-@@ -24,5 +25,6 @@ $application->add(new QuickStartCommand());
- $application->add(new InstallCommand($classloader));
- $application->add(new ServerCommand($classloader));
- $application->add(new GenerateTheme());
-+$application->add(new RecipeCommand($classloader));
- 
- $application->run();
-diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
-new file mode 100644
-index 00000000..28071db5
---- /dev/null
-+++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/RecipeCommandTest.php
-@@ -0,0 +1,57 @@
-+<?php
-+
-+namespace Drupal\FunctionalTests\Core\Recipe;
-+
-+use Drupal\Tests\BrowserTestBase;
-+use Symfony\Component\Process\PhpExecutableFinder;
-+use Symfony\Component\Process\Process;
-+
-+/**
-+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeCommand
-+ * @group Recipe
-+ *
-+ * BrowserTestBase is used for a proper Drupal install.
-+ */
-+class RecipeCommandTest extends BrowserTestBase {
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  protected $defaultTheme = 'stark';
-+
-+  public function testRecipeCommand(): void {
-+    $this->assertFalse(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is not installed');
-+    $php_executable_finder = new PhpExecutableFinder();
-+    $php = $php_executable_finder->find();
-+
-+    $recipe_command = [
-+      $php,
-+      'core/scripts/drupal',
-+      'recipe',
-+      'core/tests/fixtures/recipes/install_node_with_config',
-+    ];
-+    $process = new Process($recipe_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->siteDirectory]);
-+    $process->setTimeout(500);
-+    $status = $process->run();
-+    $this->assertSame(0, $status);
-+    $this->assertSame('', $process->getErrorOutput());
-+    $this->assertStringContainsString('Install node with config applied successfully', $process->getOutput());
-+
-+    $this->rebuildAll();
-+    $this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
-+
-+    // Ensure recipes that fail have an exception message.
-+    $recipe_command = [
-+      $php,
-+      'core/scripts/drupal',
-+      'recipe',
-+      'core/tests/fixtures/recipes/missing_extensions',
-+    ];
-+    $process = new Process($recipe_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->siteDirectory]);
-+    $process->setTimeout(500);
-+    $status = $process->run();
-+    $this->assertSame(1, $status);
-+    $this->assertStringContainsString('Drupal\Core\Recipe\RecipeMissingExtensionsException', $process->getErrorOutput());
-+  }
-+
-+}
-diff --git a/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php
-new file mode 100644
-index 00000000..13fd4e91
---- /dev/null
-+++ b/core/tests/Drupal/KernelTests/Core/Config/Action/ConfigActionTest.php
-@@ -0,0 +1,324 @@
-+<?php
-+
-+namespace Drupal\KernelTests\Core\Config\Action;
-+
-+// cspell:ignore inflector
-+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
-+use Drupal\Component\Uuid\Uuid;
-+use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName;
-+use Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName;
-+use Drupal\Core\Config\Action\ConfigActionException;
-+use Drupal\Core\Config\Action\DuplicateConfigActionIdException;
-+use Drupal\Core\Config\Action\EntityMethodException;
-+use Drupal\KernelTests\KernelTestBase;
-+
-+/**
-+ * Tests the config action system.
-+ *
-+ * @group config
-+ */
-+class ConfigActionTest extends KernelTestBase {
-+
-+  /**
-+   * Modules to enable.
-+   *
-+   * @var array
-+   */
-+  protected static $modules = ['config_test'];
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityCreate
-+   */
-+  public function testEntityCreate(): void {
-+    $this->assertCount(0, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There are no config_test entities');
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */
-+    $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
-+    $this->assertCount(1, \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple(), 'There is 1 config_test entity');
-+    $this->assertSame('Action test', $config_test_entities['action_test']->label());
-+    $this->assertTrue(Uuid::isValid((string) $config_test_entities['action_test']->uuid()), 'Config entity assigned a valid UUID');
-+
-+    // Calling ensure exists action again will not error.
-+    $manager->applyAction('entity_create:ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test']);
-+
-+    try {
-+      $manager->applyAction('entity_create:create', 'config_test.dynamic.action_test', ['label' => 'Action test']);
-+      $this->fail('Expected exception not thrown');
-+    }
-+    catch (ConfigActionException $e) {
-+      $this->assertSame('Entity config_test.dynamic.action_test exists', $e->getMessage());
-+    }
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
-+   */
-+  public function testEntityMethod(): void {
-+    $this->installConfig('config_test');
-+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
-+
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('Default', $config_test_entity->getProtectedProperty());
-+
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    // Call a method action.
-+    $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('Test value', $config_test_entity->getProtectedProperty());
-+
-+    $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value 2');
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('Test value 2', $config_test_entity->getProtectedProperty());
-+
-+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', ['Test value ', '3']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('Test value 3', $config_test_entity->getProtectedProperty());
-+
-+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value ', '4']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('Test value 4', $config_test_entity->getProtectedProperty());
-+
-+    // Test calling an action that has 2 arguments but one is optional with an
-+    // array value.
-+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', ['Test value 5']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('Test value 5', $config_test_entity->getProtectedProperty());
-+
-+    // Test calling an action that has 2 arguments but one is optional with a
-+    // non array value.
-+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedPropertyOptional', 'config_test.dynamic.dotted.default', 'Test value 6');
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('Test value 6', $config_test_entity->getProtectedProperty());
-+
-+    // Test calling an action that expects no arguments.
-+    $manager->applyAction('entity_method:config_test.dynamic:defaultProtectedProperty', 'config_test.dynamic.dotted.default', []);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('Set by method', $config_test_entity->getProtectedProperty());
-+
-+    $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'foo');
-+    $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', 'bar');
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame(['foo', 'bar'], $config_test_entity->getArrayProperty());
-+
-+    $manager->applyAction('entity_method:config_test.dynamic:addToArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame(['foo', 'bar', ['a', 'b', 'c']], $config_test_entity->getArrayProperty());
-+
-+    $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', ['a', 'b', 'c']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame(['a', 'b', 'c'], $config_test_entity->getArrayProperty());
-+
-+    $manager->applyAction('entity_method:config_test.dynamic:setArray', 'config_test.dynamic.dotted.default', [['a', 'b', 'c'], ['a']]);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame([['a', 'b', 'c'], ['a']], $config_test_entity->getArrayProperty());
-+
-+    $config_test_entity->delete();
-+    try {
-+      $manager->applyAction('entity_method:config_test.dynamic:setProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
-+      $this->fail('Expected exception not thrown');
-+    }
-+    catch (ConfigActionException $e) {
-+      $this->assertSame('Entity config_test.dynamic.dotted.default does not exist', $e->getMessage());
-+    }
-+
-+    // Test custom and default admin labels.
-+    $this->assertSame('Test configuration append', (string) $manager->getDefinition('entity_method:config_test.dynamic:append')['admin_label']);
-+    $this->assertSame('Set default name', (string) $manager->getDefinition('entity_method:config_test.dynamic:defaultProtectedProperty')['admin_label']);
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
-+   */
-+  public function testPluralizedEntityMethod(): void {
-+    $this->installConfig('config_test');
-+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
-+
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    // Call a pluralized method action.
-+    $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', ['a', 'b', 'c', 'd']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame(['a', 'b', 'c', 'd'], $config_test_entity->getArrayProperty());
-+
-+    $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', [['foo'], 'bar']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame(['a', 'b', 'c', 'd', ['foo'], 'bar'], $config_test_entity->getArrayProperty());
-+
-+    $config_test_entity->setProtectedProperty('')->save();
-+    $manager->applyAction('entity_method:config_test.dynamic:appends', 'config_test.dynamic.dotted.default', ['1', '2', '3']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('123', $config_test_entity->getProtectedProperty());
-+
-+    // Test that the inflector converts to a good plural form.
-+    $config_test_entity->setProtectedProperty('')->save();
-+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperties', 'config_test.dynamic.dotted.default', [['1', '2'], ['3', '4']]);
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('dotted.default');
-+    $this->assertSame('34', $config_test_entity->getProtectedProperty());
-+
-+    $this->assertTrue($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedProperty'), 'The setProtectedProperty action exists');
-+    // cspell:ignore Propertys
-+    $this->assertFalse($manager->hasDefinition('entity_method:config_test.dynamic:setProtectedPropertys'), 'There is no automatically pluralized version of the setProtectedProperty action');
-+
-+    // Admin label for pluralized form.
-+    $this->assertSame('Test configuration append (multiple calls)', (string) $manager->getDefinition('entity_method:config_test.dynamic:appends')['admin_label']);
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
-+   */
-+  public function testPluralizedEntityMethodException(): void {
-+    $this->installConfig('config_test');
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    $this->expectException(EntityMethodException::class);
-+    $this->expectExceptionMessage('The pluralized entity method config action \'entity_method:config_test.dynamic:addToArrayMultipleTimes\' requires an array value in order to call Drupal\config_test\Entity\ConfigTest::addToArray() multiple times');
-+    $manager->applyAction('entity_method:config_test.dynamic:addToArrayMultipleTimes', 'config_test.dynamic.dotted.default', 'Test value');
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver
-+   */
-+  public function testDuplicatePluralizedMethodNameException(): void {
-+    \Drupal::state()->set('config_test.class_override', DuplicatePluralizedMethodName::class);
-+    \Drupal::entityTypeManager()->clearCachedDefinitions();
-+    $this->installConfig('config_test');
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    $this->expectException(EntityMethodException::class);
-+    $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedMethodName::testMethod(). The existing action is for the ::testMethod() method');
-+    $manager->getDefinitions();
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\EntityMethodDeriver
-+   */
-+  public function testDuplicatePluralizedOtherMethodNameException(): void {
-+    \Drupal::state()->set('config_test.class_override', DuplicatePluralizedOtherMethodName::class);
-+    \Drupal::entityTypeManager()->clearCachedDefinitions();
-+    $this->installConfig('config_test');
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    $this->expectException(EntityMethodException::class);
-+    $this->expectExceptionMessage('Duplicate action can not be created for ID \'config_test.dynamic:testMethod2\' for Drupal\config_test\ConfigActionErrorEntity\DuplicatePluralizedOtherMethodName::testMethod2(). The existing action is for the ::testMethod() method');
-+    $manager->getDefinitions();
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\EntityMethod
-+   */
-+  public function testEntityMethodException(): void {
-+    $this->installConfig('config_test');
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    $this->expectException(EntityMethodException::class);
-+    $this->expectExceptionMessage('Entity method config action \'entity_method:config_test.dynamic:concatProtectedProperty\' requires an array value. The number of parameters or required parameters for Drupal\config_test\Entity\ConfigTest::concatProtectedProperty() is not 1');
-+    $manager->applyAction('entity_method:config_test.dynamic:concatProtectedProperty', 'config_test.dynamic.dotted.default', 'Test value');
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\Plugin\ConfigAction\SimpleConfigUpdate
-+   */
-+  public function testSimpleConfigUpdate(): void {
-+    $this->installConfig('config_test');
-+    $this->assertSame('bar', $this->config('config_test.system')->get('foo'));
-+
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    // Call the simple config update action.
-+    $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']);
-+    $this->assertSame('Yay!', $this->config('config_test.system')->get('foo'));
-+
-+    try {
-+      $manager->applyAction('simple_config_update', 'config_test.system', 'Test');
-+      $this->fail('Expected exception not thrown');
-+    }
-+    catch (ConfigActionException $e) {
-+      $this->assertSame('Config config_test.system can not be updated because $value is not an array', $e->getMessage());
-+    }
-+
-+    $this->config('config_test.system')->delete();
-+    try {
-+      $manager->applyAction('simple_config_update', 'config_test.system', ['foo' => 'Yay!']);
-+      $this->fail('Expected exception not thrown');
-+    }
-+    catch (ConfigActionException $e) {
-+      $this->assertSame('Config config_test.system does not exist so can not be updated', $e->getMessage());
-+    }
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
-+   */
-+  public function testShorthandActionIds(): void {
-+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
-+    $this->assertCount(0, $storage->loadMultiple(), 'There are no config_test entities');
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']);
-+    /** @var \Drupal\config_test\Entity\ConfigTest[] $config_test_entities */
-+    $config_test_entities = $storage->loadMultiple();
-+    $this->assertCount(1, $config_test_entities, 'There is 1 config_test entity');
-+    $this->assertSame('Action test', $config_test_entities['action_test']->label());
-+
-+    $this->assertSame('', $config_test_entities['action_test']->getProtectedProperty());
-+
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    // Call a method action.
-+    $manager->applyAction('setProtectedProperty', 'config_test.dynamic.action_test', 'Test value');
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('action_test');
-+    $this->assertSame('Test value', $config_test_entity->getProtectedProperty());
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
-+   */
-+  public function testDuplicateShorthandActionIds(): void {
-+    $this->enableModules(['config_action_duplicate_test']);
-+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
-+    $manager = $this->container->get('plugin.manager.config_action');
-+    $this->expectException(DuplicateConfigActionIdException::class);
-+    $this->expectExceptionMessage("The plugins 'entity_method:config_test.dynamic:setProtectedProperty' and 'config_action_duplicate_test:config_test.dynamic:setProtectedProperty' both resolve to the same shorthand action ID for the 'config_test' entity type");
-+    $manager->applyAction('ensure_exists', 'config_test.dynamic.action_test', ['label' => 'Action test', 'protected_property' => '']);
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\ConfigActionManager::getShorthandActionIdsForEntityType()
-+   */
-+  public function testParentAttributes(): void {
-+    $definitions = $this->container->get('plugin.manager.config_action')->getDefinitions();
-+    // The \Drupal\config_test\Entity\ConfigQueryTest::concatProtectedProperty()
-+    // does not have an attribute but the parent does so this is discovered.
-+    $this->assertArrayHasKey('entity_method:config_test.query:concatProtectedProperty', $definitions);
-+  }
-+
-+  /**
-+   * @see \Drupal\Core\Config\Action\ConfigActionManager
-+   */
-+  public function testMissingAction(): void {
-+    $this->expectException(PluginNotFoundException::class);
-+    $this->expectErrorMessageMatches('/^The "does_not_exist" plugin does not exist/');
-+    $this->container->get('plugin.manager.config_action')->applyAction('does_not_exist', 'config_test.system', ['foo' => 'Yay!']);
-+  }
-+
-+}
-diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php
-new file mode 100644
-index 00000000..faf87866
---- /dev/null
-+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeDiscoveryTest.php
-@@ -0,0 +1,58 @@
-+<?php
-+
-+namespace Drupal\KernelTests\Core\Recipe;
-+
-+use Drupal\Core\Recipe\RecipeDiscovery;
-+use Drupal\Core\Recipe\UnknownRecipeException;
-+use Drupal\KernelTests\KernelTestBase;
-+
-+/**
-+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeDiscovery
-+ * @group Recipe
-+ */
-+class RecipeDiscoveryTest extends KernelTestBase {
-+
-+  public function providerRecipeDiscovery(): array {
-+    return [
-+      ['install_two_modules', 'Install two modules'],
-+      ['recipe_include', 'Recipe include'],
-+    ];
-+  }
-+
-+  /**
-+   * Tests that recipe discovery can find recipes.
-+   *
-+   * @dataProvider providerRecipeDiscovery
-+   */
-+  public function testRecipeDiscovery(string $recipe, string $name): void {
-+    $discovery = new RecipeDiscovery(['core/tests/fixtures/recipes']);
-+    $recipe = $discovery->getRecipe($recipe);
-+    $this->assertSame($name, $recipe->name);
-+  }
-+
-+  public function providerRecipeDiscoveryException(): array {
-+    return [
-+      'missing recipe.yml' => ['no_recipe'],
-+      'no folder' => ['does_not_exist'],
-+    ];
-+  }
-+
-+  /**
-+   * Tests the exception thrown when recipe discovery cannot find a recipe.
-+   *
-+   * @dataProvider providerRecipeDiscoveryException
-+   */
-+  public function testRecipeDiscoveryException(string $recipe): void {
-+    $discovery = new RecipeDiscovery(['core/tests/fixtures/recipes']);
-+    try {
-+      $discovery->getRecipe($recipe);
-+      $this->fail('Expected exception not thrown');
-+    }
-+    catch (UnknownRecipeException $e) {
-+      $this->assertSame($recipe, $e->recipe);
-+      $this->assertSame(['core/tests/fixtures/recipes'], $e->searchPaths);
-+      $this->assertSame('Can not find the ' . $recipe . ' recipe, search paths: core/tests/fixtures/recipes', $e->getMessage());
-+    }
-+  }
-+
-+}
-diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php
-new file mode 100644
-index 00000000..b61ef6ee
---- /dev/null
-+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeRunnerTest.php
-@@ -0,0 +1,209 @@
-+<?php
-+
-+namespace Drupal\KernelTests\Core\Recipe;
-+
-+use Drupal\Core\Recipe\Recipe;
-+use Drupal\Core\Recipe\RecipePreExistingConfigException;
-+use Drupal\Core\Recipe\RecipeRunner;
-+use Drupal\Core\Recipe\RecipeUnmetDependenciesException;
-+use Drupal\KernelTests\KernelTestBase;
-+use Drupal\node\Entity\NodeType;
-+use Drupal\views\Entity\View;
-+
-+/**
-+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeRunner
-+ * @group Recipe
-+ */
-+class RecipeRunnerTest extends KernelTestBase {
-+
-+  public function testModuleInstall(): void {
-+    // Test the state prior to applying the recipe.
-+    $this->assertFalse($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is not installed');
-+    $this->assertFalse($this->container->get('module_handler')->moduleExists('text'), 'The text module is not installed');
-+    $this->assertFalse($this->container->get('module_handler')->moduleExists('node'), 'The node module is not installed');
-+    $this->assertFalse($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration does not exist');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_two_modules');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after applying the recipe.
-+    $this->assertTrue($this->container->get('module_handler')->moduleExists('filter'), 'The filter module is installed');
-+    $this->assertTrue($this->container->get('module_handler')->moduleExists('text'), 'The text module is installed');
-+    $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed');
-+    $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
-+    $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE');
-+  }
-+
-+  public function testModuleAndThemeInstall(): void {
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/base_theme_and_views');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after applying the recipe.
-+    $this->assertTrue($this->container->get('module_handler')->moduleExists('views'), 'The views module is installed');
-+    $this->assertTrue($this->container->get('module_handler')->moduleExists('node'), 'The node module is installed');
-+    $this->assertTrue($this->container->get('theme_handler')->themeExists('test_basetheme'), 'The test_basetheme theme is installed');
-+    $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subtheme'), 'The test_subtheme theme is installed');
-+    $this->assertTrue($this->container->get('theme_handler')->themeExists('test_subsubtheme'), 'The test_subsubtheme theme is installed');
-+    $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
-+    $this->assertFalse($this->container->get('config.storage')->exists('views.view.archive'), 'The views.view.archive configuration has not been created');
-+    $this->assertEmpty(View::loadMultiple(), "No views exist");
-+  }
-+
-+  public function testThemeModuleDependenciesInstall(): void {
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/theme_with_module_dependencies');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after applying the recipe.
-+    $this->assertTrue($this->container->get('module_handler')->moduleExists('test_module_required_by_theme'), 'The test_module_required_by_theme module is installed');
-+    $this->assertTrue($this->container->get('module_handler')->moduleExists('test_another_module_required_by_theme'), 'The test_another_module_required_by_theme module is installed');
-+    $this->assertTrue($this->container->get('theme_handler')->themeExists('test_theme_depending_on_modules'), 'The test_theme_depending_on_modules theme is installed');
-+  }
-+
-+  public function testModuleConfigurationOverride(): void {
-+    // Test the state prior to applying the recipe.
-+    $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after applying the recipe.
-+    $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
-+    $this->assertTrue($this->container->get('config.storage')->exists('node.settings'), 'The node.settings configuration has been created');
-+    $this->assertTrue($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to TRUE');
-+    $this->assertSame('Test content type', NodeType::load('test')->label());
-+    $node_type_data = $this->config('node.type.test')->get();
-+    $this->assertGreaterThan(0, strlen($node_type_data['uuid']), 'The node type configuration has been assigned a UUID.');
-+    // cSpell:disable-next-line
-+    $this->assertSame('965SCwSA3qgVf47x7hEE4dufnUDpxKMsUfsqFtqjGn0', $node_type_data['_core']['default_config_hash']);
-+  }
-+
-+  public function testUnmetConfigurationDependencies(): void {
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/unmet_config_dependencies');
-+    try {
-+      RecipeRunner::processRecipe($recipe);
-+      $this->fail('Expected exception not thrown');
-+    }
-+    catch (RecipeUnmetDependenciesException $e) {
-+      $this->assertSame("The configuration 'node.type.test' has unmet dependencies", $e->getMessage());
-+      $this->assertSame('node.type.test', $e->configName);
-+    }
-+  }
-+
-+  public function testApplySameRecipe(): void {
-+    // Test the state prior to applying the recipe.
-+    $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state prior to applying the recipe.
-+    $this->assertNotEmpty($this->container->get('config.factory')->listAll('node.'), 'There is node configuration');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
-+    RecipeRunner::processRecipe($recipe);
-+    $this->assertTrue(TRUE, 'Applying a recipe for the second time with no config changes results in a successful application');
-+
-+    $type = NodeType::load('test');
-+    $type->setNewRevision(FALSE);
-+    $type->save();
-+
-+    $this->expectException(RecipePreExistingConfigException::class);
-+    $this->expectExceptionMessage("The configuration 'node.type.test' exists already and does not match the recipe's configuration");
-+    Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
-+  }
-+
-+  public function testConfigFromModule(): void {
-+    // Test the state prior to applying the recipe.
-+    $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after to applying the recipe.
-+    $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
-+    $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
-+    $this->assertSame(['dotted.default', 'override'], array_keys($config_test_entities));
-+  }
-+
-+  public function testConfigWildcard(): void {
-+    // Test the state prior to applying the recipe.
-+    $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_wildcard');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after to applying the recipe.
-+    $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
-+    $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
-+    $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities));
-+    $this->assertSame('Default', $config_test_entities['dotted.default']->label());
-+    $this->assertSame('herp', $this->config('config_test.system')->get('404'));
-+  }
-+
-+  public function testConfigFromModuleAndRecipe(): void {
-+    // Test the state prior to applying the recipe.
-+    $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_from_module_and_recipe');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after to applying the recipe.
-+    $this->assertNotEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is config_test configuration');
-+    $config_test_entities = \Drupal::entityTypeManager()->getStorage('config_test')->loadMultiple();
-+    $this->assertSame(['dotted.default', 'override', 'override_unmet'], array_keys($config_test_entities));
-+    $this->assertSame('Provided by recipe', $config_test_entities['dotted.default']->label());
-+    $this->assertSame('derp', $this->config('config_test.system')->get('404'));
-+  }
-+
-+  public function testRecipeInclude(): void {
-+    // Test the state prior to applying the recipe.
-+    $this->assertEmpty($this->container->get('config.factory')->listAll('node.'), 'There is no node configuration');
-+    $this->assertFalse($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module not installed');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after to applying the recipe.
-+    $this->assertTrue($this->container->get('module_handler')->moduleExists('dblog'), 'Dblog module installed');
-+    $this->assertSame('Test content type', NodeType::load('test')->label());
-+    $this->assertSame('Another test content type', NodeType::load('another_test')->label());
-+  }
-+
-+  public function testConfigActions() :void {
-+    // Test the state prior to applying the recipe.
-+    $this->assertEmpty($this->container->get('config.factory')->listAll('config_test.'), 'There is no config_test configuration');
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after to applying the recipe.
-+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('recipe');
-+    $this->assertSame('Created by recipe', $config_test_entity->label());
-+    $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty());
-+    $this->assertSame('not bar', $this->config('config_test.system')->get('foo'));
-+  }
-+
-+  public function testConfigActionsPreExistingConfig() :void {
-+    $this->enableModules(['config_test']);
-+    $this->installConfig(['config_test']);
-+    $this->assertSame('bar', $this->config('config_test.system')->get('foo'));
-+    $storage = \Drupal::entityTypeManager()->getStorage('config_test');
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->create(['id' => 'recipe', 'label' => 'Created by test']);
-+    $config_test_entity->setProtectedProperty('Set by test');
-+    $config_test_entity->save();
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/config_actions');
-+    RecipeRunner::processRecipe($recipe);
-+
-+    // Test the state after to applying the recipe.
-+    /** @var \Drupal\config_test\Entity\ConfigTest $config_test_entity */
-+    $config_test_entity = $storage->load('recipe');
-+    $this->assertSame('Created by test', $config_test_entity->label());
-+    $this->assertSame('Set by recipe', $config_test_entity->getProtectedProperty());
-+    $this->assertSame('not bar', $this->config('config_test.system')->get('foo'));
-+  }
-+
-+}
-diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php
-new file mode 100644
-index 00000000..56a52fb8
---- /dev/null
-+++ b/core/tests/Drupal/KernelTests/Core/Recipe/RecipeTest.php
-@@ -0,0 +1,108 @@
-+<?php
-+
-+namespace Drupal\KernelTests\Core\Recipe;
-+
-+use Drupal\Core\Recipe\Recipe;
-+use Drupal\Core\Recipe\RecipeFileException;
-+use Drupal\Core\Recipe\RecipeMissingExtensionsException;
-+use Drupal\Core\Recipe\RecipePreExistingConfigException;
-+use Drupal\Core\Recipe\UnknownRecipeException;
-+use Drupal\KernelTests\KernelTestBase;
-+
-+/**
-+ * @coversDefaultClass \Drupal\Core\Recipe\Recipe
-+ * @group Recipe
-+ */
-+class RecipeTest extends KernelTestBase {
-+
-+  /**
-+   * {@inheritdoc}
-+   */
-+  protected static $modules = ['system', 'user', 'field'];
-+
-+  public function providerTestCreateFromDirectory(): array {
-+    return [
-+      'no extensions' => ['no_extensions', 'No extensions' , 'Testing', [], 'A recipe description'],
-+      // Filter is installed because it is a dependency and it is not yet installed.
-+      'install_two_modules' => ['install_two_modules', 'Install two modules' , 'Content type', ['filter', 'text', 'node'], ''],
-+    ];
-+  }
-+
-+  /**
-+   * @dataProvider providerTestCreateFromDirectory
-+   */
-+  public function testCreateFromDirectory2(string $recipe_name, string $expected_name, string $expected_type, array $expected_modules, string $expected_description): void {
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/' . $recipe_name);
-+    $this->assertSame($expected_name, $recipe->name);
-+    $this->assertSame($expected_type, $recipe->type);
-+    $this->assertSame($expected_modules, $recipe->install->modules);
-+    $this->assertSame($expected_description, $recipe->description);
-+  }
-+
-+  public function testCreateFromDirectoryNoRecipe(): void {
-+    $this->expectException(RecipeFileException::class);
-+    $this->expectExceptionMessage('There is no core/tests/fixtures/recipes/no_recipe/recipe.yml file');
-+    Recipe::createFromDirectory('core/tests/fixtures/recipes/no_recipe');
-+  }
-+
-+  public function testCreateFromDirectoryNoRecipeName(): void {
-+    $this->expectException(RecipeFileException::class);
-+    $this->expectExceptionMessage('The core/tests/fixtures/recipes/no_name/recipe.yml has no name value.');
-+    Recipe::createFromDirectory('core/tests/fixtures/recipes/no_name');
-+  }
-+
-+  public function testCreateFromDirectoryMissingExtensions(): void {
-+    $this->enableModules(['module_test']);
-+
-+    // Create a missing fake dependency.
-+    // dblog will depend on Config, which depends on a non-existing module Foo.
-+    // Nothing should be installed.
-+    \Drupal::state()->set('module_test.dependency', 'missing dependency');
-+
-+    try {
-+      Recipe::createFromDirectory('core/tests/fixtures/recipes/missing_extensions');
-+      $this->fail('Expected exception not thrown');
-+    }
-+    catch (RecipeMissingExtensionsException $e) {
-+      $this->assertSame(['does_not_exist_one', 'does_not_exist_two', 'foo'], $e->extensions);
-+    }
-+  }
-+
-+  public function testPreExistingDifferentConfiguration(): void {
-+    // Install the node module, its dependencies and configuration.
-+    $this->container->get('module_installer')->install(['node']);
-+    $this->assertFalse($this->config('node.settings')->get('use_admin_theme'), 'The node.settings:use_admin_theme is set to FALSE');
-+
-+    try {
-+      Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
-+      $this->fail('Expected exception not thrown');
-+    }
-+    catch (RecipePreExistingConfigException $e) {
-+      $this->assertSame("The configuration 'node.settings' exists already and does not match the recipe's configuration", $e->getMessage());
-+      $this->assertSame('node.settings', $e->configName);
-+    }
-+  }
-+
-+  public function testPreExistingMatchingConfiguration(): void {
-+    // Install the node module, its dependencies and configuration.
-+    $this->container->get('module_installer')->install(['node']);
-+    // Change the config to match the recipe's config to prevent the exception
-+    // being thrown.
-+    $this->config('node.settings')->set('use_admin_theme', TRUE)->save();
-+
-+    $recipe = Recipe::createFromDirectory('core/tests/fixtures/recipes/install_node_with_config');
-+    $this->assertSame('core/tests/fixtures/recipes/install_node_with_config/config', $recipe->config->recipeConfigDirectory);
-+  }
-+
-+  public function testRecipeIncludeMissing(): void {
-+    try {
-+      Recipe::createFromDirectory('core/tests/fixtures/recipes/recipe_include_missing');
-+    }
-+    catch (UnknownRecipeException $e) {
-+      $this->assertSame('does_not_exist', $e->recipe);
-+      $this->assertSame(['core/tests/fixtures/recipes'], $e->searchPaths);
-+      $this->assertSame('Can not find the does_not_exist recipe, search paths: core/tests/fixtures/recipes', $e->getMessage());
-+    }
-+  }
-+
-+}
-diff --git a/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php
-new file mode 100644
-index 00000000..14de011b
---- /dev/null
-+++ b/core/tests/Drupal/Tests/Core/Recipe/RecipeConfigStorageWrapperTest.php
-@@ -0,0 +1,324 @@
-+<?php
-+
-+declare(strict_types=1);
-+
-+namespace Drupal\Tests\Core\Recipe;
-+
-+use Drupal\Core\Config\MemoryStorage;
-+use Drupal\Core\Config\NullStorage;
-+use Drupal\Core\Config\StorageInterface;
-+use Drupal\Core\Recipe\RecipeConfigStorageWrapper;
-+use Drupal\Tests\UnitTestCase;
-+
-+/**
-+ * @coversDefaultClass \Drupal\Core\Recipe\RecipeConfigStorageWrapper
-+ * @group Recipe
-+ */
-+class RecipeConfigStorageWrapperTest extends UnitTestCase {
-+
-+  /**
-+   * Validate that an empty set of storage backends returns null storage.
-+   */
-+  public function testNullStorage(): void {
-+    $this->assertInstanceOf(
-+      NullStorage::class,
-+      RecipeConfigStorageWrapper::createStorageFromArray([])
-+    );
-+  }
-+
-+  /**
-+   * Validate that a single storage returns exactly the same instance.
-+   */
-+  public function testSingleStorage(): void {
-+    $storages = [new NullStorage()];
-+    $this->assertSame(
-+      $storages[0],
-+      RecipeConfigStorageWrapper::createStorageFromArray($storages)
-+    );
-+  }
-+
-+  /**
-+   * Validate that multiple storages return underlying values correctly.
-+   */
-+  public function testMultipleStorages(): void {
-+    $a = new MemoryStorage();
-+    $a->write('a_key', ['a_data_first']);
-+    $b = new MemoryStorage();
-+
-+    // Add a conflicting key so that we can test the first value is returned.
-+    $b->write('a_key', ['a_data_second']);
-+    $b->write('b_key', ['b_data']);
-+
-+    // We test with a third storage as well since only two storages can be done
-+    // via the constructor alone.
-+    $c = new MemoryStorage();
-+    $c->write('c_key', ['c_data']);
-+
-+    $storages = [$a, $b, $c];
-+    $wrapped = RecipeConfigStorageWrapper::createStorageFromArray($storages);
-+
-+    $this->assertSame($a->read('a_key'), $wrapped->read('a_key'));
-+    $this->assertNotEquals($b->read('a_key'), $wrapped->read('a_key'));
-+    $this->assertSame($b->read('b_key'), $wrapped->read('b_key'));
-+    $this->assertSame($c->read('c_key'), $wrapped->read('c_key'));
-+  }
-+
-+  /**
-+   * Validate that the first storage checks existence first.
-+   */
-+  public function testLeftSideExists(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $a->expects($this->once())->method('exists')->with('a_key')
-+      ->willReturn(TRUE);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->never())->method('exists');
-+
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertTrue($storage->exists('a_key'));
-+  }
-+
-+  /**
-+   * Validate that we fall back to the second storage.
-+   */
-+  public function testRightSideExists(): void {
-+    [$a, $b] = $this->generateStorages(TRUE);
-+
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $storage->exists('a_key');
-+  }
-+
-+  /**
-+   * Validate FALSE when neither storage contains a key.
-+   */
-+  public function testNotExists(): void {
-+    [$a, $b] = $this->generateStorages(FALSE);
-+
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertFalse($storage->exists('a_key'));
-+  }
-+
-+  /**
-+   * Validate that we read from storage A first.
-+   */
-+  public function testReadFromA(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $value = ['a_value'];
-+    $a->expects($this->once())->method('read')->with('a_key')
-+      ->willReturn($value);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->never())->method('read');
-+
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertSame($value, $storage->read('a_key'));
-+  }
-+
-+  /**
-+   * Validate that we read from storage B second.
-+   */
-+  public function testReadFromB(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $a->expects($this->once())->method('read')->with('a_key')
-+      ->willReturn(FALSE);
-+    $b = $this->createMock(StorageInterface::class);
-+    $value = ['a_value'];
-+    $b->expects($this->once())->method('read')->with('a_key')
-+      ->willReturn($value);
-+
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertSame($value, $storage->read('a_key'));
-+  }
-+
-+  /**
-+   * Validate when neither storage can read a value.
-+   */
-+  public function testReadFails(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $a->expects($this->once())->method('read')->with('a_key')
-+      ->willReturn(FALSE);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->once())->method('read')->with('a_key')
-+      ->willReturn(FALSE);
-+
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertFalse($storage->read('a_key'));
-+  }
-+
-+  /**
-+   * Test reading multiple values.
-+   */
-+  public function testReadMultiple(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
-+      ->willReturn(['a_key' => ['a_value']]);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
-+      ->willReturn(['b_key' => ['b_value']]);
-+
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertEquals([
-+      'a_key' => ['a_value'],
-+      'b_key' => ['b_value'],
-+    ], $storage->readMultiple(['a_key', 'b_key']));
-+  }
-+
-+  /**
-+   * Test that storage A has precedence over storage B.
-+   */
-+  public function testReadMultipleStorageA(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $a->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
-+      ->willReturn(['a_key' => ['a_value']]);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->once())->method('readMultiple')->with(['a_key', 'b_key'])
-+      ->willReturn(['a_key' => ['a_conflicting_value'], 'b_key' => ['b_value']]);
-+
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertEquals([
-+      'a_key' => ['a_value'],
-+      'b_key' => ['b_value'],
-+    ], $storage->readMultiple(['a_key', 'b_key']));
-+  }
-+
-+  /**
-+   * Test methods that are unsupported.
-+   *
-+   * @param string $method
-+   *   The method to call.
-+   * @param array $args
-+   *   The arguments to pass to the method.
-+   *
-+   * @dataProvider unsupportedMethods
-+   */
-+  public function testUnsupportedMethods(string $method, ...$args): void {
-+    $this->expectException(\BadMethodCallException::class);
-+    $storage = new RecipeConfigStorageWrapper(new NullStorage(), new NullStorage());
-+    $storage->{$method}(...$args);
-+  }
-+
-+  /**
-+   * Test that we only use storage A's encode method.
-+   */
-+  public function testEncode(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $data = 'value';
-+    $a->expects($this->once())->method('encode')->with([$data])
-+      ->willReturn($data);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->never())->method('encode');
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertSame($data, $storage->encode([$data]));
-+  }
-+
-+  /**
-+   * Test that we only use storage A's decode method.
-+   */
-+  public function testDecode(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $raw = 'value';
-+    $a->expects($this->once())->method('decode')->with($raw)
-+      ->willReturn([$raw]);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->never())->method('decode');
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertEquals([$raw], $storage->decode($raw));
-+  }
-+
-+  /**
-+   * Test that list all merges values and makes them unique.
-+   */
-+  public function testListAll(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $a->method('listAll')->with('node.')
-+      ->willReturn(['node.type']);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->method('listAll')->with('node.')
-+      ->willReturn(['node.type', 'node.id']);
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertEquals([
-+      0 => 'node.type',
-+      2 => 'node.id',
-+    ], $storage->listAll('node.'));
-+  }
-+
-+  /**
-+   * Test creating a collection passes the name through to the child storages.
-+   */
-+  public function testCreateCollection(): void {
-+    $collection_name = 'collection';
-+    $a = $this->createMock(StorageInterface::class);
-+    $b = $this->createMock(StorageInterface::class);
-+    /** @var \PHPUnit\Framework\MockObject\MockObject $mock */
-+    foreach ([$a, $b] as $mock) {
-+      $mock->expects($this->once())->method('createCollection')
-+        ->with($collection_name)->willReturn(new NullStorage($collection_name));
-+    }
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $new = $storage->createCollection($collection_name);
-+    $this->assertInstanceOf(RecipeConfigStorageWrapper::class, $new);
-+    $this->assertEquals($collection_name, $new->getCollectionName());
-+    $this->assertNotEquals($storage, $new);
-+  }
-+
-+  /**
-+   * Test that we merge and return only unique collection names.
-+   */
-+  public function testGetAllCollectionNames(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $a->expects($this->once())->method('getAllCollectionNames')
-+      ->willReturn(['collection_1', 'collection_2']);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->once())->method('getAllCollectionNames')
-+      ->willReturn(['collection_3', 'collection_1', 'collection_2']);
-+    $storage = new RecipeConfigStorageWrapper($a, $b);
-+    $this->assertEquals([
-+      'collection_1',
-+      'collection_2',
-+      'collection_3',
-+    ], $storage->getAllCollectionNames());
-+  }
-+
-+  /**
-+   * Test the collection name is stored properly.
-+   */
-+  public function testGetCollection(): void {
-+    $a = $this->createMock(StorageInterface::class);
-+    $b = $this->createMock(StorageInterface::class);
-+    $storage = new RecipeConfigStorageWrapper($a, $b, 'collection');
-+    $this->assertEquals('collection', $storage->getCollectionName());
-+  }
-+
-+  /**
-+   * Generate two storages where the second storage should return a value.
-+   *
-+   * @param bool $b_return
-+   *   The return value for storage $b's exist method.
-+   *
-+   * @return \Drupal\Core\Config\StorageInterface[]
-+   *   An array of two mocked storages.
-+   */
-+  private function generateStorages(bool $b_return): array {
-+    $a = $this->createMock(StorageInterface::class);
-+    $a->expects($this->once())->method('exists')->with('a_key')
-+      ->willReturn(FALSE);
-+    $b = $this->createMock(StorageInterface::class);
-+    $b->expects($this->once())->method('exists')->with('a_key')
-+      ->willReturn($b_return);
-+    return [$a, $b];
-+  }
-+
-+  /**
-+   * Data provider for methods that are unsupported.
-+   *
-+   * @return array
-+   *   An array of method names w/ args to test the unsupported methods.
-+   */
-+  private function unsupportedMethods(): array {
-+    return [
-+      ['write', 'name', []],
-+      ['delete', 'name'],
-+      ['rename', 'old_name', 'new_name'],
-+      ['deleteAll'],
-+    ];
-+  }
-+
-+}
-diff --git a/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml
-new file mode 100644
-index 00000000..6d9589f9
---- /dev/null
-+++ b/core/tests/fixtures/recipes/base_theme_and_views/recipe.yml
-@@ -0,0 +1,6 @@
-+name: 'Base theme and views'
-+type: 'Testing'
-+install:
-+  - test_subsubtheme
-+  - node
-+  - views
-diff --git a/core/tests/fixtures/recipes/config_actions/recipe.yml b/core/tests/fixtures/recipes/config_actions/recipe.yml
-new file mode 100644
-index 00000000..4e16eeb6
---- /dev/null
-+++ b/core/tests/fixtures/recipes/config_actions/recipe.yml
-@@ -0,0 +1,13 @@
-+name: 'Config actions'
-+type: 'Testing'
-+install:
-+  - config_test
-+config:
-+  actions:
-+    config_test.dynamic.recipe:
-+      ensure_exists:
-+        label: 'Created by recipe'
-+      setProtectedProperty: 'Set by recipe'
-+    config_test.system:
-+      simple_config_update:
-+        foo: 'not bar'
-diff --git a/core/tests/fixtures/recipes/config_from_module/recipe.yml b/core/tests/fixtures/recipes/config_from_module/recipe.yml
-new file mode 100644
-index 00000000..f88aa486
---- /dev/null
-+++ b/core/tests/fixtures/recipes/config_from_module/recipe.yml
-@@ -0,0 +1,9 @@
-+name: 'Config from module'
-+type: 'Testing'
-+install:
-+  - config_test
-+config:
-+  import:
-+    config_test:
-+      - config_test.dynamic.dotted.default
-+      - config_test.dynamic.override
-diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml
-new file mode 100644
-index 00000000..ce5eb672
---- /dev/null
-+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.dynamic.dotted.default.yml
-@@ -0,0 +1,6 @@
-+id: dotted.default
-+label: 'Provided by recipe'
-+weight: 0
-+protected_property: Default
-+# Intentionally commented out to verify default status behavior.
-+# status: 1
-diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml
-new file mode 100644
-index 00000000..07a32843
---- /dev/null
-+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/config/config_test.system.yml
-@@ -0,0 +1,2 @@
-+foo: bar
-+404: derp
-diff --git a/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml
-new file mode 100644
-index 00000000..91b42de4
---- /dev/null
-+++ b/core/tests/fixtures/recipes/config_from_module_and_recipe/recipe.yml
-@@ -0,0 +1,12 @@
-+name: 'Config from module and recipe'
-+type: 'Testing'
-+install:
-+  - config_test
-+  - tour
-+  - tour_test
-+config:
-+  import:
-+    config_test: '*'
-+    tour_test:
-+      - tour.tour.tour-test
-+      - tour.tour.tour-test2
-diff --git a/core/tests/fixtures/recipes/config_wildcard/recipe.yml b/core/tests/fixtures/recipes/config_wildcard/recipe.yml
-new file mode 100644
-index 00000000..582f7aeb
---- /dev/null
-+++ b/core/tests/fixtures/recipes/config_wildcard/recipe.yml
-@@ -0,0 +1,8 @@
-+name: 'Config wildcard'
-+type: 'Testing'
-+install:
-+  - config_test
-+  - tour
-+config:
-+  import:
-+    config_test: '*'
-diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml
-new file mode 100644
-index 00000000..6cb95cbc
---- /dev/null
-+++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.settings.yml
-@@ -0,0 +1 @@
-+use_admin_theme: true
-diff --git a/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml
-new file mode 100644
-index 00000000..567eb1bf
---- /dev/null
-+++ b/core/tests/fixtures/recipes/install_node_with_config/config/node.type.test.yml
-@@ -0,0 +1,9 @@
-+langcode: en
-+status: true
-+name: 'Test content type'
-+type: test
-+description: 'Test content type from a recipe'
-+help: ''
-+new_revision: true
-+preview_mode: 1
-+display_submitted: true
-diff --git a/core/tests/fixtures/recipes/install_node_with_config/recipe.yml b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml
-new file mode 100644
-index 00000000..e2fc243b
---- /dev/null
-+++ b/core/tests/fixtures/recipes/install_node_with_config/recipe.yml
-@@ -0,0 +1,5 @@
-+name: 'Install node with config'
-+type: 'Content type'
-+install:
-+  - node
-+  - drupal:text
-diff --git a/core/tests/fixtures/recipes/install_two_modules/recipe.yml b/core/tests/fixtures/recipes/install_two_modules/recipe.yml
-new file mode 100644
-index 00000000..ee57ca14
---- /dev/null
-+++ b/core/tests/fixtures/recipes/install_two_modules/recipe.yml
-@@ -0,0 +1,5 @@
-+name: 'Install two modules'
-+type: 'Content type'
-+install:
-+  - node
-+  - text
-diff --git a/core/tests/fixtures/recipes/missing_extensions/recipe.yml b/core/tests/fixtures/recipes/missing_extensions/recipe.yml
-new file mode 100644
-index 00000000..77c82fbe
---- /dev/null
-+++ b/core/tests/fixtures/recipes/missing_extensions/recipe.yml
-@@ -0,0 +1,6 @@
-+name: 'Missing extensions'
-+type: 'Testing'
-+install:
-+  - does_not_exist_one
-+  - does_not_exist_two
-+  - dblog
-diff --git a/core/tests/fixtures/recipes/no_extensions/recipe.yml b/core/tests/fixtures/recipes/no_extensions/recipe.yml
-new file mode 100644
-index 00000000..b7d3aeb4
---- /dev/null
-+++ b/core/tests/fixtures/recipes/no_extensions/recipe.yml
-@@ -0,0 +1,3 @@
-+name: 'No extensions'
-+description: 'A recipe description'
-+type: 'Testing'
-diff --git a/core/tests/fixtures/recipes/no_name/recipe.yml b/core/tests/fixtures/recipes/no_name/recipe.yml
-new file mode 100644
-index 00000000..65daa60d
---- /dev/null
-+++ b/core/tests/fixtures/recipes/no_name/recipe.yml
-@@ -0,0 +1 @@
-+type: 'Testing'
-diff --git a/core/tests/fixtures/recipes/no_recipe/.gitkeep b/core/tests/fixtures/recipes/no_recipe/.gitkeep
-new file mode 100644
-index 00000000..e69de29b
-diff --git a/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml
-new file mode 100644
-index 00000000..48a9258b
---- /dev/null
-+++ b/core/tests/fixtures/recipes/recipe_include/config/node.type.another_test.yml
-@@ -0,0 +1,9 @@
-+langcode: en
-+status: true
-+name: 'Another test content type'
-+type: another_test
-+description: 'Another test content type from a recipe'
-+help: ''
-+new_revision: true
-+preview_mode: 1
-+display_submitted: true
-diff --git a/core/tests/fixtures/recipes/recipe_include/recipe.yml b/core/tests/fixtures/recipes/recipe_include/recipe.yml
-new file mode 100644
-index 00000000..a81aa075
---- /dev/null
-+++ b/core/tests/fixtures/recipes/recipe_include/recipe.yml
-@@ -0,0 +1,6 @@
-+name: 'Recipe include'
-+type: 'Testing'
-+recipes:
-+  - install_node_with_config
-+install:
-+  - dblog
-diff --git a/core/tests/fixtures/recipes/recipe_include_missing/recipe.yml b/core/tests/fixtures/recipes/recipe_include_missing/recipe.yml
-new file mode 100644
-index 00000000..b80db734
---- /dev/null
-+++ b/core/tests/fixtures/recipes/recipe_include_missing/recipe.yml
-@@ -0,0 +1,7 @@
-+name: 'Recipe include'
-+type: 'Testing'
-+recipes:
-+  - recipe_include
-+  - does_not_exist
-+install:
-+  - config_test
-diff --git a/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml
-new file mode 100644
-index 00000000..550c3610
---- /dev/null
-+++ b/core/tests/fixtures/recipes/theme_with_module_dependencies/recipe.yml
-@@ -0,0 +1,5 @@
-+name: 'Theme with module dependencies'
-+type: 'Testing'
-+install:
-+  - test_theme_depending_on_modules
-+  - test_module_required_by_theme
-diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml
-new file mode 100644
-index 00000000..567eb1bf
---- /dev/null
-+++ b/core/tests/fixtures/recipes/unmet_config_dependencies/config/node.type.test.yml
-@@ -0,0 +1,9 @@
-+langcode: en
-+status: true
-+name: 'Test content type'
-+type: test
-+description: 'Test content type from a recipe'
-+help: ''
-+new_revision: true
-+preview_mode: 1
-+display_submitted: true
-diff --git a/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml
-new file mode 100644
-index 00000000..e90fca91
---- /dev/null
-+++ b/core/tests/fixtures/recipes/unmet_config_dependencies/recipe.yml
-@@ -0,0 +1,2 @@
-+name: 'Unmet config dependencies'
-+type: 'Testing'