Search by

pieceofcodero / symlink-composer-plugin

zoliszabo

Composer plugin for creating symlinks to packages, files, and folders in custom locations. Supports type-based, vendor-based, name-based, and path-based rules.

Package info

github.com/pieceofcodero/symlink-composer-plugin

Type:composer-plugin

pkg:composer/pieceofcodero/symlink-composer-plugin

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3 2026-11-12 08:04 UTC

This package is auto-updated.

Last update: 2026-11-12 08:36:35 UTC


README

A Composer plugin that creates symlinks for specific packages according to defined criteria.

Installation

Requires PHP 7.4 or PHP 8.x and Composer 2.

On Windows, PHP needs permission to create symbolic links; an elevated terminal may be required. Links use absolute targets on Windows, including when an installer places sources on another drive. Unix links use relative targets. After moving a Windows project, follow the recovery instructions to reset ownership tracking and recreate links with updated targets.

composer require pieceofcodero/symlink-composer-plugin

Composer 2.2 and later asks whether to allow this plugin to execute code. Approve it if you trust it. For noninteractive installs, add this setting to the root project's composer.json before running composer require or composer install:

{
    "config": {
        "allow-plugins": {
            "pieceofcodero/symlink-composer-plugin": true
        }
    }
}

Merge this entry into any existing config.allow-plugins settings. Without approval, a noninteractive install fails. See Composer's allow-plugins documentation.

Configuration

Add a symlink-paths section to the extra section of your composer.json:

{
    "extra": {
        "symlink-paths": {
            "public/components/{$name}": ["vendor:some-vendor", "type:component"],
            "public/js/{$name}": "vendor:js-vendor"
        }
    }
}

Linking individual files and folders

Use path:vendor/package/relative/path to link a specific file or folder from a package:

{
    "extra": {
        "symlink-paths": {
            "public/js/widget.js": "path:acme/widget/dist/widget.min.js",
            "public/css/widget.css": "path:acme/widget/dist/widget.css",
            "public/images/widget": "path:acme/widget/assets/images"
        }
    }
}

Each key is the exact destination of the symlink, including a filename for a file link. The first two source segments identify the package; the remainder is relative to its actual installation directory. Multiple path: destinations can link files or folders from the same package. Destination placeholders and package-provided path overrides also work with these rules.

Source paths must use / separators and nonempty segments. Absolute paths, . and .. segments, backslashes, control characters, and <>:"|?*{} are rejected. Segments cannot end with a dot or space. Sources are literal paths; glob patterns and source placeholders are not supported. The selected source must resolve to a regular file or directory inside the package. Source symlinks are allowed when they resolve within the package, including packages installed through a symlinked path repository.

Matching and precedence

  • Whole-package criteria are an exact package name, vendor:vendor, or type:type. Only the first matching whole-package destination is processed for each package.
  • Every destination whose matching criterion is path: is processed, alongside the first whole-package match.
  • An array of criteria uses OR matching. The first matching criterion selects the source for that destination. If that source is missing, the rule reports an error without trying a later criterion.
  • Valid rules for packages that are not installed are skipped.

For example, alternative packages can supply a file for the same destination:

{
    "extra": {
        "symlink-paths": {
            "public/widget.js": [
                "path:acme/widget/dist/widget.js",
                "path:other/widget/dist/widget.js"
            ]
        }
    }
}

If both packages are installed, the destination collision is reported using the same rules as other symlink destinations.

Usage

The plugin automatically creates symlinks when packages are installed or updated. It removes unchanged plugin-owned links when packages are uninstalled and reconciles configuration changes at the end of composer install and composer update, even when no packages needed installation or updating.

Source directories come from Composer's installation manager. Custom installer locations, custom vendor directories, legacy target-dir values, and symlinked path-repository packages are supported without extra plugin configuration. Matched packages without an installation directory, including metapackages, are reported as errors. Missing directories and installer lookup failures are also reported as errors while remaining packages continue to be processed.

Recreating all symlinks

If you need to recreate all symlinks (for example, after changing the configuration), use the dedicated command:

composer symlink-recreate-all

This command recreates all symlinks according to your configuration and cleans up obsolete plugin-owned links.

The command returns exit code 0 when all requested links succeed or there is nothing to do. It returns 1 if any requested link fails, including invalid paths, missing source directories, destination conflicts, ownership metadata errors, or filesystem errors during creation or cleanup. Failures are reported while the remaining packages continue to be processed. Other path: destinations for the same package are also processed after a failure. Malformed configuration is reported even when no packages are installed; an invalid destination rule is skipped while other valid rules are processed.

Destination collisions

If multiple packages resolve to the same destination, the first package to successfully create a link keeps it. Later conflicting packages are skipped with an error identifying both packages and the destination. This includes collisions caused by preferred-name overrides or equivalent paths through different parent symlinks, absolute paths, or dot segments.

For example, acme/widget and other/widget collide at public/{$name}. Use public/{$package} to give them separate destinations.

