diff --git a/includes/include.drupalci.main.yml b/includes/include.drupalci.main.yml
index 43b2310da011282e13597a380e607ecb6f304e0e..b380f3c9674eb658bece8c41c4794b0fa98a6b0f 100644
--- a/includes/include.drupalci.main.yml
+++ b/includes/include.drupalci.main.yml
@@ -72,6 +72,15 @@
       echo -e "\e[0Ksection_end:`date +%s`:show_env_vars\r\e[0K"
     fi
 
+# Check the internal composer end code. This is necessary in case allow_failure:true is set in the Composer job or one of its variants.
+.check-composer-end-code: &check-composer-end-code
+  - |
+    echo "COMPOSER_END_CODE=$COMPOSER_END_CODE"
+    if [ "$COMPOSER_END_CODE" != "0" ]; then
+      printf "$DIVIDER\nERROR: The pre-requisite Composer job did not complete successfully.\nCheck the log of that job for details.$DIVIDER\n"
+      exit 1;
+    fi;
+
 # Separately install and configure lenient plugin, if required.
 .add-composer-drupal-lenient: &add-composer-drupal-lenient
   - |
@@ -357,7 +366,8 @@ stages:
       else
         echo "{}" > composer.json
       fi
-    - php expand_composer_json.php
+    - php expand_composer_json.php || EXPAND_COMPOSER_EXIT_CODE=$?
+    - if [[ "$EXPAND_COMPOSER_EXIT_CODE" != "" ]]; then echo "EXPAND_COMPOSER_EXIT_CODE=$EXPAND_COMPOSER_EXIT_CODE"; exit $EXPAND_COMPOSER_EXIT_CODE; fi
     - composer install $COMPOSER_EXTRA
     - rm expand_composer_json.php
     - *require-drush
@@ -378,6 +388,8 @@ stages:
     - touch $_WEB_ROOT/core/.env
     # Display any deprecation warning which may have been set.
     - printf "$_DEPRECATION_MESSAGE"
+    # Set an internal composer end code, to halt subsequent jobs even when allow_failure: true is set.
+    - echo "COMPOSER_END_CODE=0" >> build.env
 
 composer:
   extends: .composer-base
@@ -532,6 +544,7 @@ phpcs:
   needs:
     - composer
   script:
+    - *check-composer-end-code
     - *calculate-gitlab-ref
     # If present, use PHPStan configuration neon file.
     - |
@@ -605,6 +618,8 @@ phpstan (next major):
     - *php-files-exist-rule
   needs:
     - "composer (next major)"
+  # @TODO Delete 'allow_failure:true' in MR242
+  allow_failure: true
   script:
     - *amend-core-requirements-drupal-11
     - !reference [phpstan, script]
@@ -851,6 +866,7 @@ cspell:
     DRUPAL_NIGHTWATCH_IGNORE_DIRECTORIES: node_modules,vendor,.*,sites/*/files,sites/*/private,sites/simpletest
     DRUPAL_NIGHTWATCH_OUTPUT: reports/nightwatch
   script:
+    - *check-composer-end-code
     - *setup-webserver
     - *simpletest-db
     - export DRUPAL_TEST_DB_URL=$SIMPLETEST_DB
@@ -982,6 +998,7 @@ nightwatch (next major):
   variables:
     SYMFONY_DEPRECATIONS_HELPER: "disabled"
   script:
+    - *check-composer-end-code
     - *show-environment-variables
     - *setup-webserver
     - *simpletest-db
diff --git a/scripts/expand_composer_json.php b/scripts/expand_composer_json.php
index ddeb95dcae8c24249dcbe112479cde6785fae6d5..d34527c97adc33502fcd496fd23be4571aa5c9db 100755
--- a/scripts/expand_composer_json.php
+++ b/scripts/expand_composer_json.php
@@ -6,6 +6,8 @@
  * Populate a composer.json with the right dependencies.
  */
 
+set_error_handler("halt_on_warning");
+
 $project_name = $argv[1] ?? getenv('CI_PROJECT_NAME');
 if (empty($project_name)) {
   throw new RuntimeException('Unable to determine project name.');
@@ -113,7 +115,23 @@ if ($composer_patches_file) {
 
 // Remove empty top-level items.
 $json_rich = array_filter($json_rich);
-file_put_contents(empty(getenv('COMPOSER')) ? $path : getenv('COMPOSER'), json_encode($json_rich, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT));
+$output_file = getenv('COMPOSER') ?: $path;
+print "Writing output to {$output_file}" . PHP_EOL;
+file_put_contents($output_file, json_encode($json_rich, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT));
+
+/**
+ * Helper function to treat warnings as errors.
+ *
+ * If the composer.json file is mal-formed this might only produce a warning,
+ * but we want to exit with a code that can be detected in the calling job.
+ */
+function halt_on_warning($errno, $message, $file, $line) {
+  if ($errno === E_WARNING) {
+    exit(2);
+  }
+  // Return FALSE to execute the regular error handler.
+  return FALSE;
+}
 
 /**
  * Get default composer.json contents.