How to prevent PHPStorm from restoring manually deleted include path entries even with Composer options disabled?

All these options under «Languages & Frameworks» → «PHP» → «Composer» are disabled:

  • «Add packages as libraries»
  • «Synchronize IDE Settings with composer.json»
  • «Check for available package updates»

2024-10-06--22-17-53

Step 1

Delete all entries from «Languages & Frameworks» → «PHP» → «Composer» → «Composer Files»:
2024-10-06--23-06-50

Step 2

Exit IntelliJ IDEA / PHPStorm completely.

Step 3

How to remove «Include Path» entries from PHPStorm when the minus button is disabled?

Step 4

PHPStorm will likely make multiple attempts to restore the contents of the .idea/php.xml file that we modified in the previous step.
So, make a copy of the file after editing it.

Step 5

Start IntelliJ IDEA / PHPStorm again and open your project.
After that, PHPStorm re-enabled (for an unknown reason) the 2 options under «Languages & Frameworks» → «PHP» → «Composer»:

  • «Add packages as libraries»
  • «Synchronize IDE Settings with composer.json»

2024-10-06--23-17-18

Step 6

Disable the 2 options again:

  • «Add packages as libraries»
  • «Synchronize IDE Settings with composer.json»

Then, repeat Step 2 and Step 3.
You may need to repeat this procedure multiple times.
That’s why we made a copy of the .idea/php.xml file in Step 4.

1. Root cause analysis

1.1. Plugin architecture and closed-source implementation

The JetBrains PHP plugin (com.jetbrains.php) is a proprietary, closed-source component bundled with PhpStorm and IntelliJ IDEA Ultimate inside plugins/php-impl/lib/php.jar (e.g., %APPDATA%\JetBrains\IntelliJIdea<version>\plugins\php-impl\lib\php.jar). Related bug reports are tracked on JetBrains YouTrack:

While the base IntelliJ Platform is open-source on GitHub (github.com/JetBrains/intellij-community/tree/idea/262.10968.63), the PHP plugin bytecode decompiled directly from php.jar using Fernflower reveals how project opening and Composer detection operate.


1.2. Execution flow on project open

When a project is opened in the IDE, JetBrains runs background startup activities implementing com.intellij.openapi.startup.ProjectActivity:

Specifically, com.jetbrains.php.roots.PhpDetectPsrRootsOnProjectOpen triggers ComposerDetectionManager.detectComposer(project).

Decompiled from com/jetbrains/php/composer/ComposerDetectionManager.class:

package com.jetbrains.php.composer;

public final class ComposerDetectionManager {
	public static @Nullable ComposerConfigs detectComposer(Project project) {
		if (project != null && !project.isDefault()) {
			if (ComposerConfigManager.getInstance(project).isConfigured()) {
				return null;
			} else {
				VirtualFile mainComposerJson = findMainConfig(project);
				if (mainComposerJson == null) {
					return null;
				} else {
					Collection<VirtualFile> subProjectConfigs = findSubProjectConfigs(project, mainComposerJson);
					return new ComposerConfigs(mainComposerJson, subProjectConfigs);
				}
			}
		} else {
			return null;
		}
	}
}

Notice the safeguard at line 34:

if (ComposerConfigManager.getInstance(project).isConfigured()) {
	return null;
}

If isConfigured() returns true, the detection routine exits immediately. No files are scanned, no notifications are displayed, and no settings are altered.


1.3. The flaw: how isConfigured() is implemented

Decompiled from com/jetbrains/php/composer/configData/ComposerConfigManager.class:

package com.jetbrains.php.composer.configData;

@Service({Level.PROJECT})
@State(
	name = "ComposerConfigs",
	storages = {@Storage("$WORKSPACE_FILE$")}
)
public final class ComposerConfigManager extends SimplePersistentStateComponent<ComposerConfigState> {
	private final Set<VirtualFile> configs;

	public final boolean isConfigured() {
		return !((ComposerConfigState)this.getState()).getNotifyAboutSettingsSync() || !((Collection)this.configs).isEmpty();
	}
}

And in the inner state class ComposerConfigState:

public static final class ComposerConfigState extends BaseState {
	public ComposerConfigState() {
		this.configs$delegate = this.list().provideDelegate(this, $$delegatedProperties[0]);
		this.notifyAboutSettingsSync$delegate = this.property(true).provideDelegate(this, $$delegatedProperties[1]);
	}

	public final boolean getNotifyAboutSettingsSync() {
		return (Boolean)this.notifyAboutSettingsSync$delegate.getValue(this, $$delegatedProperties[1]);
	}
}

Key observations:

  1. notifyAboutSettingsSync is initialized to true by default. Thus !getNotifyAboutSettingsSync() evaluates to false.
  2. As a result, isConfigured() evaluates to:
isConfigured() = false || !this.configs.isEmpty() = !this.configs.isEmpty()
  1. Therefore, the IDE considers a project to be configured if and only if configs is NOT empty!

1.4. Why deleting «Composer Files» triggers re-population

When all entries are deleted from «Composer Files»:

  1. <component name='ComposerConfigs'> becomes empty in .idea/workspace.xml.
  2. On the next IDE launch, this.configs.isEmpty() is true.
  3. isConfigured() returns false.
  4. detectComposer(project) runs and scans the repository using FilenameIndex.getVirtualFilesByName():
  1. In projects containing nested packages, test suites, or sub-modules, it locates the root composer.json plus all sub-project composer.json files outside /vendor/.
  2. Because isConfigured() is false, detectComposer() returns a non-null ComposerConfigs object.
  3. Next, ComposerDetectionManager.updateProjectSettings(project, composerConfigs) is invoked:
