Disabling symlinks for local Composer package repositories

Last updated on
5 April 2026

This page has not yet been reviewed by Using Composer maintainer(s) and added to the menu.

This documentation needs review. See "Help improve this page" in the sidebar.

When a local Drupal module has no composer.json file, you must use a package-type Composer repository (as described on Tricks for using Composer in local development). With a package-type repository, the dist type is set to path, and Composer installs the package by symlinking to the local directory by default.

Symlinks can cause problems in several environments:

  • Docker with bind mounts - the symlink target may not be accessible from inside the container.
  • CI/CD pipelines - the build step that packages a deployable artifact (zip, tar, rsync) does not follow symlinks, so the linked module files are missing from the artifact.
  • Deployment environments - platforms that serve from a read-only or containerised filesystem (PaaS hosts, cloud functions, read-only image layers) require real files, not symlinks.

There are two ways to configure Composer to copy the package files instead of symlinking them: set transport-options in composer.json, or use the COMPOSER_MIRROR_PATH_REPOS environment variable.

Using composer.json

Add a transport-options key with "symlink": false directly inside the package definition:

"repositories": [
  {
    "type": "package",
    "package": {
      "name": "drupal/your_project_name",
      "version": "dev-1.0.x",
      "type": "drupal-module",
      "transport-options": {
        "symlink": false
      },
      "dist": {
        "url": "path/to/your/local/copy",
        "type": "path"
      }
    },
  {
    "type": "composer",
    "url": "https://packages.drupal.org/8"
  }
]

Composer will copy the package files instead of symlinking. You will see Mirroring from in the output when the setting is in effect.

Warning

transport-options.symlink is an internal Composer field and is not part of Composer's documented public API. It works as of Composer 2.x but could change in a future version without notice.

Using an environment variable

The COMPOSER_MIRROR_PATH_REPOS environment variable tells Composer to prefer mirroring (copying) for all path-based packages:

COMPOSER_MIRROR_PATH_REPOS=1 composer install

You can set this in your shell profile, a .env file, or your CI configuration.

If a package explicitly sets symlink to true in its transport-options, that package-level setting still takes precedence.

Which option to use

Use transport-options in composer.json when:

  • You need per-package control — for example, some packages should symlink while others should copy.
  • You want the behavior committed to composer.json and consistent across all environments without relying on an environment variable being set.

Use COMPOSER_MIRROR_PATH_REPOS when:

  • You want mirroring for all path packages without modifying composer.json.
  • You are concerned about relying on the undocumented transport-options field.
  • Your CI or deployment platform already sets this variable, meaning mirroring may already be active with no additional configuration.

Why transport-options works

transport-options.symlink is not documented as a valid key in a package repository definition, but it works because it replicates exactly what Composer sets internally for path-type repositories.

For a path-type repository, PathRepository::loadPackages() automatically copies the options.symlink setting from the repository definition into the package object's transport-options array (PathRepository.php, line 188):

$package['transport-options'] = array_intersect_key($this->options, ['symlink' => true, 'relative' => true]);

For a package-type repository there is no PathRepository involved, so that automatic step does not happen. Instead, ArrayLoader::load() reads transport-options directly from the inline package definition in composer.json (ArrayLoader.php, lines 302-303). By setting it explicitly, you are replicating exactly what PathRepository would have set automatically.

In both cases the end result is the same: PathDownloader::computeAllowedStrategies() reads $transportOptions['symlink'] from the package object to decide the install strategy (PathDownloader.php, lines 275-291):

Value Behavior
null (not set) Prefer symlink; fall back to mirror if symlinking fails.
true Symlink only; no fallback.
false Mirror (copy) only.

Setting transport-options.symlink explicitly in a package repository definition therefore produces exactly the same runtime behavior as configuring options.symlink in a path repository.


AI disclosure

This article was created with assistance from GitHub Copilot.

Help improve this page

Page status: Needs review

You can: