mnsami / composer-custom-directory-installer
A composer plugin, to help install packages of different types in custom paths.
Package info
github.com/mnsami/composer-custom-directory-installer
Type:composer-plugin
pkg:composer/mnsami/composer-custom-directory-installer
Fund package maintenance!
Requires
- php: >=8.1
- composer-plugin-api: ^2.0
Requires (Dev)
- composer/composer: ^2.0
- friendsofphp/php-cs-fixer: ^3.75
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master / 2.2.x-dev
- 2.2.1
- 2.2.0
- 2.1.0
- 2.0.0
- 1.1.1
- 1.1.0
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- dev-feat/issue-65-packaging-changelog-security
- dev-feat/issue-64-ci-phpstan-dependabot-matrix
- dev-feat/issue-63-integration-tests
- dev-feat/issue-62-normalise-matching-and-paths
- dev-feat/issue-61-remove-dead-pear-code
- dev-feat/issue-60-document-stale-directories
- dev-bug/issue-59-unknown-var-flag-throw
- dev-feat/issue-58-vendor-prefix-installer-paths
- dev-bug/issue-57-installer-types-typeerror
- dev-bug/issue-56-readme-path-examples
- dev-bug/issue-55-plugin-modifies-install-path
- dev-bug/issue-54-composer-plugin-api-floor
- dev-chore/issue-68-coddy-pr-check
- dev-fix/remove-pear-warning
- dev-worktree-marketing+readme-improvements
This package is auto-updated.
Last update: 2026-10-13 22:46:57 UTC
README
A Composer plugin to install packages in custom directories outside the default vendor folder.
This is not another composer-installer library for supporting non-composer package types such as application. By default it handles library and composer-plugin packages, and you can extend it to any other Composer package type via extra.installer-types in your root composer.json.
Table of Contents
- Why this plugin?
- Requirements
- Installation
- How to use
- Path Variables
- Matching Strategies
- Custom
installer-name - Supporting Custom Package Types
- Complete example
- Real-world use cases
- Migrating from oomphinc/composer-installers-extender
- Security
- Troubleshooting
- Upgrading to v2.1
- Note
https://getcomposer.org/doc/04-schema.md#type
The type of the package. It defaults to library.
Package types are used for custom installation logic. If you have a package that needs some special logic, you can define a custom type. This could be a symfony-bundle, a wordpress-plugin or a typo3-module. These types will all be specific to certain projects, and they will need to provide an installer capable of installing packages of that type.
Why this plugin?
Composer itself does not read installer-paths. The key is interpreted by composer/installers, and that plugin only acts on packages whose type is one of the framework types in its list (wordpress-plugin, drupal-module, …). A plain library package — which is most of Packagist — never matches, so Composer silently installs it to vendor/ no matter what your installer-paths says.
This plugin acts on any package regardless of its type. It routes library packages out of the box, composer-plugin packages through its own installer, and any other type you list in extra.installer-types. The rule lives in the root project, so the package itself needs no changes.
In short: if you've ever added installer-paths and nothing happened, this plugin is the fix.
Requirements
- PHP >= 8.1
- Composer >= 2.3
Installation
Add the plugin to the require section of your composer.json:
"require": { "mnsami/composer-custom-directory-installer": "^2.1" }
Important — Composer 2.2+ plugin trust:
Composer 2.2 and later require you to explicitly allow third-party plugins. Add the following to your composer.json:
"config": { "allow-plugins": { "mnsami/composer-custom-directory-installer": true } }
Without this, Composer will either prompt interactively or block the plugin entirely in non-interactive (CI) environments.
How to use
In the extra section of your root composer.json, define the custom directory for each package:
"extra": { "installer-paths": { "./monolog/": ["monolog/monolog"] } }
This tells Composer to install monolog/monolog into the ./monolog/ directory instead of vendor/monolog/monolog.
Path Variables
You can use the following variables in your installer-paths to build dynamic paths:
| Variable | Description | Example value |
|---|---|---|
{$vendor} |
The vendor portion of the package name | monolog |
{$name} |
The package name (or installer-name override) |
monolog |
{$type} |
The Composer package type | library |
"extra": { "installer-paths": { "./customlibs/{$vendor}/db/{$name}": ["doctrine/orm"], "./custom/{$type}/{$vendor}/{$name}": ["acme/*"] } }
Every package that matches a multi-package pattern (type:, vendor:, acme/*) must resolve to its own directory, so include {$name} (or another distinguishing variable) in such templates. Composer empties a package's target directory before installing into it, and two packages sharing one directory wipe each other on every install. To stop a typo from causing exactly that, an unknown variable such as {$nmae} is rejected with an InvalidArgumentException instead of resolving to an empty string.
Path Variable Flags
You can append transformation flags after a pipe (|) to modify how a variable is substituted:
{$token|flags}
Flags are applied left-to-right in the order given:
| Flag | Transformation | Example input | Example output |
|---|---|---|---|
F |
Capitalize first letter (ucfirst) |
my-package |
My-package |
P |
Strip hyphens/underscores and capitalize each following word | my-package |
myPackage |
U |
Uppercase all characters | my-package |
MY-PACKAGE |
Flags can be combined. FP together produces PascalCase (capitalize first + strip separators):
| Expression | Input | Output |
|---|---|---|
{$name|F} |
my-package |
My-package |
{$name|P} |
my-package |
myPackage |
{$name|FP} |
my-package |
MyPackage |
{$name|U} |
my-package |
MY-PACKAGE |
{$vendor|U} |
acme |
ACME |
Any other flag character is rejected: {$name|XZ} throws an InvalidArgumentException naming the placeholder.
Example:
"installer-paths": { "src/{$vendor|U}/{$name|FP}/": ["acme/my-package"], "modules/{$name|FP}/": ["type:drupal-module"] }
For a package acme/my-package (type library), this resolves to src/ACME/MyPackage/.
Matching Strategies
installer-paths supports three matching strategies. Precedence is evaluated globally across all entries: all entries are first scanned for an exact name match, then for a type match, then for a wildcard match. An exact match in a later-listed entry always wins over a wildcard match in an earlier-listed entry.
Matching is case-insensitive: Composer package names and types are lowercase, so Acme/Foo and type:WordPress-Plugin match acme/foo and wordpress-plugin. A trailing slash on a path template is dropped from the resolved path (./monolog/ installs to ./monolog).
1. Exact package name (highest precedence)
Matches one specific package:
"installer-paths": { "./libs/monolog/": ["monolog/monolog"] }
2. Package type prefix
Matches all packages of a given Composer type using the type: prefix:
"installer-paths": { "./wp-content/plugins/{$name}/": ["type:wordpress-plugin"] }
3. Wildcard vendor glob (lowest precedence)
Matches all packages from a given vendor using *. Patterns are shell-style globs, so ? and [...] work as well; any pattern containing *, ? or [ is treated as a glob:
"installer-paths": { "./acme-libs/{$name}/": ["acme/*"] }
vendor:acme is accepted as an alias of acme/* at the same precedence. It is the spelling used by composer/installers and oomphinc/composer-installers-extender, so existing configurations keep working:
"installer-paths": { "./acme-libs/{$name}/": ["vendor:acme"] }
Custom installer-name
A package can override the {$name} variable by setting installer-name in its own extra section (inside the package's composer.json, not the root project):
"extra": { "installer-name": "my-custom-name" }
When set, {$name} in the path template will resolve to my-custom-name instead of the package's actual name.
Supporting Custom Package Types
By default the plugin handles the library package type, and composer-plugin packages through its own PluginPlugin installer. To install packages of other types (e.g. drupal-module, wordpress-plugin) into custom directories, declare those types in extra.installer-types:
"extra": { "installer-types": ["drupal-module", "wordpress-plugin"], "installer-paths": { "web/modules/{$name}/": ["type:drupal-module"], "wp-content/plugins/{$name}/": ["type:wordpress-plugin"] } }
The plugin will claim any package whose type appears in installer-types and apply the matching installer-paths rule. A single type may also be given as a plain string ("installer-types": "drupal-module"). Without this list, packages of any other type are left to Composer's default installer.
Complete example
{
"require": {
"mnsami/composer-custom-directory-installer": "^2.1",
"monolog/monolog": "*",
"acme/foo": "*",
"acme/bar": "*"
},
"config": {
"allow-plugins": {
"mnsami/composer-custom-directory-installer": true
}
},
"extra": {
"installer-types": ["wordpress-plugin"],
"installer-paths": {
"./logger/": ["monolog/monolog"],
"./acme/{$name}/": ["acme/*"],
"./plugins/{$name}/": ["type:wordpress-plugin"]
}
}
}
Real-world use cases
WordPress with WPackagist
Packages from WPackagist are typed wordpress-plugin and wordpress-theme. Without a plugin that claims those types they land in vendor/; two installer-types entries route them where WordPress expects them:
{
"require": {
"mnsami/composer-custom-directory-installer": "^2.1",
"wpackagist-plugin/contact-form-7": "*",
"wpackagist-theme/twentytwentyfour": "*"
},
"config": {
"allow-plugins": {
"mnsami/composer-custom-directory-installer": true
}
},
"extra": {
"installer-types": ["wordpress-plugin", "wordpress-theme"],
"installer-paths": {
"wp-content/plugins/{$name}/": ["type:wordpress-plugin"],
"wp-content/themes/{$name}/": ["type:wordpress-theme"]
}
}
}
Docker / keeping vendor outside the web root
Put one package (e.g. a CLI tool) in a path that's bind-mounted into a container, while everything else stays in vendor/:
"extra": { "installer-paths": { "tools/{$name}/": ["vendor/some-cli-tool"] } }
Monorepos with sibling library directories
Route internal packages to their canonical location in the repo tree:
"extra": { "installer-types": ["company-module"], "installer-paths": { "modules/{$name}/": ["type:company-module"] } }
PascalCase or namespaced paths
Use path variable flags to match the naming convention your framework expects:
"extra": { "installer-paths": { "src/Modules/{$name|FP}/": ["acme/*"] } }
acme/my-module installs to src/Modules/MyModule/.
Migrating from oomphinc/composer-installers-extender
oomphinc/composer-installers-extender has not had a functional release since December 2021. This plugin is a maintained, drop-in alternative with a superset of its features (path variable flags, three-pass precedence, traversal guards).
Before:
"require": { "composer/installers": "^2.0", "oomphinc/composer-installers-extender": "^2.0" }, "config": { "allow-plugins": { "composer/installers": true, "oomphinc/composer-installers-extender": true } }, "extra": { "installer-types": ["drupal-module"], "installer-paths": { "web/modules/contrib/{$name}/": ["type:drupal-module"] } }
After:
"require": { "mnsami/composer-custom-directory-installer": "^2.1" }, "config": { "allow-plugins": { "mnsami/composer-custom-directory-installer": true } }, "extra": { "installer-types": ["drupal-module"], "installer-paths": { "web/modules/contrib/{$name}/": ["type:drupal-module"] } }
The extra.installer-types key is unchanged, and extra.installer-paths accepts the same entries — exact names, type: and vendor: — with two differences to check:
- Precedence is global, not first-entry-wins. oomphinc walks the entries in order and takes the first match. This plugin checks every entry for an exact package name first, then every entry for a
type:match, then every entry forvendor:and*globs (see Matching Strategies). If an earliertype:orvendor:entry used to shadow a later exact name, the exact name now wins. - Multi-package patterns need
{$name}in the path. Atype:,vendor:oracme/*entry whose path has no{$name}(or another distinguishing variable) sends every matching package to the same directory, and each install overwrites the previous one. The hazard existed with oomphinc too, but a precedence change can make a different rule apply to a package than before.
Otherwise only the require and allow-plugins entries change. If you required composer/installers solely for path routing (not for any package's own declared dependency), you can remove it too.
Security
Resolved install paths are validated against two attack vectors:
- Directory traversal — a resolved path containing
..throws anInvalidArgumentException. - Absolute path injection — a resolved path that is absolute (starting with
/,\, a UNC prefix such as\\server\share, or a Windows drive letter) throws anInvalidArgumentException. This can occur when a package'sinstaller-nameis set to an absolute path value.
Both checks apply after all {$variable} substitutions are complete.
Troubleshooting
Resolved install path '/tools/foo/' must be a relative path. or Resolved install path '../modules/foo/' contains '..', which is not allowed.
An installer-paths template resolved to an absolute path (leading / or a Windows drive letter) or climbed out of the project with ... Both are rejected by the path guards. Install paths are relative to the project root: write tools/{$name}/ rather than /tools/{$name}/, and keep targets inside the project rather than ../modules/{$name}/. If the template itself looks fine, check the package's own extra.installer-name — it is substituted for {$name} and can carry the offending characters.
Unknown variable '{$nmae}' in install path template 'lib/{$nmae}'. or Unknown flag 'X' in '{$name|XZ}'.
A placeholder has a typo. The variables are {$vendor}, {$name} and {$type}; the flags are F, P and U (see Path Variables). The plugin refuses to guess, because an unresolved variable would send every matching package to the same directory.
Two copies of a package after changing its path, or a directory left behind after removing it.
installer-paths only says where a package belongs now. When you change a package's path, Composer works out both the old and the new location from the current configuration, finds them identical, and installs a fresh copy at the new path without moving or deleting the old directory. The same happens when a package is removed after its installer-paths entry was changed or deleted: Composer looks for it where the current configuration points and leaves the old directory behind. Before running composer update after such a change, delete the old directory yourself. If Composer still reports the package as installed at the old location, remove its entry from vendor/composer/installed.json as well, or delete vendor/ and reinstall. Tracked in #15 and #22.
Upgrading to v2.1
v2.1 is where the platform requirements change: PHP >= 8.1 and Composer 2 only. v2.0 was a Composer 2 compatibility release that still ran on PHP 5.3 and Composer 1, so the table applies whether you come from v1.x or v2.0.
| <= v2.0 | v2.1+ | |
|---|---|---|
| PHP | >= 5.3 | >= 8.1 |
| Composer | 1.x / 2.x | 2.x only |
| Require string | "1.*" / "^2.0" |
"^2.1" |
type: matching |
No | Yes |
Wildcard vendor/* |
No | Yes |
{$type} variable |
No | Yes |
allow-plugins needed |
No | Yes (Composer 2.2+) |
installer-types support |
No | Yes |
Path variable flags (|F, |P, |U) |
No | Yes |
Existing installer-paths configurations (exact package names) are fully backwards-compatible and require no changes.
Note
Composer type: project is not supported by this installer, as packages with type project only make sense to be used with application shells like symfony/framework-standard-edition.