A set of tools for managing PHP monorepos: merging composer.json files, validating package versions, releasing with automation, and more.
composer require monorepo-php/monorepo --devRequires PHP 8.2+. For PHP 8.1, use symplify/monorepo-builder:^11.2 (no longer maintained).
# 1. Scaffold a basic monorepo layout (one time)
vendor/bin/monorepo-builder init
# 2. Fold every package's composer.json into the root composer.json
vendor/bin/monorepo-builder merge
# 3. Cut a release when you're ready
vendor/bin/monorepo-builder release v1.0All configuration goes in monorepo-builder.php at your project root. See Configuration for the full list of options.
Generates a basic monorepo skeleton (a packages/ directory and a starter monorepo-builder.php) so you can start adding packages immediately:
vendor/bin/monorepo-builder initRun once at the start of a new monorepo. Existing files are not overwritten.
Merges all sections from package composer.json files into the root composer.json. For the reverse direction, see propagate.
vendor/bin/monorepo-builder mergeBehavior:
- All sections are merged, including standard (
require,autoload, etc.) and custom ones (scripts-aliases,abandoned, etc.) - If a package appears in both
requireandrequire-dev, therequireentry takes priority - The original key order of the root
composer.jsonis preserved; new sections are appended at the end
To customize what gets merged (append / remove data, reorder sections, skip autoload merging, etc.) see Customizing merge output.
Checks that all packages use the same version for shared dependencies:
vendor/bin/monorepo-builder validateUpdates mutual dependencies between packages to a given version:
vendor/bin/monorepo-builder bump-interdependency "^4.0"Propagates versions from root composer.json back to each package's composer.json (the reverse of merge):
vendor/bin/monorepo-builder propagateUpdates the branch-alias in every package composer.json to match the current version:
vendor/bin/monorepo-builder package-aliasTo customize the alias format string, see Custom alias format under Configuration.
Sets mutual package paths to local packages for pre-split testing:
vendor/bin/monorepo-builder localize-composer-pathsAutomates the release process: bumping dependencies, tagging, pushing, and updating changelogs.
vendor/bin/monorepo-builder release v7.0Preview what will happen without making changes:
vendor/bin/monorepo-builder release v7.0 --dry-runRelease by semver level (patch, minor, or major):
# current v0.7.1 → v0.7.2
vendor/bin/monorepo-builder release patchThe default pipeline runs TagVersionReleaseWorker followed by PushTagReleaseWorker. To customize the pipeline (add workers, reorder, disable defaults, enable LTS-aware tag resolution), see Customizing the release pipeline.
All configuration lives in monorepo-builder.php at your project root. Every option below is set on the MBConfig instance passed into the configurator closure:
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
// your configuration here
};By default, packages are discovered from ./packages. To customize:
return static function (MBConfig $mbConfig): void {
$mbConfig->packageDirectories([
__DIR__ . '/packages',
__DIR__ . '/projects',
]);
// exclude specific packages
$mbConfig->packageDirectoriesExcludes([__DIR__ . '/packages/secret-package']);
};These options shape what vendor/bin/monorepo-builder merge writes into the root composer.json.
use Symplify\MonorepoBuilder\ComposerJsonManipulator\ValueObject\ComposerJsonSection;
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\ValueObject\Option;
return static function (MBConfig $mbConfig): void {
// add data after merge (supports any composer.json key)
$mbConfig->dataToAppend([
ComposerJsonSection::AUTOLOAD_DEV => [
'psr-4' => [
'Symplify\Tests\\' => 'tests',
],
],
ComposerJsonSection::REQUIRE_DEV => [
'phpstan/phpstan' => '^2.1',
],
]);
// remove data after merge
$mbConfig->dataToRemove([
ComposerJsonSection::REQUIRE => [
// removed by key, version is irrelevant
'phpunit/phpunit' => '*',
],
ComposerJsonSection::REPOSITORIES => [
Option::REMOVE_COMPLETELY,
],
]);
};By default, the original key order of root composer.json is preserved. To enforce a specific order:
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\Merge\JsonSchema;
return static function (MBConfig $mbConfig): void {
$mbConfig->composerSectionOrder(JsonSchema::getProperties());
};By default, every internal package's autoload and autoload-dev PSR-4 entries are folded into the root composer.json so that vendor/bin/phpunit and other root-level tooling can resolve every namespace. The three scenarios below cover when you'd want to skip part or all of that merging — pick the one that matches your monorepo:
Scenario 1 — Default monorepo of libraries. No skip needed. The root composer.json autoload aggregates every internal library's PSR-4 mapping, so any namespace resolves from the root vendor.
Scenario 2 — Mixed monorepo with libraries symlinked + apps not required from root. When disablePackageReplace() is on (libraries are real path-repo deps, Composer symlinks them into vendor/), the libraries' autoload is registered automatically by Composer via vendor/composer/autoload_psr4.php. Folding them into the root composer.json would duplicate that registration. Skip autoload merging for libraries only — apps' autoload still merges so root-level scripts can find them:
use Symplify\MonorepoBuilder\Config\AutoloadSection;
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\Config\PackageType;
return static function (MBConfig $mbConfig): void {
$mbConfig->disablePackageReplace();
$mbConfig->disableAutoloadMerge(
sections: [AutoloadSection::Autoload],
forTypes: [PackageType::Library],
);
};Result: root autoload contains apps' PSR-4 entries but NOT libraries'. Root autoload-dev still aggregates everything (see "Why autoload-dev is independent" below).
Scenario 3 — Custom merge. To turn off both sections entirely (you handle merging yourself, e.g. via a custom decorator):
use Symplify\MonorepoBuilder\Config\AutoloadSection;
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
$mbConfig->disableAutoloadMerge(
sections: [AutoloadSection::Autoload, AutoloadSection::AutoloadDev],
forTypes: [],
);
};Result: root autoload and autoload-dev are untouched by monorepo-builder merge.
API reference:
disableAutoloadMerge(array $sections, array $forTypes)— both arguments are required.$sections: a non-empty list ofAutoloadSectioncases (Autoload,AutoloadDev).$forTypes: a list of composer.jsontypefilter values. Each element may be either aPackageTypeenum case (preferred for the four Composer schema types:Library,Project,Metapackage,ComposerPlugin) or a non-empty string (escape hatch for ecosystem types defined bycomposer/installerssuch as'wordpress-plugin','drupal-module','symfony-bundle', and for user-defined custom types). Mixing enum cases and strings in the same call is allowed; multiple types are OR-matched. The two filter channels are intentionally distinct: pass an empty array (forTypes: []) to skip every package regardless of type, OR pass a non-empty list of types to skip ONLY packages whosecomposer.jsondeclares the matchingtypeliterally. Composer's "missingtypedefaults to library" rule does NOT extend the filter — a package without an explicittypefield is NOT swept up byforTypes: [PackageType::Library]. If you want the filter to catch an untyped package, declaretype: library(or whichever) in that package'scomposer.json.
- Repeated calls follow last-call-wins semantics PER section. Calls touching different sections compose; calls touching the same section override.
Migrating from the previous binary API: The earlier zero-argument form $mbConfig->disableAutoloadMerge(); continues to work but is deprecated and emits an E_USER_DEPRECATED notice. It maps to the full-kill behavior — equivalent to disableAutoloadMerge(sections: [AutoloadSection::Autoload, AutoloadSection::AutoloadDev], forTypes: []). Update existing config files at your convenience. The legacy MBConfig::isAutoloadMergeDisabled() getter is also kept as a deprecated convenience that returns true only when both sections are configured to skip merging for all packages — prefer MBConfig::shouldSkipAutoload($packageType) and MBConfig::shouldSkipAutoloadDev($packageType) for new code.
Composer treats autoload-dev as a root-only section: dev autoload entries from path-repo dependencies are NEVER registered in the consumer's vendor/composer/autoload_psr4.php. (See Composer schema docs — autoload-dev.)
Practical consequence: if your CI runs vendor/bin/phpunit from the monorepo root and expects to discover library test classes, those test classes are reachable ONLY because monorepo-builder merge has folded each library's autoload-dev PSR-4 into the root composer.json. Skipping AutoloadSection::AutoloadDev from root merge therefore breaks cross-package PHPUnit discovery — skip it only when you're handling test discovery another way.
By default, monorepo-builder merge writes a replace section into the root composer.json listing every internal package at self.version. This is correct for monorepos that publish a single combined dependency surface — Composer then refuses to install any external copy of those packages.
Some monorepos do NOT want this:
- Apps that require their own internal libraries via
pathrepositories and rely on Composer's symlink installation (thereplaceentry would short-circuit the symlink) - Monorepos with mixed
type: librarypackages andtype: projectapps where the apps need real installs of the libs
To skip writing the replace section entirely:
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
$mbConfig->disablePackageReplace();
};This pairs naturally with Scenario 2 of the autoload skip section above. With both opt-outs on, your path-repository-based libraries get symlink-installed by Composer and only your apps' autoload entries land in the root composer.json.
These options shape what vendor/bin/monorepo-builder release does on each invocation.
TagVersionReleaseWorker and PushTagReleaseWorker are enabled by default. Add more workers or customize the order:
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\AddTagToChangelogReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\PushNextDevReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\PushTagReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\SetCurrentMutualDependenciesReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\SetNextMutualDependenciesReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\TagVersionReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\UpdateBranchAliasReleaseWorker;
use Symplify\MonorepoBuilder\Release\ReleaseWorker\UpdateReplaceReleaseWorker;
return static function (MBConfig $mbConfig): void {
$mbConfig->workers([
UpdateReplaceReleaseWorker::class,
SetCurrentMutualDependenciesReleaseWorker::class,
AddTagToChangelogReleaseWorker::class,
TagVersionReleaseWorker::class,
PushTagReleaseWorker::class,
SetNextMutualDependenciesReleaseWorker::class,
UpdateBranchAliasReleaseWorker::class,
PushNextDevReleaseWorker::class,
]);
};To disable the default workers (and define your pipeline from scratch):
return static function (MBConfig $mbConfig): void {
$mbConfig->disableDefaultWorkers();
};You can also add custom workers by implementing ReleaseWorkerInterface.
If you maintain multiple version lines, the release command may reject older versions because it compares against the most recent tag globally. Enable branch-aware validation to compare only within the same major version:
use Symplify\MonorepoBuilder\Config\MBConfig;
use Symplify\MonorepoBuilder\Contract\Git\TagResolverInterface;
use Symplify\MonorepoBuilder\Git\BranchAwareTagResolver;
return static function (MBConfig $mbConfig): void {
$services = $mbConfig->services();
$services->set(BranchAwareTagResolver::class);
$services->alias(TagResolverInterface::class, BranchAwareTagResolver::class);
};vendor/bin/monorepo-builder package-alias writes a branch-alias entry into every package composer.json. To override the format string used:
use Symplify\MonorepoBuilder\Config\MBConfig;
return static function (MBConfig $mbConfig): void {
// default: "<major>.<minor>-dev"
$mbConfig->packageAliasFormat('<major>.<minor>.x-dev');
};To split packages into separate repositories, use symplify/github-action-monorepo-split with GitHub Actions.