Existing symlinks are recreated when they already resolve to the expected source file or directory. An unchanged link recorded as owned by the same package can also be updated to a new source, including when its previous source no longer exists. Other links pointing elsewhere, including dangling links, are preserved and reported as conflicts. To intentionally change an unowned destination's source, remove the conflicting link or choose another destination. Existing regular files, directories, and special files are also preserved and reported as errors.

Cleaning up stale links

The plugin records links it successfully creates or recreates in <vendor-dir>/composer/symlink-composer-plugin.json. The record includes the canonical destination, package name, exact symlink target, and filesystem identity. The configured vendor directory is used, including custom locations. Access to the record is locked, and updates replace the metadata file atomically.

When a package is removed, only its recorded links are considered for deletion. After a successful package reconfiguration, obsolete destinations for that package are removed. A full reconciliation also cleans up links belonging to packages that are no longer installed. Removing all symlink-paths rules cleans up all unchanged recorded links. Parent directories are left in place.

A link is removed only if its identity and target still match the ownership record and its parent still resolves to the recorded directory. This also allows removal of owned dangling links. Replaced links, regular files, directories, unrelated links, and destinations under redirected parents are preserved; obsolete ownership records for them are forgotten. Links created before ownership tracking are left in place until a successful recreation records them.

On Windows, directory symlinks are removed with rmdir() and file symlinks with unlink(). Both operations remove the link itself and preserve its target. Windows path comparisons account for separators, ASCII casing, and short directory aliases such as RUNNER~1.

If any requested link for a package fails, that package's obsolete links are kept for a retry. Invalid root configuration suppresses obsolete-link cleanup while valid rules continue to run. Cleanup failures retain the affected records and cause symlink-recreate-all to return 1. Automatic reconciliation also reports failures to Composer through an exception.

The ownership file is local project state; do not commit or edit it. Missing metadata means existing links cannot be identified for cleanup. Invalid, unwritable, or symlinked metadata files stop processing with an error and preserve existing destinations. The record is scoped to the project's absolute directory; after moving or copying a project, remove the old ownership file to reset tracking, then run composer symlink-recreate-all. Unrecorded old links remain untouched.

On Windows, before running that command, also remove the old project symlinks whose absolute targets still point to the previous location, including dangling links. Remove only the links themselves, leaving their targets intact. Resetting ownership metadata does not allow the plugin to replace these links: they are preserved and reported as conflicts when they point somewhere other than the expected source in the new location.

Path Placeholders

The following placeholders are available for target paths:

  • {$name}: Package name without vendor (e.g., "package" from "vendor/package")
  • {$vendor}: Vendor name (e.g., "vendor" from "vendor/package")
  • {$package}: Full package name (e.g., "vendor/package")

Package-provided path overrides

A dependency can override these placeholder values in its own composer.json:

{
    "extra": {
        "symlink-preferred-name": "custom-name",
        "symlink-preferred-vendor": "custom-vendor",
        "symlink-preferred-package": "custom-vendor/custom-name"
    }
}

Overrides must be relative paths with nonempty segments separated by /. Nested paths are supported. Absolute paths, . and .. segments, backslashes, control characters, and the characters <>:"|?*{} are rejected. Segments cannot end with a dot or space. Omitted, null, or empty-string values use the package's default name, vendor, or full name. Only overrides used by the destination's placeholders are validated.

The literal directory before the first placeholder defines the destination boundary. For example, public/components/{$package} keeps generated links inside public/components. Existing parent symlinks cannot redirect a generated path outside that boundary. The root project may explicitly choose an absolute, parent-relative, or symlinked base directory.

Invalid overrides or escaping destinations are reported and skipped before creating directories or replacing links.

Development

Install development dependencies with composer install, then run the PHPUnit regression suite with composer test. To run a subset, pass PHPUnit options through Composer, for example composer test -- --filter RejectsInvalidMetadata.

The suite uses PHPUnit 9.6, which runs on the minimum supported PHP version, 7.4. Composer resolves dependencies against PHP 7.4.0 so development dependencies remain compatible with that minimum even when installed using a newer PHP.

GitHub Actions runs composer validate --strict, installs development dependencies, checks the actual runtime requirements with composer check-platform-reqs, and runs composer test on Linux and Windows with PHP 7.4 and PHP 8.0–8.5. The workflow runs on pushes and pull requests and can also be started manually. Each PHP version runs independently on each operating system so a failure does not cancel the other jobs.

The regression suite covers package and individual-path linking, preservation of existing files and unrelated links, destination collisions, traversal protection, custom installation paths, invalid configuration, and command failure reporting. Cleanup tests cover ownership persistence, configuration changes, package removal, user replacements, redirected parents, corrupt metadata, and retrying failures. Permission-failure tests require a non-root POSIX user; FIFO tests require the POSIX extension. These tests are skipped on Windows; the remaining suite runs on both operating systems. The POSIX extension is requested only for Linux jobs. Windows fixtures create links to existing targets before making them dangling; self-referencing fixtures are built by moving a link onto its former target. Assertions compare filesystem identities so long paths and 8.3 aliases can refer to the same object. Windows-specific tests also cover path casing, drive roots, and custom installer sources on another drive when one is available.