From d3e26a2ff3da88076852a763944b9c2f059d9ca5 Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Tue, 23 Jun 2026 16:36:28 +1000 Subject: [PATCH 1/3] feat: propagate config.platform from core's composer.json composer-merge-plugin does not merge the config section, only package metadata. This means config.platform.php from core's composer.json is not visible to tools like PHPStan when running via composer.local.json as the root, causing PHPStan to analyse against the running PHP version (e.g. 8.5) instead of the declared target version (e.g. 8.3 on 11.x). Hoist the core composer.json read out of the installer-paths block and propagate config.platform via Config::merge() alongside it, using the same pattern already used for installer-paths and root version pinning. --- drupal-dev/composer-plugin/src/Plugin.php | 31 ++++++++++++++--------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/drupal-dev/composer-plugin/src/Plugin.php b/drupal-dev/composer-plugin/src/Plugin.php index a97d152..483480c 100644 --- a/drupal-dev/composer-plugin/src/Plugin.php +++ b/drupal-dev/composer-plugin/src/Plugin.php @@ -29,18 +29,25 @@ public function activate(Composer $composer, IOInterface $io): void $this->composer = $composer; $this->io = $io; - // Seed installer-paths from core's composer.json into the root package - // extra so the fallback in getInstallPath() works before the merge - // plugin has had a chance to merge core's extra. - $rootExtra = $composer->getPackage()->getExtra(); - if (empty($rootExtra['installer-paths'])) { - $coreFile = getcwd() . '/composer.json'; - if (file_exists($coreFile)) { - $coreConfig = json_decode(file_get_contents($coreFile), true); - if (!empty($coreConfig['extra']['installer-paths'])) { - $rootExtra['installer-paths'] = $coreConfig['extra']['installer-paths']; - $composer->getPackage()->setExtra($rootExtra); - } + // Seed installer-paths and config.platform from core's composer.json + // into the root package so the fallback in getInstallPath() works + // before the merge plugin has had a chance to merge core's extra, and + // so PHPStan sees the correct platform PHP version. + $coreFile = getcwd() . '/composer.json'; + if (file_exists($coreFile)) { + $coreConfig = json_decode(file_get_contents($coreFile), true); + + $rootExtra = $composer->getPackage()->getExtra(); + if (empty($rootExtra['installer-paths']) && !empty($coreConfig['extra']['installer-paths'])) { + $rootExtra['installer-paths'] = $coreConfig['extra']['installer-paths']; + $composer->getPackage()->setExtra($rootExtra); + } + + if (!empty($coreConfig['config']['platform'])) { + $composer->getConfig()->merge( + ['config' => ['platform' => $coreConfig['config']['platform']]], + $coreFile + ); } } From 78628995ea3ca8e14c6d80e0d54432f73d3285ad Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Wed, 24 Jun 2026 08:30:17 +1000 Subject: [PATCH 2/3] fix: also write config.platform to the root composer file on disk PHPStan reads platform.php directly from the file pointed to by the $COMPOSER env var (e.g. composer.local.json) in a separate process. The in-memory Config::merge() is not visible to it. Add syncPlatformToRootFile() which writes config.platform from composer.json into composer.local.json on disk whenever the two differ, so PHPStan (and any other tool that reads the root file directly) picks up the correct platform PHP version without needing a phpVersion setting in phpstan.neon. --- drupal-dev/composer-plugin/src/Plugin.php | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/drupal-dev/composer-plugin/src/Plugin.php b/drupal-dev/composer-plugin/src/Plugin.php index 483480c..6b5e1fd 100644 --- a/drupal-dev/composer-plugin/src/Plugin.php +++ b/drupal-dev/composer-plugin/src/Plugin.php @@ -33,6 +33,11 @@ public function activate(Composer $composer, IOInterface $io): void // into the root package so the fallback in getInstallPath() works // before the merge plugin has had a chance to merge core's extra, and // so PHPStan sees the correct platform PHP version. + // + // config.platform must also be written to the root composer file on + // disk because PHPStan reads it directly from that file (via the + // $COMPOSER env var) in a separate process — the in-memory Config + // merge is not visible to it. $coreFile = getcwd() . '/composer.json'; if (file_exists($coreFile)) { $coreConfig = json_decode(file_get_contents($coreFile), true); @@ -48,6 +53,7 @@ public function activate(Composer $composer, IOInterface $io): void ['config' => ['platform' => $coreConfig['config']['platform']]], $coreFile ); + $this->syncPlatformToRootFile($coreConfig['config']['platform']); } } @@ -127,6 +133,44 @@ private function registerInstaller(): void $this->composer->getInstallationManager()->addInstaller($installer); } + /** + * Write config.platform into the root composer file (e.g. composer.local.json) + * so that tools like PHPStan, which read that file directly in a separate + * process, pick up the correct platform PHP version. + * + * Only writes when the value differs from what is already on disk to avoid + * unnecessary file churn. + * + * @param array $platform + */ + private function syncPlatformToRootFile(array $platform): void + { + $envComposer = getenv('COMPOSER'); + $rootFileName = is_string($envComposer) && $envComposer !== '' ? basename($envComposer) : 'composer.json'; + $rootFile = getcwd() . '/' . $rootFileName; + + // Only act when we're actually running under an overlay root file that + // is separate from the core composer.json we just read. + if ($rootFile === getcwd() . '/composer.json' || !file_exists($rootFile)) { + return; + } + + $rootData = json_decode(file_get_contents($rootFile), true); + if (!is_array($rootData)) { + return; + } + + if (($rootData['config']['platform'] ?? null) === $platform) { + return; + } + + $rootData['config']['platform'] = $platform; + file_put_contents( + $rootFile, + json_encode($rootData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" + ); + } + public function deactivate(Composer $composer, IOInterface $io): void { } From 3ec8149efde12dd3535e69563f217c345715d34a Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Wed, 24 Jun 2026 08:45:46 +1000 Subject: [PATCH 3/3] test: verify config.platform is synced to composer.local.json Assert that after composer install: - config.platform.php in composer.local.json matches the value from core's composer.json - PHPStan reports the platform version for analysis rather than falling back to the runtime PHP version --- tests/test.bats | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test.bats b/tests/test.bats index 3d2d7e4..e3d4eda 100644 --- a/tests/test.bats +++ b/tests/test.bats @@ -359,3 +359,21 @@ PY [ "${core_sha}" = "${overlay_sha}" ] fi } + +@test "config-platform" { + set -eu -o pipefail + addon_setup + + # The plugin must sync config.platform from composer.json into composer.local.json + # so that tools like PHPStan, which read the root composer file directly, pick + # up the correct platform PHP version instead of falling back to the runtime. + core_platform=$(python3 -c "import json; print(json.load(open('${TESTDIR}/composer.json'))['config']['platform']['php'])") + overlay_platform=$(python3 -c "import json; print(json.load(open('${TESTDIR}/composer.local.json'))['config']['platform']['php'])") + [ "${core_platform}" = "${overlay_platform}" ] + + # PHPStan must report the platform version, not the runtime version. + # -vvv emits "PHP version for analysis: X.Y (from config.platform.php in composer.json)" + run ddev exec vendor/bin/phpstan -vvv analyze --configuration=core/phpstan.neon.dist -- core/lib/Drupal/Core/Entity/EntityInterface.php + assert_success + assert_output --partial "from config.platform.php" +}