Skip to content

Instantly share code, notes, and snippets.

@zviryatko
Created September 23, 2026 22:36
Show Gist options
  • Select an option

  • Save zviryatko/e068d07b5659d8ea2a45ca0ba3b0ee0c to your computer and use it in GitHub Desktop.

Select an option

Save zviryatko/e068d07b5659d8ea2a45ca0ba3b0ee0c to your computer and use it in GitHub Desktop.
Drupal core patches for faster site:install --existing-config

Drupal core site:install performance patches

Five custom patches from a Drupal 11.4 project, targeting drush site:install --existing-config:

  • Cache configuration collection names during import.
  • Cache translatable default configuration and locale support checks.
  • Optionally skip translation-status key/value operations during installation.
  • Avoid forced rebuilds of cold entity field maps.
  • Limit bundle-field cache invalidation during installation.

Usage

The manifest contains only these five patches. Merge its patches.drupal/core entries into your existing patch manifest, preserving their order. Patch URLs point to a fixed gist revision.

The source project uses cweagans/composer-patches 1.x with these composer.json settings:

{
  "extra": {
    "patches-file": "composer.patches.json",
    "patchLevel": {
      "drupal/core": "-p2"
    }
  }
}

Reapply core patches through your project's Composer workflow. These patches use a/core/... paths and require strip level 2 when applied inside the Drupal core package. Confirm compatibility with your exact core version; this bundle has not been independently benchmarked or tested here.

Optional translation-status optimization

locale-null-during-install-keyvalue.patch adds an opt-in service. To enable it, merge this into the site's loaded services YAML:

parameters:
  factory.keyvalue:
    locale.translation_status: locale.keyvalue.null_during_install

The source project enables this only on its disposable testing site. During installation, reads return empty/default values and writes are discarded; after installation, operations pass through to the normal store.

{
"patches": {
"drupal/core": {
"Cache config collection names during import": "https://gist.githubusercontent.com/zviryatko/e068d07b5659d8ea2a45ca0ba3b0ee0c/raw/121e9e1900c3a95af57436446e15c528343fdb56/storage-comparer-cache-collection-names.patch",
"Cache translatable default configuration and locale support checks": "https://gist.githubusercontent.com/zviryatko/e068d07b5659d8ea2a45ca0ba3b0ee0c/raw/921eb3e94b5a503bcfc1a2042602c055fa1dd606/locale-config-manager-cache-translatable-default-config.patch",
"Allow opt-in suppression of translation-status key/value operations during install": "https://gist.githubusercontent.com/zviryatko/e068d07b5659d8ea2a45ca0ba3b0ee0c/raw/3c10f3f79cbcf9367d28897f901cd8d1ab050ecc/locale-null-during-install-keyvalue.patch",
"Avoid forcing cold entity field map rebuilds during install": "https://gist.githubusercontent.com/zviryatko/e068d07b5659d8ea2a45ca0ba3b0ee0c/raw/af93a1658776eef83d46d8936b1cfffe459ebb61/entity-field-manager-skip-field-map-rebuild-during-install.patch",
"Avoid base field and typed-data cache invalidation on bundle field imports": "https://gist.githubusercontent.com/zviryatko/e068d07b5659d8ea2a45ca0ba3b0ee0c/raw/69c5a544b75ca69634bcd69a89803cca3fe09526/field-config-skip-base-field-invalidation-during-install.patch"
}
}
}
Index: core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php
===================================================================
diff --git a/core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php b/core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php
--- a/core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php
+++ b/core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php
@@ -74,6 +74,20 @@ interface EntityFieldManagerInterface {
public function getFieldMap();
/**
+ * Checks whether the field map is already built and held in memory.
+ *
+ * Unlike self::getFieldMap(), this never triggers a rebuild - it only
+ * reports whether one has already happened (and not since been reset,
+ * e.g. by a service container rebuild). Useful for callers that only want
+ * to opportunistically refresh an already-warm map rather than force a
+ * fresh (potentially expensive) build.
+ *
+ * @return bool
+ * TRUE if the field map is currently held in memory, FALSE otherwise.
+ */
+ public function isFieldMapBuilt();
+
+ /**
* Sets a lightweight map of fields across bundles.
*
* @param array[] $field_map
Index: core/lib/Drupal/Core/Entity/EntityFieldManager.php
===================================================================
diff --git a/core/lib/Drupal/Core/Entity/EntityFieldManager.php b/core/lib/Drupal/Core/Entity/EntityFieldManager.php
--- a/core/lib/Drupal/Core/Entity/EntityFieldManager.php
+++ b/core/lib/Drupal/Core/Entity/EntityFieldManager.php
@@ -498,6 +498,13 @@ class EntityFieldManager implements EntityFieldManagerInterface, PreWarmableIn
/**
* {@inheritdoc}
*/
+ public function isFieldMapBuilt() {
+ return (bool) $this->fieldMap;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
public function setFieldMap(array $field_map) {
$this->fieldMap = $field_map;
return $this;
Index: core/lib/Drupal/Core/Field/FieldDefinitionListener.php
===================================================================
diff --git a/core/lib/Drupal/Core/Field/FieldDefinitionListener.php b/core/lib/Drupal/Core/Field/FieldDefinitionListener.php
--- a/core/lib/Drupal/Core/Field/FieldDefinitionListener.php
+++ b/core/lib/Drupal/Core/Field/FieldDefinitionListener.php
@@ -5,6 +5,7 @@
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
/**
@@ -86,6 +87,21 @@ class FieldDefinitionListener implements FieldDefinitionListenerInterface {
// Delete the cache entry.
$this->cacheBackend->delete('entity_field_map');
+ // During installation, each module install rebuilds the service
+ // container, so the field map is very often not already built in
+ // memory at this point (a fresh EntityFieldManager instance starts
+ // with none). Calling getFieldMap() below in that state doesn't "keep
+ // it warm" the way it does the rest of the time - it forces the full,
+ // expensive rebuild (looping every fieldable entity type) right now,
+ // once per field created, instead of once whenever something actually
+ // needs the map next. The bundle_field_map key value collection above
+ // already has this field's data, so skipping the forced rebuild here
+ // still leaves the next real reader with a correct, complete map -
+ // just built lazily instead of on every single field creation.
+ if (InstallerKernel::installationAttempted() && !$this->entityFieldManager->isFieldMapBuilt()) {
+ return;
+ }
+
// If the field map is initialized, update it as well, so that calls to it
// do not have to rebuild it again.
if ($field_map = $this->entityFieldManager->getFieldMap()) {
@@ -133,6 +149,11 @@ class FieldDefinitionListener implements FieldDefinitionListenerInterface {
// Delete the cache entry.
$this->cacheBackend->delete('entity_field_map');
+ // See the matching comment in onFieldDefinitionCreate() above.
+ if (InstallerKernel::installationAttempted() && !$this->entityFieldManager->isFieldMapBuilt()) {
+ return;
+ }
+
// If the field map is initialized, update it as well, so that calls to it
// do not have to rebuild it again.
if ($field_map = $this->entityFieldManager->getFieldMap()) {
Index: core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php
===================================================================
diff --git a/core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php b/core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php
--- a/core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php
+++ b/core/lib/Drupal/Core/Entity/EntityFieldManagerInterface.php
@@ -118,6 +118,20 @@ interface EntityFieldManagerInterface {
public function clearCachedFieldDefinitions();
/**
+ * Clears cached bundle-level field definitions only.
+ *
+ * A narrower alternative to self::clearCachedFieldDefinitions() for
+ * changes that are scoped to a single bundle-level (configurable) field:
+ * it refreshes the field map and per-bundle field definition caches, but
+ * leaves base field definitions, field storage definitions, and the typed
+ * data manager's cached plugin definitions alone. None of those can be
+ * affected by a configurable field's own configuration - only by changes
+ * to the entity type itself, its base fields, or its field storage - so
+ * there's nothing for this method to invalidate there.
+ */
+ public function clearCachedBundleFieldDefinitions();
+
+ /**
* Disable the use of caches.
*
* @param bool $use_caches
Index: core/lib/Drupal/Core/Entity/EntityFieldManager.php
===================================================================
diff --git a/core/lib/Drupal/Core/Entity/EntityFieldManager.php b/core/lib/Drupal/Core/Entity/EntityFieldManager.php
--- a/core/lib/Drupal/Core/Entity/EntityFieldManager.php
+++ b/core/lib/Drupal/Core/Entity/EntityFieldManager.php
@@ -644,6 +644,18 @@ class EntityFieldManager implements EntityFieldManagerInterface, PreWarmableIn
/**
* {@inheritdoc}
*/
+ public function clearCachedBundleFieldDefinitions() {
+ $this->fieldDefinitions = [];
+ $this->fieldMap = [];
+ $this->fieldMapByFieldType = [];
+ $this->entityDisplayRepository->clearDisplayModeInfo();
+ $this->extraFields = NULL;
+ Cache::invalidateTags(['entity_field_info']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
public function useCaches($use_caches = FALSE) {
$this->useCaches = $use_caches;
if (!$use_caches) {
Index: core/lib/Drupal/Core/Field/FieldConfigBase.php
===================================================================
diff --git a/core/lib/Drupal/Core/Field/FieldConfigBase.php b/core/lib/Drupal/Core/Field/FieldConfigBase.php
--- a/core/lib/Drupal/Core/Field/FieldConfigBase.php
+++ b/core/lib/Drupal/Core/Field/FieldConfigBase.php
@@ -7,6 +7,7 @@
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
@@ -294,7 +295,23 @@ abstract class FieldConfigBase extends ConfigEntityBase implements FieldConfig
*/
public static function postDelete(EntityStorageInterface $storage, array $fields) {
// Clear the cache upfront, to refresh the results of getBundles().
- \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
+ // During install, base field definitions and typed-data plugin
+ // definitions get invalidated (and, given the persistent cache is
+ // disabled during install, expensively rebuilt from scratch) by the
+ // full clearCachedFieldDefinitions() on every single bundle-level field
+ // saved or deleted - even though neither can be affected by this kind
+ // of field's own configuration, only by the entity type itself, its
+ // base fields, or its field storage. clearCachedBundleFieldDefinitions()
+ // refreshes everything this field's own change can actually affect
+ // (the field map and per-bundle field definitions) without also paying
+ // for that unrelated, unnecessary rebuild.
+ $entity_field_manager = \Drupal::service('entity_field.manager');
+ if (InstallerKernel::installationAttempted()) {
+ $entity_field_manager->clearCachedBundleFieldDefinitions();
+ }
+ else {
+ $entity_field_manager->clearCachedFieldDefinitions();
+ }
// Notify the entity storage.
foreach ($fields as $field) {
@@ -308,8 +325,14 @@ abstract class FieldConfigBase extends ConfigEntityBase implements FieldConfig
* {@inheritdoc}
*/
public function postSave(EntityStorageInterface $storage, $update = TRUE) {
- // Clear the cache.
- \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
+ // Clear the cache. See the matching comment in self::postDelete() above.
+ $entity_field_manager = \Drupal::service('entity_field.manager');
+ if (InstallerKernel::installationAttempted()) {
+ $entity_field_manager->clearCachedBundleFieldDefinitions();
+ }
+ else {
+ $entity_field_manager->clearCachedFieldDefinitions();
+ }
// Invalidate the render cache for all affected entities.
$entity_type = $this->getFieldStorageDefinition()->getTargetEntityTypeId();
Index: core/modules/locale/src/LocaleConfigManager.php
===================================================================
diff --git a/core/modules/locale/src/LocaleConfigManager.php b/core/modules/locale/src/LocaleConfigManager.php
--- a/core/modules/locale/src/LocaleConfigManager.php
+++ b/core/modules/locale/src/LocaleConfigManager.php
@@ -92,6 +92,33 @@ class LocaleConfigManager {
protected $defaultConfigStorage;
/**
+ * Cache of getTranslatableDefaultConfig() results, keyed by config name.
+ *
+ * The result depends only on $name (the default/shipped config data and
+ * schema for it, both static for the life of this service), never on
+ * langcode, so it is safe to reuse across every language a caller loops
+ * over - see updateConfigTranslations() below, which already hoists this
+ * call out of its langcode loop for exactly that reason.
+ *
+ * @var array
+ */
+ protected $translatableDefaultConfigCache = [];
+
+ /**
+ * Cache of isSupported() results, keyed by config name.
+ *
+ * Like $translatableDefaultConfigCache above, the result depends only on
+ * $name (an active-config read plus getDefaultConfigLangcode(), itself a
+ * default-config file read and another name-keyed active-config read),
+ * never on langcode - but LocaleConfigSubscriber::updateLocaleStorage()
+ * calls it once per (config name x langcode) pair, so it was being
+ * recomputed from scratch once per language for no reason.
+ *
+ * @var array
+ */
+ protected $isSupportedCache = [];
+
+ /**
* The configuration manager.
*
* @var \Drupal\Core\Config\ConfigManagerInterface
@@ -137,15 +164,19 @@ class LocaleConfigManager {
* $name.
*/
public function getTranslatableDefaultConfig($name) {
+ if (array_key_exists($name, $this->translatableDefaultConfigCache)) {
+ return $this->translatableDefaultConfigCache[$name];
+ }
+ $translatable = [];
if ($this->isSupported($name)) {
// Create typed configuration wrapper based on install storage data.
$data = $this->defaultConfigStorage->read($name);
$typed_config = $this->typedConfigManager->createFromNameAndData($name, $data);
if ($typed_config instanceof TraversableTypedDataInterface) {
- return $this->getTranslatableData($typed_config);
+ $translatable = $this->getTranslatableData($typed_config);
}
}
- return [];
+ return $this->translatableDefaultConfigCache[$name] = $translatable;
}
/**
@@ -530,7 +561,10 @@ class LocaleConfigManager {
* TRUE if interface translation is supported.
*/
public function isSupported($name) {
- return $this->getDefaultConfigLangcode($name) == 'en' && $this->configStorage->read($name);
+ if (array_key_exists($name, $this->isSupportedCache)) {
+ return $this->isSupportedCache[$name];
+ }
+ return $this->isSupportedCache[$name] = $this->getDefaultConfigLangcode($name) == 'en' && $this->configStorage->read($name);
}
/**
Index: core/modules/locale/locale.services.yml
===================================================================
--- a/core/modules/locale/locale.services.yml
+++ b/core/modules/locale/locale.services.yml
@@ -44,5 +44,11 @@
locale.locale_translation_cache_tag:
class: Drupal\locale\EventSubscriber\LocaleTranslationCacheTag
arguments: ['@cache_tags.invalidator']
+ # Not wired up to any collection by default - opt in per collection via the
+ # factory.keyvalue container parameter (see default.services.yml), e.g. to
+ # make locale.translation_status no-op for the duration of an install.
+ locale.keyvalue.null_during_install:
+ class: Drupal\locale\NullDuringInstallKeyValueFactory
+ arguments: ['@keyvalue.database']
Drupal\locale\EventSubscriber\ConfigSubscriber:
autowire: true
Index: core/modules/locale/src/NullDuringInstallKeyValueStore.php
===================================================================
diff --git a/core/modules/locale/src/NullDuringInstallKeyValueStore.php b/core/modules/locale/src/NullDuringInstallKeyValueStore.php
new file mode 100644
--- /dev/null
+++ b/core/modules/locale/src/NullDuringInstallKeyValueStore.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Drupal\locale;
+
+use Drupal\Core\Installer\InstallerKernel;
+use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
+
+/**
+ * Key/value store decorator that no-ops while installation is in progress.
+ *
+ * Reads always return empty/default and writes are dropped entirely while
+ * InstallerKernel::installationAttempted() is TRUE; every call is passed
+ * straight through to the wrapped store otherwise, so this is transparent
+ * to normal runtime behaviour. Intended for collections such as
+ * locale.translation_status that install-time code reads and writes many
+ * times per (project x language) pair purely to track whether locally or
+ * remotely available translation files have changed since the last check -
+ * state a fresh install has none of and gains nothing from persisting.
+ *
+ * @see \Drupal\locale\NullDuringInstallKeyValueFactory
+ */
+class NullDuringInstallKeyValueStore implements KeyValueStoreInterface {
+
+ public function __construct(protected KeyValueStoreInterface $inner) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCollectionName() {
+ return $this->inner->getCollectionName();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function has($key) {
+ return InstallerKernel::installationAttempted() ? FALSE : $this->inner->has($key);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get($key, $default = NULL) {
+ return InstallerKernel::installationAttempted() ? $default : $this->inner->get($key, $default);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMultiple(array $keys) {
+ return InstallerKernel::installationAttempted() ? [] : $this->inner->getMultiple($keys);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAll() {
+ return InstallerKernel::installationAttempted() ? [] : $this->inner->getAll();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAllKeys(): iterable {
+ return InstallerKernel::installationAttempted() ? [] : $this->inner->getAllKeys();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function set($key, $value) {
+ if (!InstallerKernel::installationAttempted()) {
+ $this->inner->set($key, $value);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setIfNotExists($key, $value) {
+ return InstallerKernel::installationAttempted() ? TRUE : $this->inner->setIfNotExists($key, $value);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setMultiple(array $data) {
+ if (!InstallerKernel::installationAttempted()) {
+ $this->inner->setMultiple($data);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function rename($key, $new_key) {
+ if (!InstallerKernel::installationAttempted()) {
+ $this->inner->rename($key, $new_key);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function delete($key) {
+ if (!InstallerKernel::installationAttempted()) {
+ $this->inner->delete($key);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteMultiple(array $keys) {
+ if (!InstallerKernel::installationAttempted()) {
+ $this->inner->deleteMultiple($keys);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteAll() {
+ if (!InstallerKernel::installationAttempted()) {
+ $this->inner->deleteAll();
+ }
+ }
+
+}
Index: core/modules/locale/src/NullDuringInstallKeyValueFactory.php
===================================================================
diff --git a/core/modules/locale/src/NullDuringInstallKeyValueFactory.php b/core/modules/locale/src/NullDuringInstallKeyValueFactory.php
new file mode 100644
--- /dev/null
+++ b/core/modules/locale/src/NullDuringInstallKeyValueFactory.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Drupal\locale;
+
+use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
+
+/**
+ * Wraps another key/value factory's stores in NullDuringInstallKeyValueStore.
+ *
+ * Register this as the backing service for a collection via the
+ * factory.keyvalue container parameter (see default.services.yml) to make
+ * that one collection no-op for the duration of an install while behaving
+ * completely normally at all other times.
+ *
+ * @see \Drupal\locale\NullDuringInstallKeyValueStore
+ */
+class NullDuringInstallKeyValueFactory implements KeyValueFactoryInterface {
+
+ /**
+ * Instantiated stores, keyed by collection name.
+ *
+ * @var \Drupal\locale\NullDuringInstallKeyValueStore[]
+ */
+ protected array $stores = [];
+
+ public function __construct(protected KeyValueFactoryInterface $inner) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get($collection) {
+ if (!isset($this->stores[$collection])) {
+ $this->stores[$collection] = new NullDuringInstallKeyValueStore($this->inner->get($collection));
+ }
+ return $this->stores[$collection];
+ }
+
+}
Index: core/lib/Drupal/Core/Config/StorageComparer.php
===================================================================
diff --git a/core/lib/Drupal/Core/Config/StorageComparer.php b/core/lib/Drupal/Core/Config/StorageComparer.php
--- a/core/lib/Drupal/Core/Config/StorageComparer.php
+++ b/core/lib/Drupal/Core/Config/StorageComparer.php
@@ -75,6 +75,17 @@ class StorageComparer implements StorageComparerInterface {
protected $targetNames = [];
/**
+ * Cached result of getAllCollectionNames(), without the default collection.
+ *
+ * The source and target storage objects are only ever set once, in the
+ * constructor, so the merged collection list they produce cannot change
+ * for the lifetime of this object and only needs to be computed once.
+ *
+ * @var array|null
+ */
+ protected $allCollectionNames;
+
+ /**
* A memory cache backend to statically cache source configuration data.
*
* @var \Drupal\Core\Cache\MemoryBackend
@@ -495,7 +506,10 @@ class StorageComparer implements StorageComparerInterface {
* {@inheritdoc}
*/
public function getAllCollectionNames($include_default = TRUE) {
- $collections = array_unique(array_merge($this->sourceStorage->getAllCollectionNames(), $this->targetStorage->getAllCollectionNames()));
+ if ($this->allCollectionNames === NULL) {
+ $this->allCollectionNames = array_unique(array_merge($this->sourceStorage->getAllCollectionNames(), $this->targetStorage->getAllCollectionNames()));
+ }
+ $collections = $this->allCollectionNames;
if ($include_default) {
array_unshift($collections, StorageInterface::DEFAULT_COLLECTION);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment