diff --git a/README.md b/README.md
index f1146c4d..da11a398 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,7 @@ from the community.
| [Drupal\BigPipeTrait](STEPS.md#drupalbigpipetrait) | Bypass Drupal BigPipe when rendering pages. |
| [Drupal\BlockTrait](STEPS.md#drupalblocktrait) | Manage Drupal blocks. |
| [Drupal\CacheTrait](STEPS.md#drupalcachetrait) | Invalidate specific Drupal caches from within a scenario. |
+| [Drupal\ConfigOverrideTrait](STEPS.md#drupalconfigoverridetrait) | Disable Drupal config overrides from settings.php during a scenario. |
| [Drupal\ContentBlockTrait](STEPS.md#drupalcontentblocktrait) | Manage Drupal content blocks. |
| [Drupal\ContentTrait](STEPS.md#drupalcontenttrait) | Manage Drupal content with workflow and moderation support. |
| [Drupal\DraggableviewsTrait](STEPS.md#drupaldraggableviewstrait) | Order items in the Drupal Draggable Views. |
diff --git a/STEPS.md b/STEPS.md
index b63e5e60..de96a3f5 100644
--- a/STEPS.md
+++ b/STEPS.md
@@ -30,6 +30,7 @@
| [Drupal\BigPipeTrait](#drupalbigpipetrait) | Bypass Drupal BigPipe when rendering pages. |
| [Drupal\BlockTrait](#drupalblocktrait) | Manage Drupal blocks. |
| [Drupal\CacheTrait](#drupalcachetrait) | Invalidate specific Drupal caches from within a scenario. |
+| [Drupal\ConfigOverrideTrait](#drupalconfigoverridetrait) | Disable Drupal config overrides from settings.php during a scenario. |
| [Drupal\ContentBlockTrait](#drupalcontentblocktrait) | Manage Drupal content blocks. |
| [Drupal\ContentTrait](#drupalcontenttrait) | Manage Drupal content with workflow and moderation support. |
| [Drupal\DraggableviewsTrait](#drupaldraggableviewstrait) | Order items in the Drupal Draggable Views. |
@@ -2711,6 +2712,60 @@ Given the render cache has been cleared
+## Drupal\ConfigOverrideTrait
+
+[Source](src/Drupal/ConfigOverrideTrait.php), [Example](tests/behat/features/drupal_config_override.feature)
+
+> Disable Drupal config overrides from settings.php during a scenario.
+>
+> Config overrides set in `settings.php` replace the stored configuration at
+> runtime. They cannot be disabled from the Behat process because tests run
+> in a separate process from the system under test (SUT).
+>
+> This trait signals the SUT - through a request header, a `$_SERVER` entry
+> and an environment variable - that specific config objects should be read
+> from their original (unoverridden) values. The SUT is responsible for
+> reading that signal and calling `ImmutableConfig::getOriginal()` instead of
+> `ImmutableConfig::get()` for the listed config names.
+>
+> Activated by adding `@disable-config-override:CONFIG_NAME` tags to a
+> feature or scenario. Multiple tags are combined into a comma-separated
+> list. Runs on every step because some steps reset headers set earlier in
+> the scenario.
+>
+> Limitations:
+> - Cannot be used with Selenium/JavaScript drivers (the underlying driver
+> does not expose request headers).
+> - The SUT must implement support for the `X-Config-No-Override` header,
+> the `HTTP_X_CONFIG_NO_OVERRIDE` `$_SERVER` entry or the matching
+> environment variable. An example implementation:
+> ```
+> public function getConfigValue(string $name, string $key): mixed {
+> $config = $this->configFactory->get($name);
+> $header = $_SERVER['HTTP_X_CONFIG_NO_OVERRIDE'] ?? getenv('HTTP_X_CONFIG_NO_OVERRIDE') ?: '';
+> if (in_array($name, array_map('trim', explode(',', $header)), TRUE)) {
+> return $config->getOriginal($key, FALSE);
+> }
+> return $config->get($key);
+> }
+> ```
+>
+> Soft dependency: if the consuming context also uses `RestTrait`, the
+> `$restHeaders` array is updated so standalone REST requests receive the
+> same signal.
+>
+> Example:
+> ```
+> @api @disable-config-override:system.site @disable-config-override:myconfig.settings
+> Scenario: Render the page with original config values
+> When I visit "/"
+> Then the response should contain "Original site name"
+> ```
+>
+> Skip processing with tags: `@behat-steps-skip:configOverrideBeforeScenario`
+> and `@behat-steps-skip:configOverrideBeforeStep`.
+
+
## Drupal\ContentBlockTrait
[Source](src/Drupal/ContentBlockTrait.php), [Example](tests/behat/features/drupal_content_block.feature)
diff --git a/scripts/provision.sh b/scripts/provision.sh
index 96b4f0d4..16ae8f2f 100755
--- a/scripts/provision.sh
+++ b/scripts/provision.sh
@@ -74,6 +74,19 @@ composer run-script post-install-cmd
echo " > Installing Drupal site."
/usr/bin/env PHP_OPTIONS='-d sendmail_path=/bin/true' /app/build/vendor/bin/drush -r /app/build/web si standard -y --db-url=mysql://drupal:drupal@mariadb/drupal --account-name=admin --account-pass=admin install_configure_form.enable_update_status_module=NULL install_configure_form.enable_update_status_emails=NULL --uri=http://nginx
+echo " > Appending fixture \$config overrides to settings.php for ConfigOverrideTrait tests."
+chmod 666 /app/build/web/sites/default/settings.php
+cat >> /app/build/web/sites/default/settings.php <<'PHP'
+
+// Fixture config overrides used by ConfigOverrideTrait tests. These mimic
+// environment-specific overrides that a real site would set in settings.php,
+// so tests can verify that @disable-config-override: tags let the SUT
+// read the stored (original) values via ImmutableConfig::getOriginal().
+$config['system.site']['name'] = 'Overridden Site Name';
+$config['system.site']['slogan'] = 'Overridden Slogan';
+PHP
+chmod 444 /app/build/web/sites/default/settings.php
+
echo " > Running post-install commands defined in the composer.json for each specific fixture."
composer run-script drupal-post-install
diff --git a/src/Drupal/ConfigOverrideTrait.php b/src/Drupal/ConfigOverrideTrait.php
new file mode 100644
index 00000000..ca7efd4e
--- /dev/null
+++ b/src/Drupal/ConfigOverrideTrait.php
@@ -0,0 +1,184 @@
+configFactory->get($name);
+ * $header = $_SERVER['HTTP_X_CONFIG_NO_OVERRIDE'] ?? getenv('HTTP_X_CONFIG_NO_OVERRIDE') ?: '';
+ * if (in_array($name, array_map('trim', explode(',', $header)), TRUE)) {
+ * return $config->getOriginal($key, FALSE);
+ * }
+ * return $config->get($key);
+ * }
+ * @endcode
+ *
+ * Soft dependency: if the consuming context also uses `RestTrait`, the
+ * `$restHeaders` array is updated so standalone REST requests receive the
+ * same signal.
+ *
+ * Example:
+ * @code
+ * @api @disable-config-override:system.site @disable-config-override:myconfig.settings
+ * Scenario: Render the page with original config values
+ * When I visit "/"
+ * Then the response should contain "Original site name"
+ * @endcode
+ *
+ * Skip processing with tags: `@behat-steps-skip:configOverrideBeforeScenario`
+ * and `@behat-steps-skip:configOverrideBeforeStep`.
+ */
+trait ConfigOverrideTrait {
+
+ /**
+ * Config names parsed from `@disable-config-override:*` tags.
+ *
+ * @var array
+ */
+ protected array $configOverrideDisabledNames = [];
+
+ /**
+ * Whether the `BeforeStep` hook should be skipped for this scenario.
+ */
+ protected bool $configOverrideSkipBeforeStep = FALSE;
+
+ /**
+ * Collect `@disable-config-override:*` tags for the current scenario.
+ *
+ * Always clears any propagated signal from a previous scenario first so
+ * state never bleeds between scenarios - even when this hook is bypassed
+ * via `@behat-steps-skip:configOverrideBeforeScenario`.
+ */
+ #[BeforeScenario]
+ public function configOverrideBeforeScenario(BeforeScenarioScope $scope): void {
+ $this->configOverrideDisabledNames = [];
+ $this->configOverrideSkipBeforeStep = FALSE;
+ $this->configOverrideClearSignal();
+
+ if ($scope->getScenario()->hasTag('behat-steps-skip:' . __FUNCTION__)) {
+ return;
+ }
+
+ // BeforeStep scope does not have access to scenario tags, so resolve the
+ // skip flag here.
+ if ($scope->getScenario()->hasTag('behat-steps-skip:configOverrideBeforeStep')) {
+ $this->configOverrideSkipBeforeStep = TRUE;
+ }
+
+ $tags = array_unique(array_merge($scope->getFeature()->getTags(), $scope->getScenario()->getTags()));
+ $prefix = 'disable-config-override:';
+ foreach ($tags as $tag) {
+ if (str_starts_with($tag, $prefix)) {
+ $name = substr($tag, strlen($prefix));
+ if ($name !== '' && !in_array($name, $this->configOverrideDisabledNames, TRUE)) {
+ $this->configOverrideDisabledNames[] = $name;
+ }
+ }
+ }
+ }
+
+ /**
+ * Apply the `X-Config-No-Override` signal before every step.
+ *
+ * This runs on every step because some steps reset headers set earlier in
+ * the scenario (for example, Drupal Extension login steps).
+ */
+ #[BeforeStep]
+ public function configOverrideBeforeStep(BeforeStepScope $scope): void {
+ if ($this->configOverrideSkipBeforeStep || $this->configOverrideDisabledNames === []) {
+ // Nothing to propagate - make sure the driver-level header is also
+ // cleared so a previously-set value does not survive into this step.
+ $this->configOverrideClearDriverHeader();
+ return;
+ }
+
+ $value = implode(',', $this->configOverrideDisabledNames);
+
+ // Set request header on the Mink driver for BrowserKit-based sessions.
+ // Selenium-based drivers cannot set request headers - skip silently.
+ $driver = $this->getSession()->getDriver();
+ if (!$driver instanceof Selenium2Driver) {
+ $driver->setRequestHeader('X-Config-No-Override', $value);
+ }
+
+ // Soft dependency on RestTrait: propagate to the standalone REST client
+ // when RestTrait is also used by the consuming context.
+ // @phpstan-ignore-next-line function.alreadyNarrowedType
+ if (property_exists($this, 'restHeaders')) {
+ $this->restHeaders['X-Config-No-Override'] = $value;
+ }
+
+ // For SUTs accessed via direct code invocation within the same process.
+ $_SERVER['HTTP_X_CONFIG_NO_OVERRIDE'] = $value;
+
+ // For SUTs accessed via Drush subprocesses.
+ putenv('HTTP_X_CONFIG_NO_OVERRIDE=' . $value);
+ }
+
+ /**
+ * Clear the process-level and REST-level X-Config-No-Override signal.
+ *
+ * Driver-level request headers are cleared separately in the BeforeStep
+ * hook because the session is not guaranteed to be started at the point
+ * BeforeScenario runs.
+ */
+ protected function configOverrideClearSignal(): void {
+ unset($_SERVER['HTTP_X_CONFIG_NO_OVERRIDE']);
+ putenv('HTTP_X_CONFIG_NO_OVERRIDE');
+
+ // @phpstan-ignore-next-line function.alreadyNarrowedType
+ if (property_exists($this, 'restHeaders')) {
+ unset($this->restHeaders['X-Config-No-Override']);
+ }
+ }
+
+ /**
+ * Clear the driver-level X-Config-No-Override request header.
+ */
+ protected function configOverrideClearDriverHeader(): void {
+ try {
+ $driver = $this->getSession()->getDriver();
+ }
+ // @codeCoverageIgnoreStart
+ catch (\Exception) {
+ return;
+ }
+ // @codeCoverageIgnoreEnd
+ if (!$driver instanceof Selenium2Driver) {
+ $driver->setRequestHeader('X-Config-No-Override', '');
+ }
+ }
+
+}
diff --git a/tests/behat/bootstrap/FeatureContext.php b/tests/behat/bootstrap/FeatureContext.php
index 9cbaa1c3..fe3f3cc4 100644
--- a/tests/behat/bootstrap/FeatureContext.php
+++ b/tests/behat/bootstrap/FeatureContext.php
@@ -12,6 +12,7 @@
use DrevOps\BehatSteps\Drupal\BigPipeTrait;
use DrevOps\BehatSteps\Drupal\BlockTrait;
use DrevOps\BehatSteps\Drupal\CacheTrait;
+use DrevOps\BehatSteps\Drupal\ConfigOverrideTrait;
use DrevOps\BehatSteps\Drupal\ContentBlockTrait;
use DrevOps\BehatSteps\Drupal\ContentTrait;
use DrevOps\BehatSteps\Drupal\DraggableviewsTrait;
@@ -58,6 +59,7 @@ class FeatureContext extends DrupalContext {
use BigPipeTrait;
use BlockTrait;
use CacheTrait;
+ use ConfigOverrideTrait;
use ContentBlockTrait;
use ContentTrait;
use CookieTrait;
diff --git a/tests/behat/features/drupal_config_override.feature b/tests/behat/features/drupal_config_override.feature
new file mode 100644
index 00000000..69a1f3a5
--- /dev/null
+++ b/tests/behat/features/drupal_config_override.feature
@@ -0,0 +1,67 @@
+@config-override
+Feature: Check that ConfigOverrideTrait works
+ As Behat Steps library developer
+ I want to provide a way to disable Drupal config overrides for a scenario
+ So that users can test original config values without redeploying the SUT
+
+ # The fixture site has the following overrides in settings.php:
+ # $config['system.site']['name'] = 'Overridden Site Name';
+ # $config['system.site']['slogan'] = 'Overridden Slogan';
+ # The stored (original) name is 'Drush Site-Install'.
+
+ @api
+ Scenario: Without the tag, the SUT serves the settings.php-overridden config value
+ When I visit "/mysite_core/test-config-system-site-name"
+ Then the response status code should be 200
+ And the response should contain "Overridden Site Name"
+
+ @api @disable-config-override:system.site
+ Scenario: With @disable-config-override, the SUT serves the original stored config value
+ When I visit "/mysite_core/test-config-system-site-name"
+ Then the response status code should be 200
+ And the response should contain "Drush Site-Install"
+ And the response should not contain "Overridden Site Name"
+
+ @api
+ Scenario: Visiting a page without the tag sends no X-Config-No-Override header
+ When I visit "/mysite_core/test-config-no-override-header"
+ Then the response status code should be 200
+ And the response should not contain "system.site"
+
+ @api @disable-config-override:system.site
+ Scenario: A single @disable-config-override tag sets the X-Config-No-Override header
+ When I visit "/mysite_core/test-config-no-override-header"
+ Then the response status code should be 200
+ And the response should contain "system.site"
+
+ @api @disable-config-override:system.site @disable-config-override:myconfig.settings
+ Scenario: Multiple @disable-config-override tags set a comma-separated X-Config-No-Override header
+ When I visit "/mysite_core/test-config-no-override-header"
+ Then the response status code should be 200
+ And the response should contain "system.site,myconfig.settings"
+
+ @api @disable-config-override:system.site
+ Scenario: The X-Config-No-Override header survives a login step that resets headers
+ Given users:
+ | name | mail | roles | status |
+ | test_user | test_user@example.com | administrator | 1 |
+ When I am logged in as "test_user"
+ And I visit "/mysite_core/test-config-no-override-header"
+ Then the response status code should be 200
+ And the response should contain "system.site"
+
+ @api @disable-config-override:system.site @behat-steps-skip:configOverrideBeforeScenario
+ Scenario: The @behat-steps-skip:configOverrideBeforeScenario tag bypasses the trait entirely
+ When I visit "/mysite_core/test-config-no-override-header"
+ Then the response status code should be 200
+ And the response should not contain "system.site"
+
+ @api @disable-config-override:system.site @behat-steps-skip:configOverrideBeforeStep
+ Scenario: The @behat-steps-skip:configOverrideBeforeStep tag keeps tag parsing but skips header propagation
+ Given users:
+ | name | mail | roles | status |
+ | test_user2 | test_user2@example.com | administrator | 1 |
+ When I am logged in as "test_user2"
+ And I visit "/mysite_core/test-config-no-override-header"
+ Then the response status code should be 200
+ And the response should not contain "system.site"
diff --git a/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/mysite_core.routing.yml b/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/mysite_core.routing.yml
index 33413491..eac0974e 100644
--- a/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/mysite_core.routing.yml
+++ b/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/mysite_core.routing.yml
@@ -13,3 +13,17 @@ mysite_core.test_time:
_access: 'TRUE'
defaults:
_controller: '\Drupal\mysite_core\Controller\TestContent::testTime'
+
+mysite_core.test_config_no_override_header:
+ path: '/mysite_core/test-config-no-override-header'
+ requirements:
+ _access: 'TRUE'
+ defaults:
+ _controller: '\Drupal\mysite_core\Controller\TestContent::testConfigNoOverrideHeader'
+
+mysite_core.test_config_system_site_name:
+ path: '/mysite_core/test-config-system-site-name'
+ requirements:
+ _access: 'TRUE'
+ defaults:
+ _controller: '\Drupal\mysite_core\Controller\TestContent::testConfigSystemSiteName'
diff --git a/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/src/Controller/TestContent.php b/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/src/Controller/TestContent.php
index 801e226d..3db4fac7 100644
--- a/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/src/Controller/TestContent.php
+++ b/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/src/Controller/TestContent.php
@@ -7,6 +7,7 @@
use Drupal\Core\Controller\ControllerBase;
use Drupal\mysite_core\Time\TimeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -49,4 +50,42 @@ public function testTime(): Response {
]);
}
+ /**
+ * Echoes the value of the X-Config-No-Override request header.
+ *
+ * Used by ConfigOverrideTrait tests to verify that the Behat process
+ * successfully signals the SUT.
+ */
+ public function testConfigNoOverrideHeader(Request $request): Response {
+ $value = (string) $request->headers->get('X-Config-No-Override', '');
+
+ return new Response($value, 200, [
+ 'Content-Type' => 'text/plain',
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
+ ]);
+ }
+
+ /**
+ * Returns the system.site name, honoring X-Config-No-Override.
+ *
+ * Demonstrates the SUT-side implementation expected by
+ * ConfigOverrideTrait: when the request header lists a config name, the
+ * original (un-overridden) value is returned via
+ * ImmutableConfig::getOriginal().
+ */
+ public function testConfigSystemSiteName(Request $request): Response {
+ $config = $this->config('system.site');
+ $header = (string) $request->headers->get('X-Config-No-Override', '');
+ $disabled = array_filter(array_map(trim(...), explode(',', $header)));
+
+ $value = in_array('system.site', $disabled, TRUE)
+ ? (string) $config->getOriginal('name', FALSE)
+ : (string) $config->get('name');
+
+ return new Response($value, 200, [
+ 'Content-Type' => 'text/plain',
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
+ ]);
+ }
+
}
diff --git a/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/mysite_core.routing.yml b/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/mysite_core.routing.yml
index 33413491..eac0974e 100644
--- a/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/mysite_core.routing.yml
+++ b/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/mysite_core.routing.yml
@@ -13,3 +13,17 @@ mysite_core.test_time:
_access: 'TRUE'
defaults:
_controller: '\Drupal\mysite_core\Controller\TestContent::testTime'
+
+mysite_core.test_config_no_override_header:
+ path: '/mysite_core/test-config-no-override-header'
+ requirements:
+ _access: 'TRUE'
+ defaults:
+ _controller: '\Drupal\mysite_core\Controller\TestContent::testConfigNoOverrideHeader'
+
+mysite_core.test_config_system_site_name:
+ path: '/mysite_core/test-config-system-site-name'
+ requirements:
+ _access: 'TRUE'
+ defaults:
+ _controller: '\Drupal\mysite_core\Controller\TestContent::testConfigSystemSiteName'
diff --git a/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/src/Controller/TestContent.php b/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/src/Controller/TestContent.php
index 801e226d..3db4fac7 100644
--- a/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/src/Controller/TestContent.php
+++ b/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/src/Controller/TestContent.php
@@ -7,6 +7,7 @@
use Drupal\Core\Controller\ControllerBase;
use Drupal\mysite_core\Time\TimeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -49,4 +50,42 @@ public function testTime(): Response {
]);
}
+ /**
+ * Echoes the value of the X-Config-No-Override request header.
+ *
+ * Used by ConfigOverrideTrait tests to verify that the Behat process
+ * successfully signals the SUT.
+ */
+ public function testConfigNoOverrideHeader(Request $request): Response {
+ $value = (string) $request->headers->get('X-Config-No-Override', '');
+
+ return new Response($value, 200, [
+ 'Content-Type' => 'text/plain',
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
+ ]);
+ }
+
+ /**
+ * Returns the system.site name, honoring X-Config-No-Override.
+ *
+ * Demonstrates the SUT-side implementation expected by
+ * ConfigOverrideTrait: when the request header lists a config name, the
+ * original (un-overridden) value is returned via
+ * ImmutableConfig::getOriginal().
+ */
+ public function testConfigSystemSiteName(Request $request): Response {
+ $config = $this->config('system.site');
+ $header = (string) $request->headers->get('X-Config-No-Override', '');
+ $disabled = array_filter(array_map(trim(...), explode(',', $header)));
+
+ $value = in_array('system.site', $disabled, TRUE)
+ ? (string) $config->getOriginal('name', FALSE)
+ : (string) $config->get('name');
+
+ return new Response($value, 200, [
+ 'Content-Type' => 'text/plain',
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
+ ]);
+ }
+
}