composer-custom-directory-installer
May 7, 2026 · View on GitHub
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-type packages, but you can extend it to any 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
- Upgrading from v1.x
- 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 has a built-in installer-paths key, but it only works if the package itself declares a dependency on composer/installers — something most packages on Packagist don't do. If you add an installer-paths rule for a package that doesn't opt in, Composer silently ignores it and installs the package to vendor/ anyway.
This plugin removes that constraint. It intercepts installation at the root-project level, so any package can be redirected to a custom path without requiring any changes to the package itself.
In short: if you've ever added installer-paths and nothing happened, this plugin is the fix.
Requirements
- PHP >= 8.1
- Composer 2.x
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/*"]
}
}
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 |
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.
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 *:
"installer-paths": {
"./acme-libs/{$name}/": ["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 only handles the library package type. 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. Without this list, packages of non-library types 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
Most WordPress plugins available via WPackagist don't require composer/installers, so native installer-paths silently fails for them. This plugin makes it work:
{
"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 and extra.installer-paths keys are identical — 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
/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.
Upgrading from v1.x
| v1.x | v2.x | |
|---|---|---|
| PHP | >= 5.3 | >= 8.1 |
| Composer | 1.x / 2.x | 2.x only |
| Require string | "1.*" | "^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.