Composer (PHP)
PHP's de-facto dependency manager: a per-project composer.json manifest, a SAT-based resolver ported from openSUSE's libzypp, and a vendor/ install tree with a generated autoloader — with no native workspace model, so PHP monorepos are assembled from path repositories, the replace key, symlinks, and third-party tooling like symplify/monorepo-builder.
| Field | Value |
|---|---|
| Language | PHP (requires PHP >= 7.2.5; Composer is itself written in PHP) |
| License | MIT |
| Repository | composer/composer |
| Documentation | getcomposer.org/doc |
| Category | Language Package Manager / Build System |
| Workspace model | None native. Monorepos are emulated via path repositories + replace + symlinks (+ external monorepo-builder) |
| First released | March 1, 2012 (development began April 2011) |
| Latest release | 2.10.1 |
Latest release:
2.10.1(the2.10.xline, June 2026). Composer 2.0 (October 2020) rewrote the resolver/installer for speed and introduced the explicit repository-priority model (canonical,exclude, package filters) that today's monorepo recipes depend on. Composer has no--filter,-p,--workspace, or--recursiveflag — a defining gap versus every JS/TS task orchestrator in this catalog.
Overview
What it solves
Composer is to PHP what npm is to Node, Cargo is to Rust, or Poetry is to Python: a single tool that reads a per-project manifest (composer.json), resolves a consistent set of transitive dependencies against a registry (Packagist), writes a composer.lock lockfile pinning exact versions, installs everything under vendor/, and generates a PSR-4/PSR-0 autoloader (vendor/autoload.php) so PHP code can require it once and have every class lazily loaded by namespace.
What Composer pointedly does not solve is the monorepo. There is no first-class notion of a "workspace" containing many member packages, no topological build/test pipeline across members, and no caching of per-member task results. Composer's unit of work is one root package and its dependency closure. A PHP monorepo — multiple libraries and apps in one git repository, depending on each other locally before any of them is published — is therefore assembled from lower-level primitives that were designed for other purposes:
pathrepositories point Composer at a sibling directory on disk and symlink it intovendor/, so an edit to a member library is immediately visible to a dependent app without a publish/updatecycle.- The
replacekey lets one package declare that it satisfies the requirements of others — the mechanism Symfony uses so that requiringsymfony/symfonytransparently fulfils everysymfony/*component requirement. - Repository priority (
canonical,exclude, package filters, introduced/formalized in Composer 2) lets the localpathrepository win over Packagist for the in-repo packages. - External tooling — most prominently
symplify/monorepo-builder— layers the missing workspace ergonomics (merging member manifests into the root, validating version alignment, splitting members out to read-only repos) on top.
Design philosophy
Composer is deliberately a dependency manager, not a build system or task runner. Its scripts feature can shell out to test/lint commands, but it has no task DAG, no change detection, and no build cache; PHP is interpreted, so there is usually nothing to "compile". From the schema documentation, the replace key — the closest thing Composer has to a monorepo primitive — is described as (doc/04-schema.md):
"This is also useful for packages that contain sub-packages, for example the main symfony/symfony package contains all the Symfony Components which are also available as individual packages. If you require the main package it will automatically fulfill any requirement of one of the individual components, since it replaces them."
That sentence is the whole monorepo story in one line: Composer never grew a [workspace] block (contrast Cargo or go.work); instead the community repurposed package-level features (replace, path repositories) and an external merge tool to approximate one. The resolver itself began life as a port of a Linux package manager's SAT solver — Composer's dependency-solving algorithm "started out as a PHP-based port of openSUSE's libzypp SAT solver" (Composer on Wikipedia) — which is why it reasons about a global, flat, conflict-free dependency set rather than per-member dependency trees the way pnpm or Yarn Berry do.
NOTE
Several of the five research dimensions below have no native Composer answer. Where that is the case this deep-dive says so explicitly and documents the community/idiomatic workaround, because the absence of a feature is itself the finding most relevant to a dub workspace proposal — dub today is in a very similar position (see the dub baseline once authored, and the D landscape).
How it works
The manifest, the lockfile, and the autoloader
A Composer project is rooted at a composer.json. The require and require-dev maps declare dependencies; require is "a map of packages required by this package. The package will not be installed unless those requirements can be met" (doc/04-schema.md), and require-dev adds packages "required for developing this package, or running tests" (skipped with --no-dev).
{
"name": "acme/blog-app",
"type": "project",
"require": {
"php": ">=8.2",
"acme/blog-core": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": { "Acme\\Blog\\": "src/" }
}
}composer install resolves the closure, writes composer.lock, installs into vendor/, and regenerates vendor/autoload.php. The autoload.psr-4 map binds a namespace prefix to a directory: "when autoloading a class like Foo\Bar\Baz with a namespace prefix Foo\ pointing to directory src/, the autoloader will look for a file named src/Bar/Baz.php." Critically, the root package and every installed dependency contribute their autoload maps to one merged classmap — this is how a symlinked monorepo member's classes become loadable from a dependent app with zero extra wiring.
Resolution: one global, flat, conflict-free set
Composer's resolver (the Pool/Solver in src/Composer/DependencyResolver/) computes a single flat set of package versions that satisfies every constraint simultaneously — there is exactly one installed version of any given vendor/name. This is the opposite of Node's nested node_modules and of the isolated per-package trees that pnpm's content-addressed store produces. The trade-off (discussed under Dependency Handling) is that two monorepo members cannot depend on conflicting versions of a shared library; the whole repo shares one resolution.
path repositories: the monorepo workhorse
The repositories array lets a project add package sources beyond Packagist. The path type points at a local directory:
{
"repositories": [
{
"type": "path",
"url": "../../packages/*",
"options": {
"symlink": true
}
}
],
"require": {
"acme/blog-core": "*"
}
}Behavior, quoted from doc/05-repositories.md:
"The local package will be symlinked if possible, in which case the output in the console will read
Symlinking from ../../packages/my-package. If symlinking is not possible the package will be copied."
You can force the strategy: "Instead of default fallback strategy you can force to use symlink with "symlink": true or mirroring with "symlink": false option." The URL supports globs — "Repository paths can also contain wildcards like * and ?" — which is the closest Composer comes to a glob-based member discovery mechanism (see Workspace Declaration).
Version derivation for a path package follows a hierarchy: "the version may be inferred by the branch or tag that is currently checked out. Otherwise, the version should be explicitly defined in the package's composer.json file. If the version cannot be resolved by these means, it is assumed to be dev-master." In practice monorepo members are required with "*" or "*@dev" so the symlinked working copy always satisfies the constraint.
Repository priority: making local win over Packagist
By default every repository is canonical — "as soon as a package is found in one, Composer stops looking in other repositories" (doc/05-repositories.md) — and custom repositories are consulted before Packagist because they appear first in the array. The repository-priorities article formalizes two knobs that monorepos rely on:
"canonical": falsemakes Composer "keep looking in other repositories, even if that repository contains a given package" — used when a local repo should supplement rather than shadow Packagist."exclude"removes specific packages from a repository's catalogue, e.g. excluding the in-repo packages from Packagist so thepathrepository is the only source:
{
"repositories": [
{ "type": "path", "url": "packages/*" },
{
"type": "composer",
"url": "https://packagist.org",
"exclude": ["acme/blog-core", "acme/blog-api"]
}
]
}The companion package filters (only / exclude) restrict which packages a repository may serve at all: "You can also filter packages which a repository will be able to load, either by selecting which ones you want, or by excluding those you do not want."
replace: sub-package provisioning
replace is "a map of packages that are replaced by this package" (doc/04-schema.md). In a monorepo it is used (notably by monorepo-builder, below) so the root package replaces every internal member at the exact same version, short-circuiting Packagist lookups for in-repo names. The docs warn: "You should then typically only replace using self.version as a version constraint, to make sure the main package only replaces the sub-packages of that exact version."
scripts: the only "task" mechanism
Composer's scripts map binds named events (lifecycle hooks like post-install-cmd, post-update-cmd, and arbitrary user-named scripts) to PHP callbacks or shell commands, run via composer run-script <name> (alias composer run, or just composer <name>):
{
"scripts": {
"test": "phpunit",
"lint": "php-cs-fixer fix --dry-run",
"ci": ["@lint", "@test"]
}
}This is a flat list of commands, not a DAG: @lint/@test references run sequentially in declared order with no dependency graph, no parallelism, no change detection, and no caching of results. There is no built-in way to run test across every member of a monorepo — that requires shelling out to a loop or to monorepo-builder/a Task runner.
monorepo-builder: the community workspace layer
Because Composer ships no workspace model, the de-facto standard is symplify/monorepo-builder, "a set of tools for managing PHP monorepos: merging composer.json files, validating package versions, releasing with automation, and more." Its core commands fill Composer's gaps:
| Command | What it does |
|---|---|
merge | Merges every member composer.json require/require-dev/autoload into the root manifest |
validate | "Checks that all packages use the same version for shared dependencies" (version alignment) |
bump-interdependency | "Updates mutual dependencies between packages to a given version" |
release | "Automates the release process: bumping dependencies, tagging, pushing, and updating changelogs" |
By default "monorepo-builder merge writes a replace section into the root composer.json listing every internal package at self.version" — exactly the replace + self.version pattern the schema docs recommend. Splitting members back out to individual read-only repositories (so consumers can composer require acme/blog-core from Packagist) is delegated to the symplify/github-action-monorepo-split GitHub Action.
The five dimensions
1. Workspace Declaration & Topology
No native workspace declaration. Composer has no [workspace] table, no members/packages array, and no virtual-root concept analogous to Cargo's [workspace], pnpm's pnpm-workspace.yaml, or go.work. A monorepo is discovered indirectly by a glob in a path repository URL:
{ "repositories": [{ "type": "path", "url": "packages/*" }] }That glob is the entire topology mechanism: Composer reads each matched subdirectory's composer.json, takes its declared name, and makes it installable. There is no exclusion list, no nested-workspace inheritance, and no distinction between a "root package workspace" and a "virtual workspace" — the root composer.json is always itself an installable package. monorepo-builder adds a packageDirectories() configuration (in monorepo-builder.php) to enumerate member globs for its merge/validate commands, but Composer proper never sees that file.
WARNING
Because the glob is evaluated relative to the root and members are plain path entries, there is no canonical "list the members" command in Composer itself. Tooling and CI scripts re-derive the member set from the glob or from monorepo-builder configuration — a recurring source of drift this catalog's comparison flags as the baseline failure mode that a native dub [workspace] block should fix.
2. Dependency Handling & Isolation
Composer uses a single global, flat, hoisted resolution: exactly one version of any package across the whole project, installed under one shared vendor/. There is no isolation between monorepo members — they share the one vendor/ and the one merged autoloader — which is the opposite of pnpm's strict, content-addressed, per-package symlink trees or Yarn Berry's Plug'n'Play.
Cross-member local references are expressed with path repositories + symlinks: member acme/blog-app does require: { "acme/blog-core": "*" }, and the path repository symlinks packages/blog-core into vendor/acme/blog-core. Edits to the library are seen immediately (no update needed) precisely because the symlink is the source tree. This is Composer's analogue of Yarn's workspace: protocol — but spelled out per-member, manually, with a glob and a version constraint, rather than as a first-class workspace:* resolver protocol.
The consequence of the flat model: two members cannot depend on conflicting versions of a shared third-party library — the resolver must find one version that satisfies both. This is simultaneously a strength (guaranteed single version, no diamond duplication) and a hard constraint (no per-member version divergence), and it is exactly what monorepo-builder validate exists to police.
3. Task Orchestration & Scheduling
No task DAG, no scheduler, no change detection. Composer's only task surface is scripts, a flat ordered list run sequentially per event. There is:
- No dependency graph between tasks (script
@athen@bis ordering, not a DAG). - No concurrency — scripts run one after another in one process.
- No input hashing or affected-detection — Composer never asks "which members changed since
HEAD~1"; it has no--since, no content hashing of task inputs, and no notion of a per-member task at all. - No "run X across all members" primitive (contrast
yarn workspaces foreachor Nx/Turborepo target graphs).
Monorepo task orchestration is therefore out of scope for Composer and is handled by a separate layer: a shell loop over the member glob, a Makefile / just / Task recipe set, or monorepo-builder for the manifest-merge/release pipeline specifically. This is the single largest delta between Composer and the JS/TS orchestrators in this survey.
4. Caching & Remote Execution
Composer has no build cache and no remote execution — and this is by design, because PHP is interpreted: there is no compiled artifact to cache or to execute remotely. What Composer does cache is package downloads and metadata:
- A local files cache of downloaded dist archives (
~/.cache/composer/files/) and a repo metadata cache (~/.cache/composer/repo/), so re-installs and cross-project installs avoid re-fetching from Packagist/dist URLs. composer.lockprovides reproducibility (pin exact versions), but it is a lockfile, not a task-result cache.
There is no REAPI / remote-execution backend, no remote build cache (contrast Bazel/Buck2 with BuildBuddy/NativeLink, or Turborepo's remote cache). The "build" of a monorepo member is, at most, regenerating the autoloader (composer dump-autoload --optimize builds a classmap), which is fast and uncached. For a dub proposal the lesson is narrow: Composer demonstrates a robust download/metadata cache and lockfile-based reproducibility, but contributes nothing on task-output caching.
5. CLI / UX Ergonomics
Composer's CLI is whole-project, not member-scoped. The core verbs are composer install, composer update [vendor/package], composer require "vendor/pkg:^2.0", composer remove, and composer run-script <name> (alias composer run / composer <name>). Crucially, per the CLI docs:
- There is no
--filter, no-p/--packagetarget selector, no--workspace, no--recursive, and no--since— none of the member-slicing ergonomics that define pnpm (--filter), Turborepo (--filter,--since), Nx (affected), or Yarn Berry (workspaces foreach). - The only "selection" available is glob string matching on package names for a few read/maintenance commands:
composer update "vendor/*",composer reinstall "acme/*",composer show "monolog/*". These match installed package names, not monorepo members as task targets.
So a developer in a Composer monorepo runs composer install once at the root (resolving every symlinked member together) and then drives per-member tasks through an external loop or runner. There is no colon-syntax :target, no -p app, no topological broadcast. The command boundary stops at "resolve and install the closure"; everything monorepo-shaped lives above the CLI.
Strengths
- Ubiquitous and battle-tested — the universal PHP dependency manager; every framework (Symfony, Laravel, Drupal) is Composer-native, and Packagist is a mature registry.
- Correct, single-version resolution — the SAT-based solver guarantees a flat, conflict-free dependency set, eliminating diamond duplication and "which copy am I loading?" ambiguity.
- Zero-friction local development of members —
pathrepositories with symlinks make edits to a monorepo library instantly visible to dependents without a publish/updatecycle. - Powerful repository-priority controls —
canonical,exclude, and packageonly/excludefilters give precise control over local-vs-remote sourcing, enough to build a working monorepo. - Merged autoloader across all packages — root + dependencies + symlinked members all contribute to one PSR-4 classmap, so cross-member imports need no extra configuration.
- Reproducible installs —
composer.lockplus a robust download/metadata cache.
Weaknesses
- No native workspace model at all — no
[workspace]block, no member array, no virtual root; the monorepo is reconstructed frompathrepositories +replace+ a glob, with no canonical "list members" command. - No task DAG, concurrency, or change detection —
scriptsis a flat sequential list; "run tests across all members" and "only what changed sinceHEAD~1" are entirely out of scope. - No build/test result caching or remote execution — only download/metadata caching (mitigated by PHP being interpreted, but a hard ceiling for orchestration).
- No member-slicing CLI — no
--filter/-p/--since/--recursive; member-scoped work needs an external loop or Task/just/Make. - Flat resolution forbids per-member version divergence — two members cannot use conflicting versions of a shared dependency.
- Reliance on third-party glue — robust PHP monorepos effectively require
symplify/monorepo-builderplus a split action, none of it official.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Dependency manager, not a build system | PHP is interpreted; the hard problem is resolution + autoloading, not compilation | No task DAG, no caching of task output, no orchestration — all pushed to external tools |
| Single flat, hoisted resolution (one version per package) | SAT solver (libzypp port) guarantees a conflict-free set; no diamond duplication | Monorepo members cannot diverge on a shared dependency's version; whole repo shares resolution |
No native workspace; reuse path repos + replace + symlinks | Avoids a new manifest concept; reuses package-level features already in the resolver | Topology is implicit (a glob); no member registry, no member-aware CLI; needs monorepo-builder |
Symlink path packages into vendor/ | Instant local feedback on member edits without publish/update | Symlink/copy divergence across OSes; replace can short-circuit the symlink if misconfigured |
Repository priority via canonical / exclude / filters | Precise, explicit control of local-vs-Packagist sourcing for in-repo names | Verbose, per-package configuration; easy to misorder and accidentally shadow or duplicate |
scripts as flat ordered command lists | Simple, dependency-free hook mechanism for lint/test/release | No DAG, no parallelism, no change detection, no cross-member broadcast |
| Lockfile + download/metadata cache (no task cache) | Reproducible installs and fast re-fetch across projects | Nothing accelerates repeated builds/tests; no remote cache or REAPI |
Sources
- composer/composer — GitHub repository (resolver lives in
src/Composer/DependencyResolver/) - Composer documentation — getcomposer.org/doc
doc/04-schema.md—replace,require,require-dev,version,autoload,scriptsdoc/05-repositories.md—pathrepositories, symlink option, wildcards, canonical lookupdoc/articles/repository-priorities.md—canonical,exclude, package filtersdoc/articles/scripts.md— named events andrun-scriptdoc/03-cli.md— command-line interface (no workspace/filter flags)- Composer issue #9368 — "How to prefer local path package with canonical over remote Packagist in Composer 2?"
symplify/monorepo-builder— community monorepo tooling (merge/validate/release)symplify/github-action-monorepo-split— splitting members to read-only repos- Composer (software) — Wikipedia (history, libzypp SAT-solver lineage)
- Packagist — the default Composer registry
- Sibling deep-dives: Cargo · npm · pnpm · Yarn Berry · go-work · Poetry · Nx · Turborepo · Task · just · Make · Bazel · Buck2 · BuildBuddy · NativeLink · comparison · dub baseline · D landscape