public static void updateProjectSettings(@NotNull Project project, @Nullable ComposerConfigs composerConfigs) {
	...
	dataService.setConfigPathAndLibraryUpdateStatus(composerConfigs.main().getPath(), true);

	for (VirtualFile config : composerConfigs.subProjects) {
		configManager.addConfig(config);
		ComposerLibraryService libraryManager = ComposerLibraryServiceFactory.getInstance(project, config);
		libraryManager.reloadLibraries(true, Collections.emptySet(), false, true);
	}

	if (isDefaultAllSettings) {
		dataService.setSynchronizationState(ComposerDataService.SynchronizationState.SYNCHRONIZE);
	}

	showNotification(project, composerConfigs, syncState, updateLibrary, isMainConfigUpdated);
}
  1. libraryManager.reloadLibraries(true, ...) executes:
    • Injects all vendor packages back into include_path.
    • Adds hundreds of <excludeFolder> lines to .iml.
    • Resets synchronizationState to SYNCHRONIZE.
    • Re-enables the checkboxes in the UI.

Deleting entries from «Composer Files» only appears to work after multiple manual attempts when repeated saving eventually causes the IDE to persist discovered sub-configs back into ComposerConfigs while keeping DONT_SYNCHRONIZE set.


2. The permanent solution

To prevent IntelliJ IDEA / PhpStorm from ever running detectComposer() and re-populating include_path:

Do NOT empty <component name='ComposerConfigs'>!

Instead:

  1. Pre-populate <component name='ComposerConfigs'> in .idea/workspace.xml with the paths to all sub-project composer.json files in the repository. Because configs is not empty, isConfigured() immediately returns true on project startup, and detectComposer() aborts on line 34 without modifying anything.
  2. Explicitly disable synchronization in <component name='ComposerSettings'> using synchronizationState='DONT_SYNCHRONIZE' and updatePackages='false'.
  3. Remove include_path entries from .idea/workspace.xml and .idea/php.xml.
  4. Clean .iml so it only contains project-level exclusions, with no vendor package exclusions.

3. Configuration reference

Make these modifications while IntelliJ IDEA / PhpStorm is closed.

3.1. .idea/workspace.xml

Ensure <component name='ComposerConfigs'> contains all sub-project composer.json files, <component name='ComposerSettings'> has synchronizationState='DONT_SYNCHRONIZE', and <component name='PhpWorkspaceProjectConfiguration'> is self-closing without <include_path>:

<component name='ComposerConfigs'>
	<option name='configs'>
		<option value='$PROJECT_DIR$/packages/module-a/composer.json' />
		<option value='$PROJECT_DIR$/packages/module-b/composer.json' />
		<option value='$PROJECT_DIR$/tests/integration/composer.json' />
	</option>
</component>
<component
	loadUpdateAvailability='false'
	name='ComposerSettings'
	notifyAboutMissingVendor='false'
	showQuickActionsPanel='false'
	synchronizationState='DONT_SYNCHRONIZE'
	updatePackages='false'
>
	<pharConfigPath>$PROJECT_DIR$/composer.json</pharConfigPath>
	<execution>
		<executable path='composer' />
	</execution>
</component>
<component interpreter_name='PHP' name='PhpWorkspaceProjectConfiguration' />

(Note: In <component name='ComposerConfigs'>, list every sub-project composer.json file in your repository located outside the vendor directory).


3.2. .idea/php.xml

Ensure that <component name='PhpIncludePathManager'> is completely absent:

<?xml version='1.0' encoding='UTF-8'?>
<project version='4'>
	<component name='PhpProjectSharedConfiguration' php_language_level='8.3'>
		<option name='suggestChangeDefaultLanguageLevel' value='false' />
	</component>
</project>

3.3. <module_name>.iml

Ensure the module configuration only excludes your intended project directories, with no <excludeFolder> entries for vendor packages:

<?xml version='1.0' encoding='UTF-8'?>
<module type='JAVA_MODULE' version='4'>
	<component inherit-compiler-output='true' name='NewModuleRootManager'>
		<exclude-output />
		<content url='file://$MODULE_DIR$'>
			<excludeFolder url='file://$MODULE_DIR$/var' />
			<excludeFolder url='file://$MODULE_DIR$/cache' />
		</content>
		<orderEntry type='inheritedJdk' />
		<orderEntry forTests='false' type='sourceFolder' />
	</component>
</module>

4. Result and verification

  1. When the project is opened, ComposerConfigManager.getInstance(project).isConfigured() checks configs. Because configs contains the pre-populated paths of all sub-project composer.json files, isConfigured() immediately evaluates to true.
  2. ComposerDetectionManager.detectComposer(project) returns null at line 34, completely bypassing the scan and update logic.
  3. No balloon notification is shown.
  4. «Languages & Frameworks» → «PHP» → «Include Path» displays «Nothing to show».
  5. The .iml file stays clean without vendor package pollution.
  6. «Copy Reference» (Ctrl+Alt+Shift+C) reliably produces the correct, full relative path from the project root.