composer-custom-directory-installer
===================================

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?](#why-this-plugin)
- [Requirements](#requirements)
- [Installation](#installation)
- [How to use](#how-to-use)
- [Path Variables](#path-variables)
  - [Path Variable Flags](#path-variable-flags)
- [Matching Strategies](#matching-strategies)
  - [Exact package name](#1-exact-package-name-highest-precedence)
  - [Package type prefix](#2-package-type-prefix)
  - [Wildcard vendor glob](#3-wildcard-vendor-glob-lowest-precedence)
- [Custom `installer-name`](#custom-installer-name)
- [Supporting Custom Package Types](#supporting-custom-package-types)
- [Complete example](#complete-example)
- [Real-world use cases](#real-world-use-cases)
  - [WordPress with WPackagist](#wordpress-with-wpackagist)
  - [Docker / keeping vendor outside the web root](#docker--keeping-vendor-outside-the-web-root)
  - [Monorepos with sibling library directories](#monorepos-with-sibling-library-directories)
  - [PascalCase or namespaced paths](#pascalcase-or-namespaced-paths)
- [Migrating from oomphinc/composer-installers-extender](#migrating-from-oomphinccomposer-installers-extender)
- [Security](#security)
- [Upgrading from v1.x](#upgrading-from-v1x)
- [Note](#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`:

```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`:

```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:

```json
"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`          |

```json
"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:**

```json
"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:

```json
"installer-paths": {
    "./libs/monolog/": ["monolog/monolog"]
}
```

### 2. Package type prefix

Matches all packages of a given Composer type using the `type:` prefix:

```json
"installer-paths": {
    "./wp-content/plugins/{$name}/": ["type:wordpress-plugin"]
}
```

### 3. Wildcard vendor glob (lowest precedence)

Matches all packages from a given vendor using `*`:

```json
"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):

```json
"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`:

```json
"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
----------------

```json
{
    "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](https://wpackagist.org) don't require `composer/installers`, so native `installer-paths` silently fails for them. This plugin makes it work:

```json
{
    "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/`:

```json
"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:

```json
"extra": {
    "installer-types": ["company-module"],
    "installer-paths": {
        "../modules/{$name}/": ["type:company-module"]
    }
}
```

### PascalCase or namespaced paths

Use [path variable flags](#path-variable-flags) to match the naming convention your framework expects:

```json
"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`](https://packagist.org/packages/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:**

```json
"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:**

```json
"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 an `InvalidArgumentException`.
- **Absolute path injection** — a resolved path that is absolute (starting with `/` 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.

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`.
