Search by

mnsami / composer-custom-directory-installer

mnsami

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!

mnsami

Statistics

Installs: 5 814 324

Dependents: 72

Suggesters: 0

Stars: 144

Open Issues: 4


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

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 for vendor: and * globs (see Matching Strategies). If an earlier type: or vendor: entry used to shadow a later exact name, the exact name now wins.
  • Multi-package patterns need {$name} in the path. A type:, vendor: or acme/* 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 an InvalidArgumentException.
  • Absolute path injection — a resolved path that is absolute (starting with /, \, a UNC prefix such as \\server\share, or a Windows drive letter) throws an InvalidArgumentException. This can occur when a package's installer-name is 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.